skip to content

A developer argues that an unsynchronized shared statistics counter is a 'benign' race because a slightly wrong count is acceptable. Explain, in terms of what a memory model actually guarantees, why that reasoning is unsound and what outcomes are genuinely possible.

level: seniorimportance: should knowfreq 29%

answer

  1. DRF ⇒ SC is a conditional guarantee
  2. race voids the contract, not just the count
  3. hoisted flag read → infinite loop
  4. tearing: value nobody ever wrote
  5. 'benign' fix = relaxed atomic or per-thread counters

basics

~20 s

Memory models define behaviour only for data-race-free programs. Once a race exists, the compiler and hardware were allowed to optimize under the assumption it could not happen, so you do not get 'the right answer, occasionally off by a few' — you can get a value never written, a loop that never terminates, a torn value, or code paths that make no sense in the source.

solid answer

~1 min

The argument assumes the racy program still executes some interleaving of the source statements, only with lost updates. Memory models do not promise that. The central theorem they are built around is **data-race-free implies sequential consistency**: *if* your program has no data races, it behaves as some interleaving of its threads. That guarantee is conditional, and a race voids it. What the compiler may legally do to unsynchronized reads and writes, because it assumes no other thread is involved: keep the variable in a register so the loop never sees an update; hoist a read out of a loop, turning termination into an infinite loop; re-read a location and get two different values where the source read once; reorder or coalesce writes; and on some types split an access so another thread observes a **torn** half-written value. So the realistic failure set is not "count is 4,997 instead of 5,000". It is hangs, impossible values, and behaviour that changes when you upgrade the compiler. Some models (C/C++ style) call a data race outright undefined behaviour; others (JVM style) constrain it to prevent values appearing from nowhere but still allow essentially arbitrary reordering, so you cannot reason locally either way.

code

text · 6 lines
text
# source                          # legal compilation of the loop
shared bool stop = false          reg = load(stop)
                                  while (!reg):       # stop never re-read
thread A: while (!stop):              work()
              work()
thread B: stop = true             -> thread A never exits

go deeper

for a junior

Know the rule: there is no benign data race; the guarantee that your program behaves like some interleaving only applies when there are no races.

for a middle

Name concrete legal transformations — hoisting a flag read out of a loop, coalescing writes, tearing — and show they produce hangs or impossible values, not just an off-by-a-few count.

for a senior

Explain DRF-SC as the conditional contract, contrast undefined-behaviour models with the JVM's bounded-but-unordered model, and give the correct cheap expression of an approximate counter.

for a principal

Treat it as a policy question: racy code is unbounded technical risk that surfaces on toolchain or hardware changes, so the standard is race-freedom by construction plus detector coverage in CI, with tolerance for imprecision expressed explicitly in the code.

