Go

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 m with make(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 with context.WithoutCancel plus 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

All 38 Concurrency Patterns & sync questions