skip to content

A limiter loads an atomic.Int64 count and an atomic.Pointer[Config] separately — why can the pair disagree?

level: seniorimportance: should knowfreq 44%

answer

  1. each call is atomic on its own
  2. nothing brackets two separate calls
  3. the config can change between the two loads
  4. the race detector has nothing to report
  5. one pointer to one frozen struct

basics

~20 s

Each load is atomic by itself, but nothing makes the two happen together. Another goroutine can replace the Config between them, so the count is compared against a limit that never coexisted with it. Publish everything that must agree as one immutable struct behind a single atomic.Pointer.

solid answer

~50 s

Atomic operations do not compose. `count.Load()` is indivisible, `cfg.Load()` is indivisible, and the sequence of the two is not: between them any goroutine may `Store` a new `*Config`, so the admission check compares a count read at one instant against a limit that only became true at another. Nothing is corrupted — the invariant simply never held. The race detector cannot help, because there is no data race here; every access was atomic. The fix is to make the things that must agree a single atomic unit: put them in one immutable struct and publish it with one `atomic.Pointer[T].Store`, so a reader's single `Load` yields a coherent snapshot, and never mutate a struct after publishing — build a new one and swap. When one of the fields is a counter that changes constantly it cannot live inside an immutable snapshot, so you either accept the staleness deliberately and document it, or take a `sync.Mutex` across both reads.

code

go · 16 lines
go
type settings struct {
	limit int64
	burst int64
}

var cur atomic.Pointer[settings]

// reload publishes a brand-new struct; a published one is never mutated.
func reload(limit, burst int64) {
	cur.Store(&settings{limit: limit, burst: burst})
}

func allowed(n int64) bool {
	s := cur.Load() // one load: limit and burst belong to the same version
	return n < s.limit+s.burst
}

go deeper

for a junior

Take away the rule: an atomic operation covers one value, and doing two of them in a row does not make them one. If two things must always agree, they need to be read as one unit.

for a middle

Explain where the window is and what can happen inside it, and describe the immutable-snapshot fix: one struct, built completely, published with a single atomic.Pointer Store, never mutated afterwards.

for a senior

Show diagnosis. Say why -race is silent, why the symptom looks like an operator error, how you would reproduce it by reloading config under load, and how you decide between the snapshot, a reordered read, and going back to a mutex.

for a principal

Own the review standard. Be able to state when replacing a lock with atomics is worth its reasoning cost, what you require of such a change before it merges, and how you keep a lock-free hot path from becoming state nobody on the team can reason about.

