skip to content

When is swapping a whole map through atomic.Pointer[T] better than guarding it with a mutex?

level: middleimportance: nice to knowfreq 32%

answer

  1. readers take nothing at all
  2. one word swaps the whole world
  3. published means frozen forever
  4. every write copies everything
  5. two Loads can straddle a swap

basics

~20 s

When reads vastly outnumber writes and readers can tolerate a slightly stale snapshot. Writers build a fresh copy and publish it with one pointer store, so readers take no lock. It stops paying once writes are frequent, since every write copies everything.

solid answer

~50 s

This is copy-on-write publication. The state lives behind an `atomic.Pointer[map[string]Session]`; a reader does one `Load()` and then treats what it got as immutable, so the read path has no lock and no contention. Writers serialise among themselves on an ordinary mutex, copy the current map, apply the change to the copy, and `Store()` the new pointer. It wins when the read-to-write ratio is enormous — routing tables, feature flags, a config snapshot — and readers are happy with a value that may be a few microseconds old. It loses as soon as writes are frequent or the map is large, because each write is O(n) allocation and copying, and it forbids two things absolutely: nobody may mutate a map that has already been published, and a reader that needs two related fields must get them from a single `Load`, not two, or it can straddle a swap.

code

go · 21 lines
go
type Index struct {
	write sync.Mutex // serialises writers only; readers never touch it
	m     atomic.Pointer[map[string]Session]
}

func (ix *Index) Lookup(id string) (Session, bool) {
	sess, ok := (*ix.m.Load())[id]
	return sess, ok
}

func (ix *Index) Set(id string, sess Session) {
	ix.write.Lock()
	defer ix.write.Unlock()
	old := *ix.m.Load()
	next := make(map[string]Session, len(old)+1)
	for k, v := range old {
		next[k] = v
	}
	next[id] = sess
	ix.m.Store(&next) // published; nobody may mutate it afterwards
}

go deeper

for a junior

Know the shape: readers grab a pointer to a whole snapshot and read it without locking, and a writer publishes a brand-new snapshot by swapping that pointer.

for a middle

Be ready to explain why a mutex-guarded read still costs something on many cores, and to state the two hard rules: published state is never mutated, and one load serves one logical read.

for a senior

Show you can find the break-even. Name the write rate and structure size at which the per-write copy costs more than the lock it replaced, and say how you would measure it rather than guess.

for a principal

Decide when a staleness window is acceptable at all. Publishing snapshots is a contract with every caller about how fresh their view is, and that contract should be written down before it is depended on.

## The pattern Copy-on-write publication splits a shared structure into an immutable value plus one atomically-swapped pointer to it. ```go type Index struct { write sync.Mutex // writers only m atomic.Pointer[map[string]Session] } ``` A reader does exactly one atomic load and then reads an ordinary Go map: ```go sess, ok := (*ix.m.Load())[id] ``` No lock is taken, no cache line is written, and readers never interfere with each other or with the writer. A writer takes the writers-only mutex (so two writers do not both copy from the same old state and lose one update), allocates a new map, copies the old entries in, applies its change, and stores the new pointer. From that instant, new readers see the new map; readers already holding the old pointer keep reading the old map, which is still perfectly valid memory and is collected once the last reader drops it. ## Why this removes contention rather than just moving it A mutex-guarded read still **writes** — it mutates the lock word, which bounces that cache line between cores. That is why a read-heavy structure guarded by a lock can flatten out as cores are added even though nothing is being modified. An atomic pointer load is a plain read of a word; many cores can hold that line shared and read it in parallel indefinitely. The contention does not move somewhere else, it stops existing on the read path. ## The rules you cannot break **1. Published state is immutable.** Once `Store` has been called, the map that was stored may never be written to again, by anyone. A writer that does `m := ix.m.Load(); (*m)[id] = sess` has just written to a map that readers are reading concurrently — a data race, and with Go maps a possible fatal "concurrent map read and map write" crash rather than a merely wrong answer. Every change allocates a new map. **2. One `Load` per logical read.** If a caller needs two entries that must be consistent with each other, it must load the pointer once and index the resulting map twice. Two separate `Load` calls can land on either side of a swap and produce a pair that never existed together. **3. No read-modify-write.** "Look up the entry, increment its counter, store it back" is not made safe by the atomic pointer. Anything of that shape belongs under the writers' mutex, or in a different structure. **4. Writers still need their own mutex.** `atomic.Pointer` gives you an atomic swap of one word; it does not stop two writers from both copying the same old map and one of them clobbering the other's change. Serialise them. ## The cost model Read cost: one atomic load, then normal map access. Effectively free, and it stays flat as cores are added. Write cost: allocating a map of n entries and copying n key/value pairs, every single time. With a thousand entries and a write every few seconds, nobody notices. With a hundred thousand entries and a write every hundred milliseconds, you have built an allocation machine that will show up as GC pressure long before it shows up as lock contention. The break-even is a ratio question, and it is worth measuring with a benchmark that models the real read/write mix rather than guessing. Staleness: a reader that loaded before the swap continues on the old snapshot for as long as it holds it. For a session index, feature flags, a routing table or a set of verification keys, that window is fine and often desirable — the reader sees one coherent version of the world. For anything where a write must be visible to the very next read, it is wrong. Memory: two copies exist during the swap, and the old one lives until the last reader releases it. Long-lived readers holding old snapshots keep old maps alive. ## When to choose it over partitioning Sharding the lock helps when writes are spread across many keys and every operation is per-key. Copy-on-write publication helps when writes are *rare* regardless of key, and reads are everywhere. They solve different shapes: if your write rate is high, sharding; if your read rate is high and writes are occasional bulk republications, the atomic swap. A structure that is rebuilt wholesale on a schedule — reload a config file, refresh a routing table every thirty seconds — is the ideal fit, because the copy you were going to make anyway *is* the new snapshot. ## Practical shape Initialise the pointer at construction so `Load` never returns nil; a nil dereference on the read path is the classic bug here. Consider storing a small struct rather than a bare map when you need several fields to change together — one `Store` then publishes all of them consistently, which is exactly the kind of multi-field invariant a single atomic word cannot otherwise give you. And keep the writer's copy loop boring: `make` with the right capacity hint, a `for k, v := range old` copy, then the change, then `Store`.

  • Why do writers still need a mutex if the pointer store is atomic?
    Because the swap is atomic but the read-copy-modify-publish sequence is not. Two writers can both `Load` the same old map, each build a copy with only their own change, and the second `Store` silently discards the first writer's update. A writers-only mutex serialises that sequence; readers never touch it, so it costs the read path nothing.
  • A writer loads the pointer and assigns into the map it got back. What happens?
    It is a data race against every concurrent reader of that map, and Go's runtime may detect it and abort the process with "fatal error: concurrent map read and map write" — a crash the race detector also reports under `-race`. The published map must be treated as frozen; changes go into a fresh copy that is then stored.
  • How does a reader get two related entries consistently?
    By calling `Load` once, keeping the returned pointer in a local, and indexing that one snapshot for both lookups. Two separate `Load` calls can be separated by a publish, so the reader would combine one entry from the old map with one from the new — a pairing that never existed in any single version of the state.

saying these in an interview costs you the question

  • Mutating a map after it has been published through the atomic pointer
  • Assuming the atomic pointer removes the need for a writers' mutex
  • Calling Load twice for two values that must agree
  • Using it for state that is written as often as it is read
  • Leaving the pointer nil at construction, so the first read dereferences nil
  • Expecting a lookup-then-update sequence to be atomic