Lock-free · Performance Lab
CAS Contention and Backoff
No lock, no blocking, no waiting — and yet a compare-and-set retry loop can still collapse under contention, wasting more coherence traffic on failed attempts than it spends on useful work. Lock-free does not automatically mean low latency.
Learning objective
Explain compare-and-set as a conditional atomic update, show how retry loops under contention amplify both coherence traffic and latency, compare no-backoff, fixed-backoff, and exponential-backoff-with-jitter retry strategies, and explain why "lock-free" does not automatically mean "low latency" — contrasting CAS contention against a single-writer ownership alternative.
Prerequisites
- The Memory Ordering in Java and Rust lab — CAS is itself an atomic read-modify-write, and this lab assumes the atomicity/visibility/ordering distinction from there.
- The Cache Coherence and MESI lab is useful background for "cache-line ping-pong" below, though not required.
Theory
Compare-and-set (CAS)
Compare-and-set is a single hardware-atomic instruction (cmpxchg on x86, ldxr/stxr or cas on ARM) with the shape: "if the current value equals what I expect, replace it with a new value — atomically, as one indivisible step; tell me whether it worked." Unlike a plain store, CAS can fail: if another thread changed the value between when this thread last read it and when it attempts the CAS, the expected value no longer matches, and the operation does nothing except report failure.
loop:
old = read current value
new = compute desired value from old
if compareAndSet(old, new): return // success
// else: someone else moved it first — retry
This retry loop is the fundamental building block of lock-free data structures — no lock is ever held, no thread ever blocks waiting for another, but a thread can spin through many failed attempts before it succeeds.
Contention and cache-line ping-pong
Under low contention, CAS usually succeeds on the first attempt. Under contention — many threads repeatedly attempting CAS on the same location — every attempt, whether it succeeds or fails, requires the attempting core to have exclusive access to that cache line, exactly as a write does in the MESI model. When several cores constantly attempt CAS on one location, the line is repeatedly invalidated and re-fetched, core to core — the same cache-line ping-pong the False Sharing and MESI labs describe, except here it's the direct, unavoidable consequence of many threads legitimately wanting to update the same value.
More contenders does not just mean more total work — it means a higher fraction of that work is wasted retries. This is what "contention collapse" means: throughput does not merely plateau as contender count grows, it can fall, because the wasted-retry fraction grows faster than the useful-work fraction.
Backoff and jitter
| Strategy | Behaviour |
|---|---|
| No backoff | Retry immediately on failure. Optimal under light contention; maximizes collisions under heavy contention. |
| Fixed backoff | Wait a constant delay after each failure. Spaces out contenders — a delay too short does little, too long wastes latency once contention clears. |
| Exponential backoff | Double the delay (capped) after each consecutive failure for a thread, reset on success. Adapts to a thread's own recent luck. |
| Jitter | A randomized (here: deterministic, reproducible) perturbation on the delay, so contenders who failed together don't all retry in lockstep again. |
None of this is universal. A backoff delay well-tuned for one contention level can be wrong for another — see "When backoff helps, and when it doesn't" below.
The single-writer alternative
CAS retry loops are not the only way to update shared state safely. If the architecture can be changed so exactly one thread ever owns the right to write a given piece of state, there is no contention to manage at all: every write succeeds on the first attempt, because nothing else is racing for it. This is not a claim that single-writer designs are always better — they trade away "any thread can update this," and moving that constraint elsewhere doesn't make the coordination problem disappear, it relocates it.
ABA, briefly
A classic CAS hazard: a thread reads value A, but before its CAS executes, another thread changes the value from A to B and back to A. The CAS still succeeds — the value is A — even though the state changed and changed back, which can silently invalidate an assumption the first thread's logic depended on. A successful CAS proves the value is currently what you expected, not that nothing happened in between. Solving this needs a versioned/tagged CAS, hazard pointers, or epoch-based reclamation — substantial topics this lab does not implement (see "Non-goals").
Fairness and starvation
A CAS retry loop makes no fairness promise. Under sustained contention, it is possible for one thread to fail repeatedly while others succeed — a bare CAS loop has no queue and no memory of who tried first, unlike a fair lock. Whether this matters depends entirely on the workload.
Conceptual model
The interactive model below schedules contenders in a fixed, deterministic round-robin order with deterministic (non-random) jitter — real schedulers and real hardware timing are neither round-robin nor perfectly predictable. "Completion" below is a simulated step count, not measured time — see "Benchmark methodology" for real, disclosed timing data.
Try it
Interactive contention model
Conceptual model
A deterministic round-robin simulation of CAS retry contention among 1–4 threads, not a scheduler or timing simulation. See "Limitations of this model" below for what this leaves out.
One thread, no contention. Every CAS succeeds on the first attempt — the baseline before any of the failure modes below exist.
- Scenario
- Single thread
- Step
- 0 of 3
- Value
- 0
- Successful CAS
- 0
- Failed CAS
- 0
- Retries
- 0
- Ownership transfers
- 0
- Completion (simulated steps)
- —
Event log
- Initial state — no CAS attempts yet.
Java and Rust
CAS retry loop
public final class CasCounter {
private final AtomicLong value = new AtomicLong();
// updateAndGet already implements the retry loop internally — shown
// manually once below to make the retry explicit, per the theory above.
public long incrementViaBuiltin() {
return value.updateAndGet(v -> v + 1);
}
public long incrementManually() {
long old, updated;
do {
old = value.get();
updated = old + 1;
} while (!value.compareAndSet(old, updated));
return updated;
}
}
compareAndSet returns false on a failed attempt without throwing or blocking — the do/while loop is the manual form of exactly what updateAndGet does internally. Under contention, this loop's body can run many times per successful increment.
Single-writer alternative
public final class SingleWriterCounter {
// No CAS, no atomics — correct only because exactly one thread ever
// calls increment(). See theory.md "The single-writer alternative".
private long value;
public long increment() {
return ++value;
}
}
The runnable Maven/JMH project is at content/labs/cas-contention/code/java/ in this site's repository.
CAS retry loop
use std::sync::atomic::{AtomicU64, Ordering};
pub struct CasCounter {
value: AtomicU64,
}
impl CasCounter {
pub fn increment_via_builtin(&self) -> u64 {
// fetch_update already implements the retry loop internally.
self.value.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| Some(v + 1)).unwrap() + 1
}
pub fn increment_manually(&self) -> u64 {
loop {
let old = self.value.load(Ordering::SeqCst);
let updated = old + 1;
if self.value.compare_exchange(old, updated, Ordering::SeqCst, Ordering::SeqCst).is_ok() {
return updated;
}
// else: someone else moved it first — retry
}
}
}
compare_exchange returns a Result; Err carries the current value so a retry doesn't need a second load. fetch_update is the built-in equivalent of the manual loop.
Single-writer alternative
pub struct SingleWriterCounter {
// No atomics needed — correct only because exactly one thread ever
// calls increment().
value: u64,
}
impl SingleWriterCounter {
pub fn increment(&mut self) -> u64 {
self.value += 1;
self.value
}
}
The runnable Cargo/Criterion project is at content/labs/cas-contention/code/rust/ in this site's repository.
Benchmark methodology
Measured
JMH 1.37, OpenJDK 26.0.1 (HotSpot), Apple M1 Max (10 cores: 8P + 2E), 64 GB unified memory, macOS 26.5.1, arm64. Rust: Criterion 0.5.1, rustc 1.88.0, same machine. Java: 1 fork, 5 warmup + 10 measurement iterations of 1 second each, Mode.Throughput. Rust: default Criterion sampling (100 samples, ~5 s target per benchmark). Single developer machine with ordinary desktop load running alongside — not a dedicated, thermally-stable rig. Treat absolute numbers as illustrative of the effect's shape (contention collapse as thread count grows), not a portable performance claim.
Method: 1, 2, 4, and 8 threads, each repeatedly calling compareAndSet/compare_exchange in a retry loop against one shared counter, measuring aggregate throughput as thread count grows — contrasted against a single-writer counter touched by exactly one thread.
Java (JMH, ops/ms, higher is better):
| Threads | Throughput | 99.9% CI |
|---|---|---|
| 1 (CAS) | 139,812.901 | ±511.706 |
| 2 (CAS) | 29,390.379 | ±600.692 |
| 4 (CAS) | 14,219.198 | ±520.758 |
| 8 (CAS) | 4,520.314 | ±114.867 |
| Single-writer (1, no CAS) | 483,984.812 | ±1,782.614 |
Rust (Criterion, derived ops/ms from median batch time — batch sizes scale with thread count):
| Threads | Batch | Median batch time | Derived ops/ms |
|---|---|---|---|
| 1 (CAS) | 20,000 ops | 167.76 µs | 119,200 |
| 2 (CAS) | 40,000 ops | 1.1505 ms | 34,767 |
| 4 (CAS) | 80,000 ops | 4.1018 ms | 19,505 |
| 8 (CAS) | 160,000 ops | 26.912 ms | 5,945 |
| Single-writer (1, no CAS) | 160,000 ops | 50.356 µs | 3,177,654 |
Contention collapse, unambiguously, in both languages: doubling thread count from 1→2 roughly quarters Java's throughput and cuts Rust's by a similar factor — going from zero contention to any contention at all is the steepest drop in both runs, and throughput keeps falling rather than plateauing all the way to 8 threads. The single-writer counter is dramatically faster than even uncontended CAS — roughly 3.5× in Java, and far more in Rust, where a plain non-atomic scalar increment can be optimized far more aggressively than an AtomicLong under HotSpot or a SeqCst Rust atomic. This is exactly why this lab does not present one cross-language "backoff wins by N%" number — absolute baselines differ enormously by language and JIT/compiler behavior; the shape (collapse under contention, single-writer's advantage) is what transfers. See benchmark.md in the code repository for the full write-up, including a real optimizer pitfall this benchmark hit while being built (Rust's initial single-writer measurement was a physically impossible 314.75 picoseconds — LLVM had proven the whole loop dead and eliminated it; black_box fixed it, see rust.md).
Re-run the project in code/ on your own hardware before treating any of this as authoritative for a design decision — thread count, core topology, and contention pattern all change the shape of this curve.
Common mistakes
- Assuming "lock-free" means "fast regardless of contention" — a CAS loop under heavy contention can be slower than a well-implemented lock, because every failed attempt still pays full coherence cost for zero progress.
- Adding backoff everywhere by default — under light contention, backoff only adds latency to attempts that would have succeeded immediately.
- Treating a successful CAS as proof nothing happened to the value in between — see "ABA" above.
- Reaching for a CAS retry loop when the actual fix is architectural — see "The single-writer alternative."
- Assuming CAS retry loops are fair — a thread can, in principle, lose repeatedly under sustained contention.
When backoff helps, and when it doesn't
Backoff helps when
- Many threads, sustained contention, a genuinely hot shared value — this is where uncontrolled retries waste the most coherence traffic.
- You've measured (not assumed) that failed-attempt rate is high enough that spacing out retries reduces total wasted work.
Backoff doesn't help, or actively hurts, when
- Contention is light or absent — backoff only adds latency to attempts that would have succeeded immediately.
- The delay is poorly matched to the actual contention level — this lab's own scenarios show fixed and exponential backoff performing comparably at one fixed contention level; exponential's real advantage is adapting across varying contention, not a guaranteed win at any single level.
Investigation task
Using the Java or Rust project in code/:
- Run the CAS-counter benchmark at 1, 2, 4, and 8 threads and record throughput at each point.
- Plot throughput against thread count — identify the point where adding threads stops increasing throughput, or starts decreasing it (contention collapse).
- If you have
perf(Linux) or Instruments (macOS) available, look for a cache-miss or coherence-traffic counter and confirm it rises sharply at the same thread count where throughput collapses. - Compare against the single-writer benchmark run single-threaded — note that it isn't a fair apples-to-apples comparison (different architectures solve different problems), but observe how its cost scales (or doesn't) as you'd add more work to the same single thread.
- Write down your CPU model and core count, your measured numbers, and explain any difference from the disclosed numbers above.
Accessibility notes
- The interactive model is fully keyboard operable: the scenario selector and the Java/Rust code tabs are roving-tabindex tablists (arrow keys, Home/End); every step/reset control is a native
<button>. - Every state change is announced to screen readers via an
aria-live="polite"region, in addition to the visible per-contender status badges and the state inspector table — state is never colour-only. - This page has no auto-playing or looping animation; step transitions resolve instantly, so there is nothing beyond the site-wide CSS rule to gate on
prefers-reduced-motion. - With JavaScript disabled, this page's theory, Java and Rust code, benchmark methodology, trade-offs and sources remain fully readable — only the interactive model and the graded quiz require script.
Review questions
Self-check before or after the graded quiz below:
- Why can a CAS retry loop become slower, not just no-faster, as more threads contend for the same location?
- What specifically does exponential backoff adapt to that fixed backoff does not?
- Why doesn't a successful CAS prove nothing changed in between the thread's read and its attempt?
- Give one condition under which adding backoff would make throughput worse, not better.
Quiz
Sources
- The Art of Multiprocessor Programming — Herlihy & Shavit, Morgan Kaufmann, 2020.
- Java Concurrency in Practice — Goetz et al., Addison-Wesley, 2006.
- java.util.concurrent.atomic.AtomicLong — Java SE documentation — docs.oracle.com
- The Rust Reference — `compare_exchange`/`fetch_update` — doc.rust-lang.org/std/sync/atomic
- Exponential Backoff and Jitter — Marc Brooker, AWS Architecture Blog — aws.amazon.com/blogs/architecture
- JMH: Java Microbenchmark Harness — openjdk.org/projects/code-tools/jmh
- Criterion.rs User Guide — bheisler.github.io/criterion.rs