Using TryLock in Go

Go documentation for sync package states that:

Note that while correct uses of TryLock do exist, they're rare, and use of TryLock is often a sign of a deeper problem in a particular use of mutexes.

I believe I have found correct usage of TryLock mechanism, but before we dive in, let's briefly explain what TryLock is.

What is TryLock?

TryLock is a method on the sync.Mutex and sync.RWMutex types that attempts to acquire a lock on the mutex without blocking. This contrasts with the Lock method, which blocks until the lock is acquired.

How does it work?

If the lock is already held by another goroutine, TryLock returns false immediately without blocking.

If the lock is not held by another goroutine, TryLock acquires the lock and returns true.

Why is it useful?

The TryLock is useful when not acquiring the lock is okay. In this blog post, I will demonstrate such case.

A usage of TryLock

go
package client

import (
	"sync"
	"time"
)

type Client struct {
	// ... other fields
	mu        sync.RWMutex
	expiresAt time.Time
	token     string
}

// GetToken returns valid token.
func (c *Client) GetToken() (string, error) {
	if err := c.tryRefresh(); err != nil {
		return "", err
	}

	c.mu.RLock()
	defer c.mu.RUnlock()

	return c.token, nil
}

func (c *Client) tryRefresh() error {
	c.mu.RLock()
	if time.Now().Before(c.expiresAt) {
		c.mu.RUnlock()
		return nil
	}
	c.mu.RUnlock()

	if !c.mu.TryLock() {
		return nil
	}
	defer c.mu.Unlock()

	// refresh token
	c.expiresAt = time.Now().Add(1 * time.Hour)
	c.token = "new token"
	return nil
}

Why is it correct?

In a highly concurrent environment, it is possible and probable that multiple goroutines will encounter expired token. When that happens, we can consider two cases:

  1. Our call to tryRefresh is first, and we acquired the lock. In such case we will refresh and assign new token to c.token field. After that, we will release the lock and return the token.
  2. Our call to tryRefresh is not the first one, and we didn't acquire the lock. That means we can assume that another goroutine is refreshing the token, and we can just return. In that situation it's much better to wait on RLock because it will be unblocked when token is refreshed.

What is not correct?

Unfortunately, the code above has a bug. Imagine the case when we're refreshing the token and it fails. In such case we should return an error from tryRefresh function. Although this behaviour is correct, let's look at this situation from other goroutines' perspective.

So, we're waiting for the read lock to be released hoping that after that we will return valid token. Unfortunately, the token refresh failed and c.token will be either empty or contain expired token.

It doesn't sound good. How can we fix it?

Improving the code

I see two ways of fixing this problem:

  1. Make tryRefresh run until the token is refreshed. We can achieve that by using retries and exponential backoff.
  2. We can introduce a tokenErr field that will hold the error from the last token refresh.

I will go with the second option because it's simpler, although the best approach would be to combine those two approaches. Let's see how the code will look now.

go
package client

import (
	"sync"
	"time"
)

type Client struct {
	// ... other fields
	mu        sync.RWMutex
	expiresAt time.Time
	token     string
	tokenErr  error
}

// GetToken returns valid token or error if acquiring token fails.
func (c *Client) GetToken() (string, error) {
	if err := c.tryRefresh(); err != nil {
		return "", err
	}

	c.mu.RLock()
	defer c.mu.RUnlock()

	return c.token, c.tokenErr
}

func (c *Client) tryRefresh() error {
	c.mu.RLock()
	if time.Now().Before(c.expiresAt) {
		c.mu.RUnlock()
		return nil
	}
	c.mu.RUnlock()

	if !c.mu.TryLock() {
		return nil
	}
	defer c.mu.Unlock()

	// refresh token
	c.expiresAt = time.Now().Add(1 * time.Hour)
	c.token = "new token"
	c.tokenErr = nil
	return nil
}

Does that fix the problem?

It seems like it does. Now, we will either always return a valid token or error if refreshing the token fails regardless of the order of calls to tryRefresh.

Summary

In this blog post I demonstrated a usage of TryLock mechanism. I hope you enjoyed it and learned something new.