On Sun, Jan 29, 2023 at 7:34 PM Diego Augusto Molina < diegoaugustomol...@gmail.com> wrote:
> 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: > So how about using sync.Once: type CacheEntry struct { Auth AuthInfo once sync.Once } Cache would be a map[CacheKey]*CacheEntry. When AuthInfo expires, you simply replace it with a new one: func (c *Cache) Expire(key CacheKey) { c.Lock() defer c.Unlock() c.cache[key]=&CacheEntry{} } func (c *Cache) Get(key CacheKey) Auth { c.RLock() entry:=c.cache[key] c.RUnlock() if entry==nil { c.Lock() entry=c.cache[key] if entry==nil { c.cache[key]=&CacheEntry{} } c.UnLock() } entry.Do(func() { // Authenticate }) return entry.Auth } > > *<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: > > 1. Get an upgradable lock > 2. 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". > 3. Return the value and the error. > > > Kind regards. > > -- > You received this message because you are subscribed to the Google Groups > "golang-nuts" group. > To unsubscribe from this group and stop receiving emails from it, send an > email to golang-nuts+unsubscr...@googlegroups.com. > To view this discussion on the web visit > https://groups.google.com/d/msgid/golang-nuts/0fe9c0f3-a703-45f3-88b7-bbfc29111b2en%40googlegroups.com > <https://groups.google.com/d/msgid/golang-nuts/0fe9c0f3-a703-45f3-88b7-bbfc29111b2en%40googlegroups.com?utm_medium=email&utm_source=footer> > . > -- You received this message because you are subscribed to the Google Groups "golang-nuts" group. To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts+unsubscr...@googlegroups.com. To view this discussion on the web visit https://groups.google.com/d/msgid/golang-nuts/CAMV2RqqorOvzFsFKObrBbRwEzrns-p6CrKhyn5_M8_THN-cvTQ%40mail.gmail.com.