From times to times I write a scraper or some other tool that would
authenticate to a service and then use the auth result to do stuff
concurrently. But when auth expires, I need to synchronize all my
goroutines and have a single one do the re-auth process, check the
status, etc. and then arrange for all goroutines to go back to work
using the new auth result.
To generalize the
problem: multiple goroutines read a cached value that expires at some
point. When it does, they all should block and some I/O operation has to be performed by a single
goroutine to renew the cached value, then unblock all other goroutines
and have them use the new value.
I solved this
in the past in a number of ways: having a single goroutine that handles
the cache by asking it for the value through a channel, using sync.Cond
(which btw every time I decide to use I need to carefully re-read its docs
and do lots of tests because I never get it right at first). But what I
came to do lately is to implement an upgradable lock and have every
goroutine do:
<code>
func (x implem) getProtectedValue() (someType, error) {
// acquires a read lock that can be upgraded
lock := x.upgradableLock.UpgradableRLock()
// the Unlock method of the returned lock does the right thing
// even if we later upgrade the lock
defer lock.Unlock()
// here we have read access to x.protectedValue
// if the cached value is no longer valid, upgrade the lock
// and update it
if !isValid(x.protectedValue) && lock.TryUpgrade() {
// within this if, we know we *do* have write access
// to x.protectedValue
x.protectedValue, x.protectedValueError = newProtectedValue()
}
// here we can only say everyone has read access to x.protectedValue
// (the goroutine that got the lock upgrade could still write
// here but as this code is shared, we should check the result
// of the previous lock.TryUpgrade() again)
return x.protectedValue, x.protectedValueError
}
</code>
The implementation is quite simple:
<code>
// Upgradable implements all methods of sync.RWMutex, plus a new
// method to acquire a read lock that can optionally be upgraded
// to a write lock.
type Upgradable struct {
readers sync.RWMutex
writers sync.RWMutex
}
func (m *Upgradable) RLock() { m.readers.RLock() }
func (m *Upgradable) TryRLock() bool { return m.readers.TryRLock() }
func (m *Upgradable) RUnlock() { m.readers.RUnlock() }
func (m *Upgradable) RLocker() sync.Locker { return m.readers.RLocker() }
func (m *Upgradable) Lock() {
m.writers.Lock()
m.readers.Lock()
}
func (m *Upgradable) TryLock() bool {
if m.writers.TryLock() {
if m.readers.TryLock() {
return true
}
m.writers.Unlock()
}
return false
}
func (m *Upgradable) Unlock() {
m.readers.Unlock()
m.writers.Unlock()
}
// UpgradableRLock returns a read lock that can optionally be
// upgraded to a write lock.
func (m *Upgradable) UpgradableRLock() *UpgradableRLock {
m.readers.RLock()
return &UpgradableRLock{
m: m,
unlockFunc: m.RUnlock,
}
}
// UpgradableRLock is a read lock that can be upgraded to a write
// lock. This is acquired by calling (*Upgradable).
// UpgradableRLock().
type UpgradableRLock struct {
mu sync.Mutex
m *Upgradable
unlockFunc func()
}
// TryUpgrade will attempt to upgrade the acquired read lock to
// a write lock, and return whether it succeeded. If it didn't
// succeed then it will block until the goroutine that succeeded
// calls Unlock(). After unblocking, the read lock will still be
// valid until calling Unblock().
//
// TryUpgrade panics if called more than once or if called after
// Unlock.
func (u *UpgradableRLock) TryUpgrade() (ok bool) {
u.mu.Lock()
defer u.mu.Unlock()
if u.m == nil {
panic("TryUpgrade can only be called once and not after Unlock")
}
if ok = u.m.writers.TryLock(); ok {
u.m.readers.RUnlock()
u.m.readers.Lock()
u.unlockFunc = u.m.Unlock
} else {
u.m.readers.RUnlock()
u.m.writers.RLock()
u.unlockFunc = u.m.writers.RUnlock
}
u.m = nil
return
}
// Unlock releases the lock, whether it was a read lock or a write
// lock acquired by calling Upgrade.
//
// Unlock panics if called more than once.
func (u *UpgradableRLock) Unlock() {
u.mu.Lock()
defer u.mu.Unlock()
if u.unlockFunc == nil {
panic("Unlock can only be called once")
}
u.unlockFunc()
u.unlockFunc = nil
u.m = nil
}
</code>
I
obviously try to avoid using it for other than protecting values that
require potentially long I/O operations (like in my case
re-authenticating to a web service) and also having a lot of interested
goroutines. The good thing is that most of the time this pattern only
requires one RLock, but the (*UpgradableRLock).Unlock requires an
additional lock/unlock to prevent misusage of the upgradable lock (I
could potentially get rid of it but preferred to keep it in the side of
caution). Another thing I don't like is that I need to allocate for each
UpgradableRLock. I'm thinking to re-define UpgradableRLock to be a
defined type of Upgradable and convert the pointer type of the receiver
to *Upgradable whenever needed (also getting rid of the lock that
prevents misusage).
I wanted to ask the
community what do you think of this pattern, pros and cons, and whether
this is overkill and could be better solved with something obvious I'm
missing (even though I did my best searching).
The reason why I decide to use this pattern is that all other options seem to make my code more complex and less readable. I generally just implement one method that does all the value-getting and it ends up being quite readable and easy to follow:
- Get an upgradable lock
- If the value is stale and I get to upgrade the lock, then update the value and store the error as well. The "update" part is generally implemented as a separate method or func called "updateThingLocked".
- Return the value and the error.
Kind regards.