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
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:
-
Our call to
tryRefreshis first, and we acquired the lock. In such case we will refresh and assign new token toc.tokenfield. After that, we will release the lock and return the token. -
Our call to
tryRefreshis 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 onRLockbecause 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:
-
Make
tryRefreshrun until the token is refreshed. We can achieve that by using retries and exponential backoff. -
We can introduce a
tokenErrfield 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.
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.