Design a thread-safe loading cache. Why is naive double-checked locking with a plain flag wrong in Go?
Question 252HardGo 1.22 to 1.25
Naive double-checked locking reads a shared flag or pointer without synchronization:
if !initialized { // racy read: may see true before the data writes are visible
mu.Lock()
...
}
That is a data race, and under the memory model the reader can observe initialized == true without seeing the data it guards. Use sync.Once or OnceValue, an atomic, or re-check under a lock:
type Cache[K comparable, V any] struct {
mu sync.RWMutex
m map[K]V
sf singleflight.Group
load func(context.Context, K) (V, error)
key func(K) string
}
func (c *Cache[K, V]) Get(ctx context.Context, k K) (V, error) {
c.mu.RLock()
v, ok := c.m[k]
c.mu.RUnlock()
if ok {
return v, nil
}
// load WITHOUT holding the lock; dedupe concurrent misses
res, err, _ := c.sf.Do(c.key(k), func() (any, error) {
v, err := c.load(ctx, k)
if err != nil {
return v, err
}
c.mu.Lock()
c.m[k] = v
c.mu.Unlock()
return v, nil
})
if err != nil {
var zero V
return zero, err
}
return res.(V), nil
}
Design points:
- Never do slow I/O while holding the write lock; it serializes every key. Singleflight avoids duplicate loads without that.
- Initialize
mwithmake(map[K]V)in a constructor. Writing to a nil map panics. - The loader runs with the first caller's
ctx. If that caller cancels, every waiting caller gets the error, so detach it withcontext.WithoutCancelplus a timeout. - For production use, add TTL and eviction (LRU), bound the size, and shard the lock (N maps hashed by key) to reduce contention.
- For read-mostly data that is rebuilt in bulk,
atomic.Pointer[map[K]V]with copy-on-write gives lock-free reads.
More on Concurrency Patterns & sync
- Q250Is time.After in a select loop a leak? What changed in Go 1.23 timers?
- Q251What happens here, and when does the runtime NOT detect a deadlock?
- Q253Channels or mutexes: how do you decide? ("Share memory by communicating")
- Q254How do you test concurrent and time-dependent code deterministically? (testing/synctest)
- Q255What does this print? Can recover in main catch a panic from another goroutine?
- Q256What is false sharing, how does it hurt concurrent Go code, and how do you fix it?