RWMutex Cache
A sync.Mutex lets one goroutine in at a
time, and that is right when the code inside changes something. For a cache it
is wasteful: reads do not interfere with each other, and a cache is almost all
reads.
sync.RWMutex splits the lock in two.
c.mu.RLock() // a read lock: any number at once
defer c.mu.RUnlock()
c.mu.Lock() // a write lock: one, and no readers
defer c.mu.Unlock()
Many RLock holders can hold it together. A Lock waits until every reader has
let go, and while a writer is waiting new readers queue behind it - which is
what stops a stream of readers starving the writer for ever.
The pairs do not mix: RLock is released by RUnlock, Lock by Unlock.
Mismatch them and you get a runtime panic or a permanent hang.
It is not free. An RWMutex is slower than a Mutex for a lock held only
briefly, so the rule of thumb is plain Mutex first, RWMutex once you have
many readers and long enough read sections to pay for it.
Not reentrant, in either direction
A goroutine that already holds the read lock must not take it again, and must
never take the write lock while holding the read lock. Both deadlock. That is
why ForEach here documents that f must not call back into the cache: f
runs while the read lock is held.
The double check
Reading first and writing only if you must is the standard cache shape, and the gap in the middle is the whole lesson:
c.mu.RLock()
v, ok := c.entries[key]
c.mu.RUnlock()
if ok {
return v, false
}
c.mu.Lock()
defer c.mu.Unlock()
if v, ok := c.entries[key]; ok { // look again
return v, false
}
...
Between RUnlock and Lock this goroutine holds no lock at all. Any number
of other goroutines can run in that window, and one of them may store exactly
the key you are about to compute. Without the second check both of you compute
it and the loser's value is thrown away - or worse, kept, if compute opened
something that now leaks.
Upgrading a read lock to a write lock in place is not possible in Go, and that is deliberate: two goroutines both trying to upgrade would deadlock. Release, retake, look again.
Computing under the lock
GetOrCompute calls compute while holding the write lock. That is what
makes "exactly once" true, and it costs exactly what you would expect: every
other key waits behind it, however unrelated.
The last concept's rule - do not hold a lock across a caller's function - is being broken on purpose here, and it is a real trade. The version that does not break it needs a lock per key, so that two different keys can be computed at the same time while two callers of the same key still share one call. That is the next challenge.
Task
Implement Cache: Get, Set, Len, Keys, ForEach and GetOrCompute.
Hints
- Reads take
RLock, writes takeLock.Get,Len,KeysandForEachare all reads;Setis a write;GetOrComputeis a read that may become a write. - The map is
niluntil something writes to it. Only the two write paths need to create it. Keysreturnsmake([]string, 0, len(c.entries))filled and sorted withslices.Sort, so an empty cache gives an empty non-nil slice.GetOrComputereturnscomputedfalseon a hit, including the hit it finds on the second look.- The tests hold a barrier open inside
ForEach'sfacross three concurrent calls. Only a read lock lets all three arrive.