## The shape of the bug An admission check on a hot path holds two pieces of state: an `atomic.Int64` counting in-flight work, and an `atomic.Pointer[Config]` holding settings that an operator can reload at run time. The check reads both: ```go n := inFlight.Load() c := cfg.Load() if n >= c.MaxInFlight { return errBusy } ``` Every access here is atomic and none of it is a data race. Yet the decision can be wrong. Between the two `Load` calls a reload goroutine can `Store` a new `*Config` whose `MaxInFlight` is half the old one. The check then compares a count taken under the old regime against a limit from the new one — a pair of values that never described the system at any single instant. Reverse the order of the reads and you get the mirror bug. This is the general truth about atomics, and it is the thing candidates most often miss: **an atomic operation protects one word, not an invariant.** Two atomic operations are two atomic operations. There is no implicit bracket around them. ## Why it is so hard to catch Three properties make this class of bug expensive: - **`-race` is silent.** The race detector reports unsynchronised access to the same memory. Here every access went through `sync/atomic`, so there is nothing for it to report. Passing `-race` is often taken as proof of concurrency correctness; it is proof of the absence of *data races* only. - **The window is tiny and the trigger is rare.** The two loads are adjacent instructions; the config reload happens once an hour. The bug fires when a preemption or a core migration lands exactly there, which is to say in production, at load, occasionally. - **The symptom is plausible.** Slightly too much traffic admitted just after a config change looks like an operator error, a caching delay, or an off-by-one — not like a memory-ordering question. ## The fix: one pointer, one immutable struct When several values must be read together, make them one value. Put them in a struct, treat that struct as immutable once published, and swap the whole thing with a single `atomic.Pointer[T]`: ```go type settings struct { limit int64 burst int64 } var cur atomic.Pointer[settings] func reload(limit, burst int64) { cur.Store(&settings{limit: limit, burst: burst}) } func allowed(n int64) bool { s := cur.Load() // one load: limit and burst are guaranteed to belong together return n < s.limit+s.burst } ``` One `Load` yields a pointer to one struct, and the fields inside it were fixed before the pointer was ever visible. Readers on the hot path pay a single pointer load and no lock. The discipline that makes it work is **write-once**. A published `*settings` is never mutated — not by the writer, not by a reader. A reload builds a fresh struct and stores the new pointer; goroutines still holding the old pointer keep using a fully valid older version and drop it when they return, and the garbage collector reclaims it. The moment somebody "just updates one field" on a published struct, the pattern is gone and you have a data race instead. ## The field that cannot live in the snapshot The counter is different from the config. A snapshot works for the config because it changes rarely and wholesale; the in-flight count changes constantly and must be a live shared value, so it cannot be frozen into an immutable struct. That leaves three honest options: 1. **Accept the skew deliberately.** For an advisory limiter, comparing a fresh count against a limit that may be a few nanoseconds stale is fine — but say so in a comment, so the next reader knows it was a decision and not an oversight. 2. **Reorder to make the skew safe.** Load the config *first*, then the count, so the count is always at least as new as the limit it is judged against. It does not make the pair atomic, but it makes the error direction predictable, which is sometimes all a limiter needs. 3. **Take a `sync.Mutex` across both.** If the pair really is an invariant somebody depends on, hold a lock over the read and over the update. Reaching for a lock after showing you understand why the atomics were not enough is a strong answer, not a retreat. ## `atomic.Pointer[T]` versus `atomic.Value` `atomic.Value` is the older way to publish an arbitrary value atomically. It stores an `any`, so it is checked at run time rather than compile time: `Store` panics if the value is nil, and it panics if the value's concrete type is inconsistent with what was stored before. It also boxes the value into an interface. `atomic.Pointer[T]`, added with the typed atomics in **Go 1.19**, is type-safe at compile time, cannot be given the wrong type, and reads as `*T` with no assertion at the call site. For new code publishing a struct pointer, `atomic.Pointer[T]` is the default and `atomic.Value` is what you meet in older code. ## The review heuristic When a change replaces a mutex with atomics, the question to ask is not "is each operation atomic?" but **"how many variables does one decision read, and can they change between the reads?"** One variable: atomics are a good fit. Two or more that must agree: either fold them into one value published through a single pointer, or keep the lock.

  • Why does a clean go test -race run say nothing about this bug?
    Because there is no data race. The race detector reports unsynchronised accesses to the same memory where at least one is a write; here every access went through sync/atomic and is properly synchronised. What is broken is a logical invariant spanning two separately-atomic operations, which no runtime detector models. Only reasoning about the read set, or a test that reloads config under load, will surface it.
  • What discipline keeps the atomic.Pointer snapshot pattern correct?
    Write-once. Build the struct completely, then Store the pointer, and never mutate a struct that has been published — not from the writer and not from a reader. An update allocates a fresh struct and swaps the pointer; goroutines holding the old pointer keep a fully valid earlier version until they finish, and the garbage collector reclaims it. Mutating in place turns the pattern back into a data race.
  • When would you choose atomic.Value over atomic.Pointer[T] today?
    Essentially only in existing code that already uses it. atomic.Value stores an `any`, so its safety is run-time: Store panics on a nil value and panics if the concrete type differs from what was stored before, and every read needs a type assertion. atomic.Pointer[T] is checked at compile time and returns *T directly, which is why it is the default for publishing a struct pointer.
  • If the live counter cannot go inside the immutable snapshot, what are your options?
    Three. Accept the skew and document that the limiter is advisory. Or fix the read order — load the config first, then the count — so the count is never older than the limit it is checked against, making the error direction predictable. Or, if the pair really is an invariant, hold a sync.Mutex across both the read and the update and stop pretending atomics cover it.

saying these in an interview costs you the question

  • Says two atomic loads together are automatically consistent
  • Claims the race detector would have caught it
  • Puts the two reads on adjacent lines and calls it fixed
  • Mutates a struct after publishing its pointer
  • Thinks atomics are always faster so a mutex is never right
  • Adds a third atomic field to fix an invariant across two