## What a memory model is for A memory model is the contract between you, the compiler, and the hardware about what values a read may return. Both compiler and CPU want to reorder, cache, and eliminate memory operations, because those transformations are what make code fast. The model states the conditions under which they must not. The pivotal clause in every mainstream model is roughly: **a program with no data races behaves as if its threads' operations were interleaved in some sequential order** (data-race-freedom implies sequential consistency, "DRF-SC"). This is the property that lets you reason about concurrent code at all — you consider interleavings, and that is enough. Every word of it is conditional on the absence of data races. If your program has one, you are outside the region where the model says anything useful, and the transformations that were valid *under the assumption of no races* stay in the compiled binary. ## Concrete transformations that break 'benign' races **Register promotion / hoisting a read.** Given ``` while (!stopRequested) { work(); } ``` with `stopRequested` unsynchronized, the compiler sees a variable this thread never writes. It may load it once into a register before the loop: ``` reg = stopRequested while (!reg) { work(); } # never terminates ``` The program hangs forever. That is not "slightly wrong"; it is a liveness failure that no amount of thinking about lost updates predicts. **Re-reading, or reading fewer times than written.** The compiler may turn one source-level read into two machine reads (rematerialization) so that a single `if (p != null) use(p)` reads `p` twice and uses a value it never checked; or it may collapse repeated reads into one so a thread never observes a change. Both are legal for non-atomic accesses. **Write coalescing and sinking.** Several writes in a loop may be reduced to a single final write, or a write may be sunk past other operations, so another thread never observes intermediate states it was implicitly counting on. **Tearing.** If a value is wider than the platform's atomic access unit, a racing reader can observe a mix of the old and new halves — a number that was never stored by anyone. The same effect applies to a multi-field structure updated field by field. **Reordering.** Absent a happens-before edge, the writes a thread performs may become visible to another thread in a different order than the source shows. Code that infers "if I see the flag, the data must be there" is exactly what this destroys. ## The difference between memory models matters, but not enough to rescue the argument - **C/C++-style models** declare a data race **undefined behaviour** outright. There is no bound on what the program may do; the compiler may assume the racing path is unreachable and delete code around it. "Catch fire" semantics. - **JVM-style models** deliberately refuse full undefined behaviour for safety reasons: a racy read must return a value written by *some* write to that variable (no values out of thin air), and type/memory safety is preserved. But the ordering of what you may observe is essentially unconstrained, references and most primitives may be reordered freely, and 64-bit values could historically tear. So a racy program remains unpredictable, just not memory-unsafe. Either way, the developer's mental model — "same program, occasionally loses an increment" — corresponds to no memory model in use. ## Why 'it works' is not evidence The transformations above are *optional*. Today's compiler at today's optimization level on today's CPU may leave your race looking harmless. The reasons it can change tomorrow are all routine: a compiler upgrade, a different optimization level, inlining that exposes the loop to a new transformation, a JIT compiling the method after a few thousand iterations, or a move to hardware with a weaker memory ordering. This class of bug characteristically appears months after the code shipped, in production only, and disappears under a debugger. ## What to do instead If the value genuinely does not need to be exact, you still must make the accesses **race-free** — cheaply, but explicitly: - Mark the location **atomic/relaxed** in models that offer it. Relaxed atomics give you no ordering guarantees at all, which is what "I don't care about precision" really means, but they do make each access indivisible and remove the race, so the compiler stops assuming exclusivity. This is usually as cheap as the plain access on mainstream hardware. - Use **per-thread counters** and sum on read. No sharing, no race, and much faster under contention than a single hot location. - Use a **sampling or approximate counter** designed for the purpose. The rule to state in an interview: *there is no such thing as a benign data race — there are only races whose consequences you have not observed yet.* If precision does not matter, say so by choosing a relaxed atomic or a per-thread accumulator, not by omitting synchronization.

  • If the count really may be approximate, what is the correct way to express that?
    Make the accesses race-free but unordered: a relaxed atomic read-modify-write, which is indivisible yet imposes no ordering, so the compiler stops assuming the location is thread-private while costing about the same as a plain access on mainstream hardware. For a hot counter the better answer is per-thread accumulators summed on read, which removes both the race and the contention. The point is to state the tolerance in the code, not to obtain it by omission.
  • How would you detect races like this before they reach production?
    Dynamic race detectors instrument memory accesses and track happens-before or lockset information, flagging conflicting unsynchronized accesses even on runs where the program produced correct output — which is exactly the property you need, since these bugs are invisible in normal testing. Run them in CI over the concurrent test suite, accepting the large slowdown, and pair them with stress tests that vary thread counts and injected delays to widen windows.
  • Why do JVM-style models bother to forbid out-of-thin-air values rather than simply declaring races undefined?
    Because the platform's security model depends on memory and type safety holding unconditionally: untrusted code must not be able to fabricate a reference or corrupt the heap by racing, so the model must bound what a racy read can return even in broken programs. The cost is a much harder specification and weaker optimization freedom in places. It bounds the damage, but it does not make racy programs predictable, so it is no license to write them.

saying these in an interview costs you the question

  • 'Worst case we lose a few increments' — assuming the racy program still follows source-level interleavings
  • 'It has run for a year in production, so it's fine' — the transformations are optional and appear with a compiler or JIT change
  • Believing a race can only produce values that some thread actually wrote at that moment, on any platform
  • Thinking a single machine word is automatically atomic and immune to tearing everywhere
  • Calling a race benign because the variable is only read for logging, while the compiler may still hoist or eliminate its reads

context