Skip to content
dethlex

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.

growingplanted 2 min read

🧪 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.