Go

Design a thread-safe generic cache with a GetOrLoad method. What concurrency pitfalls exist?

Question 135HardGo 1.22 to 1.25
type Cache[K comparable, V any] struct {
    mu    sync.Mutex
    items map[K]*entry[V]
}

type entry[V any] struct {
    once sync.Once
    val  V
    err  error
}

func NewCache[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{items: make(map[K]*entry[V])}
}

// GetOrLoad calls load at most once per key, even under concurrency,
// and never holds the global lock while load runs.
func (c *Cache[K, V]) GetOrLoad(k K, load func(K) (V, error)) (V, error) {
    c.mu.Lock()
    e, ok := c.items[k]
    if !ok {
        e = &entry[V]{}
        c.items[k] = e
    }
    c.mu.Unlock()

    e.once.Do(func() { e.val, e.err = load(k) })
    if e.err != nil {
        c.mu.Lock()
        if c.items[k] == e { // do not cache failures
            delete(c.items, k)
        }
        c.mu.Unlock()
    }
    return e.val, e.err
}

Pitfalls to discuss:

  • Holding the mutex during load serializes all keys. Per-key sync.Once avoids that and deduplicates concurrent loads for the same key, like singleflight.
  • Decide whether errors are cached. The example removes failed entries.
  • The map grows without bound. Add an LRU list or a TTL.
  • If V is a pointer or slice, callers can mutate cached data.
  • sync.Map is not generic. Wrapping it means paying for type assertions, whereas this version is type-safe.

More on Generics

All 36 Generics questions