the code
A metrics collector, arriving in a pull request. Two counters, no locks, and the author has
thought about threading — the fields are volatile, with a comment saying why.
sealed class RequestMetrics
{
// volatile "so the other threads see it"
private volatile int _total;
private volatile int _errors;
public void RecordSuccess() => _total++;
public void RecordError() { _total++; _errors++; }
public int Total => _total;
public int Errors => _errors;
}It is called from every request handler in the service, on whatever thread-pool thread the request landed on. The counters are exported to the dashboard once a minute. Nothing else touches them.
find it
before you scroll
There are two defects here and they are different in kind. One is about a single counter, one is about the pair.
Commit to a shape before reading on. Four threads each call RecordSuccess or RecordError
250,000 times, 1,000,000 calls in total. Will Total read exactly 1,000,000 at the end? If
not, is the shortfall a handful of increments, or a large fraction of them — and does that
fraction depend on how long the threads spend racing, or is it fixed by the code?
The word to distrust is volatile. Ask yourself which of the three guarantees — atomicity,
visibility, ordering — it gives, and which one this code needs.
the failure
Run it. bench/atomics-and-cas/lost-updates.cs, five trials, each 4 threads × 250,000 calls, half
of them errors. Real output from a run:
=== 1. 4 threads × 250,000 requests each (half of them errors) ===
trial 1: volatile int ++ Total= 438,750 ( 561,250 lost) Errors= 234,436 (of 500,000) | Interlocked Total=1,000,000 Errors= 500,000
trial 2: volatile int ++ Total= 397,799 ( 602,201 lost) Errors= 207,672 (of 500,000) | Interlocked Total=1,000,000 Errors= 500,000
trial 3: volatile int ++ Total= 445,004 ( 554,996 lost) Errors= 241,241 (of 500,000) | Interlocked Total=1,000,000 Errors= 500,000
trial 4: volatile int ++ Total= 466,498 ( 533,502 lost) Errors= 248,613 (of 500,000) | Interlocked Total=1,000,000 Errors= 500,000
trial 5: volatile int ++ Total= 416,718 ( 583,282 lost) Errors= 221,630 (of 500,000) | Interlocked Total=1,000,000 Errors= 500,000
The same file, same threads, same call counts, with Interlocked.Increment in place of ++:
Total=1,000,000 and Errors=500,000, exactly, in every trial.
More than half the increments are gone, in every one of the five trials above. Not a rounding error, not a rare interleaving — the common case whenever there are enough cores available to race. And a service reporting these numbers would look almost healthy: the error rate a dashboard would compute from trial 1, 234,436 / 438,750, is 53.4% instead of 50% — wrong, but not implausibly wrong.
How much you lose is itself a race outcome, decided by scheduling rather than by anything in the program — the five trials above range from 53.3% lost to 60.2% lost, same code, same process, back to back. What is stable is that it is always large and never zero; the exact figure is not.
This is where the bug hides, so it is worth being precise about how the failure scales with how long the threads overlap. Same class, two threads, 200 trials at each size (fewer at the largest sizes, where each trial already takes real time), counting how many trials lost at least one update — real output from a run:
=== 2. 200 trials at each size: how many trials lost at least one update? ===
(2 threads, both calling RecordSuccess in a tight loop)
10 increments per thread: 0/200 trials wrong, worst loss 0 ( 0.0%)
100 increments per thread: 0/200 trials wrong, worst loss 0 ( 0.0%)
1,000 increments per thread: 169/200 trials wrong, worst loss 782 ( 39.1%)
10,000 increments per thread: 198/200 trials wrong, worst loss 6,041 ( 30.2%)
100,000 increments per thread: 20/20 trials wrong, worst loss 64,752 ( 32.4%)
1,000,000 increments per thread: 20/20 trials wrong, worst loss 949,790 ( 47.5%)
Ten and a hundred increments per thread come back at 0/200 in every run of this file — zero failures across every trial at those two sizes, run after run. By a thousand increments per thread it has already flipped: the majority of trials come back wrong, and it stays that way at every larger size tried. The crossover between “never fails” and “usually fails” sits somewhere between a hundred and a thousand increments per thread on a machine with cores enough to spare — arithmetic decides the two ends of that range, scheduling decides exactly where the middle falls, and the exact boundary is not something this file pins down more precisely than that.
A unit test that spins up two threads and increments ten times each never failed here. Not once, across 600 trials. Neither did a hundred increments each. That is a real reason this bug reaches production: the loss is proportional to how long the two threads overlap, and a short loop barely overlaps at all. Starting two threads takes real time — microseconds, through the OS scheduler — against which ten increments is nothing, so in practice one thread is often finished before the other is scheduled onto a core, and there is no window to collide in.
Now the interleaving, forced rather than hoped for. count++ is a load, an add and a store; the
file writes those three steps out explicitly and holds thread A between its load and its add
while thread B runs a whole increment in between. Real output:
=== 3. one lost update, scripted step by step ===
step 1 A: load -> regA=41 shared=41
step 2 B: load -> regB=41 shared=41
step 3 B: add -> regB=42 shared=41
step 4 B: store -> shared=42
step 5 A: add -> regA=42 shared=42
step 6 A: store -> shared=42
two increments applied to 41, shared = 42 (expected 43)
| step | thread A | thread B | A’s register | B’s register | shared |
|---|---|---|---|---|---|
| 1 | load | 41 | — | 41 | |
| 2 | load | 41 | 41 | 41 | |
| 3 | add | 41 | 42 | 41 | |
| 4 | store | 41 | 42 | 42 | |
| 5 | add | 42 | 42 | 42 | |
| 6 | store | 42 | 42 | 42 |
Two increments went in. One came out. Nobody read a torn value, nobody saw stale data, no exception was thrown — thread A’s store was simply based on a number that had stopped being true two steps earlier.
why it breaks
volatile is the wrong guarantee. “Thread-safe” bundles three separate properties, and
the memory model takes them apart: atomicity (does this happen all at
once), visibility (will the other thread ever see it), ordering (will it see my writes in order).
volatile in C# buys visibility and release/acquire ordering. It buys no atomicity, and this
code needs nothing but atomicity. The keyword makes each individual load and each individual store
really happen against memory — which is exactly what steps 1 through 6 above did.
The increment is a read-modify-write. Even when the JIT folds it into a single instruction —
inc dword ptr [rax], which is what it emits for Count++ on a static — the core loads, adds
and stores without holding the cache line across the sequence, so another core can slip in.
Atomics and compare-and-swap has that disassembly and the one prefix
byte that changes it.
And the second defect is not about either counter. _total and _errors are updated in two
separate operations, so even after both are made atomic, a reader can see _total incremented
and _errors not yet. The pair is never guaranteed consistent. That is a race condition rather
than a data race — the same shape as check-then-act, on
races and deadlock — and no per-field primitive fixes it.
the fix
sealed class AtomicRequestMetrics
{
private int _total; // no `volatile`: Interlocked already implies a full fence
private int _errors;
public void RecordSuccess() => Interlocked.Increment(ref _total);
public void RecordError() { Interlocked.Increment(ref _total); Interlocked.Increment(ref _errors); }
// Volatile.Read so the reader cannot be handed a value the JIT cached
// earlier; on x86-64 this costs nothing over a plain read.
public int Total => Volatile.Read(ref _total);
public int Errors => Volatile.Read(ref _errors);
}Interlocked.Increment compiles to lock inc (or lock xadd when you use the return value): one
instruction that holds the cache line exclusively from the load to the store, so there is no
step 2 for another thread to land on. Exact totals in every trial, as the run above shows.
Two things people reach for first, and what is wrong with each:
volatile — already there, and demonstrably not enough. It is the fix that looks like a fix.
The broken class in this page is the volatile version; it loses the majority of its updates
under real load. If you see volatile on a counter that gets incremented, that is a bug marker,
not a safety marker — volatile was never the guarantee this problem needed.
lock — also correct, and structurally more expensive for no extra correctness here. A lock
block around the increment does the same load-add-store as Interlocked, wrapped in acquire and
release of a monitor. Uncontended, that monitor’s fast path is itself built from a compare-and-swap
on a word — what a lock is made of shows the mechanism — so lock
never does less work than Interlocked for this job, and once contended it can additionally park a
waiting thread through the kernel, which Interlocked never does. Reach for lock only when the
invariant spans more than the one field an atomic can cover — which is exactly the Total/Errors
pair below.
Which is precisely the case for the second defect. If the dashboard needs Total and Errors to
be consistent with each other, the pair has to be updated and read under one lock — or packed into
a single 64-bit value and updated with one Interlocked.Add (errors in the high 32 bits, total
in the low 32, an increment of 1L << 32 | 1), which is the trick worth knowing precisely because
it turns two locations back into one — as long as the low half cannot overflow into the high one,
which caps it at about four billion requests between resets.
what this looks like in prod
The counter is never the thing that pages you. What pages you is a number that does not add up: the rate limiter that admitted more requests than its limit, the “processed exactly once” claim contradicted by the downstream count, the billing total that is short by a fraction of a percent that nobody can reproduce on a developer machine. The service is healthy, the logs are clean, and the discrepancy scales with traffic — which is the fingerprint. A bug whose error rate rises with load is almost always a window between two operations that used to be one.
Where it comes from, in real .NET code: a hand-rolled cache with a _hits++ next to a
_misses++; a background job with a _processed++ in the loop body; a circuit breaker counting
consecutive failures with _failures++ and comparing against a threshold; any singleton service
with an int field and a method that touches it. All of them work perfectly under a single
request at a time, which is how integration tests run.
The cheap audit: grep the codebase for ++ and += on fields of any class registered as a
singleton, or any static. Every hit is either provably single-threaded or a bug. On a hot path,
the answer after Interlocked is to stop sharing the counter at all — per-partition counters
summed on read, which two counters, one cache line
covers next.
the same idea in other languages
| language | what it’s called | the trap |
|---|---|---|
| Java | the same bug; the fix is AtomicInteger.incrementAndGet or, for a hot counter, LongAdder |
Java’s volatile is stronger than C#’s — a volatile write/read pair is sequentially consistent — and it still does not make x++ atomic. Engineers who know that Java’s volatile is the strong one are, if anything, more likely to write this bug |
| C | x++ on a shared int, with or without volatile |
C’s volatile gives neither atomicity nor ordering; it only stops the compiler caching the value. The fix is _Atomic int or __atomic_fetch_add, and nothing in the language warns you |
| C++ | std::atomic<int> with ++, fetch_add |
std::atomic operations default to seq_cst, so the naive fix is correct but pays for a full fence you may not need. The trap is the other direction from C#: relaxing to memory_order_relaxed is right for a pure statistics counter and wrong the moment the counter guards anything |
| Go | atomic.Int64.Add, or a mutex, or a channel |
the race detector (go test -race) finds this class of bug mechanically, which .NET has no built-in equivalent of. If you come from Go and expect the tooling to catch it, nothing in .NET will |
| Python | x += 1 under CPython’s GIL |
the GIL makes each bytecode atomic, not each statement, so x += 1 is LOAD/ADD/STORE with a switch point between any of them and loses updates exactly like this page’s C# — just far more rarely, because since CPython 3.2 the interpreter only offers the GIL to another thread on a timer (sys.setswitchinterval(), default 5 ms), so the window between the LOAD and the STORE is almost never the one it lands in |
common bugs
- Trusting
volatileto make a counter safe. It is the single most common wrong fix, and it looks deliberate in a diff.volatilegives visibility and ordering; a counter needs atomicity. - Testing with too little overlap and concluding it works. Two threads and ten increments each never failed here — 600 trials, 600 passes, on code that loses the majority of its updates under real load. If you are writing a test for a concurrency fix, the failing version must fail that test reliably first — otherwise you have tested nothing.
- Making each field atomic and calling the object thread-safe.
Interlockedon_totaland on_errorsfixes each counter and leaves the pair inconsistent. Atomic parts, non-atomic whole. if (Interlocked.Read(ref n) < limit) Interlocked.Increment(ref n). Two atomic operations with a hole between them: the classic check-then-act. The correct shape is one operation — increment first and compensate, or CAS the value you checked.- Reading the counter with a plain field access after fixing the writes. The reader can be
handed a value the JIT hoisted out of a loop;
Volatile.Readcosts nothing on x86-64 and removes the question. - Reaching for
lockon a hot counter “because it’s the safe default.” It is correct here, and it is never doing less work thanInterlockedfor a single field — the monitor’s own fast path is a compare-and-swap, with a wait queue behind it thatInterlockednever needs.