Lock-free config reloads in Go with atomic.Pointer
When 100k RPS worth of goroutines read a lookup table that reloads every minute, RWMutex is the wrong tool. Build the new snapshot aside and swap one pointer.
🧪 Sample seed note. Placeholder content demonstrating the garden structure — replace with your own text.
A recurring shape in high-load services: a lookup structure — deduplication set, targeting rules, a denylist — that is read on every request and replaced wholesale every N seconds from some source of truth. Hundreds of goroutines read it; exactly one goroutine writes it; the write is never an edit, always a full replacement.
That last property is the key that unlocks everything.
Why not RWMutex
sync.RWMutex is correct here and wrong anyway. Every RLock is an atomic
RMW on shared state — at hundreds of thousands of reads per second across
many cores, readers contend with each other on the lock word itself, and the
cache line holding it ping-pongs between cores. You pay this tax on the hot
path to protect against a write that happens once a minute.
sync.Map solves a different problem (disjoint key sets, incremental
updates). For build-then-replace workloads it buys complexity, not speed.
The pattern
Treat the structure as an immutable snapshot. Readers grab the current one; the reloader builds the next one off to the side and swaps a pointer:
type Snapshot struct {
seen map[uint64]struct{} // never mutated after publish
}
var current atomic.Pointer[Snapshot]
// The zero value of atomic.Pointer is nil — seed it before the hot path
// runs, or every request panics until the first successful reload.
func init() {
current.Store(&Snapshot{seen: map[uint64]struct{}{}})
}
// Hot path: one atomic load, zero locks, zero contention.
func Seen(id uint64) bool {
_, ok := current.Load().seen[id]
return ok
}
// Cold path: runs in one goroutine, e.g. on a ticker.
func reload(ctx context.Context, src Source) error {
next, err := buildSnapshot(ctx, src) // minutes of work allowed here
if err != nil {
return err // keep serving the old snapshot — stale beats broken
}
current.Store(next)
return nil
}
atomic.Pointer (Go 1.19+) makes this typed and impossible to get wrong at
the call site. A reader that loaded the old snapshot finishes its request on
the old snapshot — perfectly consistent, just up to one reload stale. The
garbage collector retires the old map once the last such reader returns.
The fine print
- Immutability is a contract, not a mechanism. Nothing stops a confused reader from writing to the map and racing every other goroutine. Keep the map unexported, expose only query functions.
- Memory doubles during the swap. Old and new snapshots coexist while the build runs. For a 2 GB table, plan 4+ GB — or your “lock-free elegance” becomes an OOMKilled pod at the worst hour.
- Failed reloads must not clear state. Return early, keep the old pointer, and export the moment of last success:
lastReload.Set(float64(time.Now().Unix())) // prometheus gauge
Alert when time() - last_successful_reload_seconds exceeds a few reload
intervals — a service quietly serving week-old rules is the failure mode
nobody notices until money moves.
- Writes on the hot path change the game. If readers must also mark ids as seen, a read-only snapshot no longer fits — reach for sharded mutexes or a pair of rotating generations instead. The atomic-swap trick is for read-heavy, replace-rarely data. Know when you’ve left its territory.