Scalable Synchronization and Contention
You have a program that runs beautifully on 4 cores. You move it to a 64-core server expecting a
16\times boost — and it runs slower than on 4. Not a little slower;
measurably, humiliatingly slower. The culprit is almost always a single hot lock. This lesson is about the
physics of that collapse: why one lock does not merely fail to help but actively destroys
throughput as cores pile on, and the toolkit — per-CPU data, sharding, RCU, cache-line padding — that
kernel engineers use to claw scalability back.
The intuition from
Amdahl's law
already warns you that a serial fraction caps speedup. But Amdahl is optimistic: it assumes the
serial part costs the same no matter how many cores wait. Real locks are worse, because contention adds a
cost that grows with the number of contenders — the
coherence
traffic of shuttling the lock's cache line between them. Past a point, adding a core
subtracts throughput.
Two costs, not one
A single global lock imposes two distinct penalties as you add cores, and it is vital to keep them
apart:
- Serialization — only one core can be inside the critical section at a time, so that
work cannot go in parallel. This is the Amdahl term. It flattens throughput to a ceiling but does not, by
itself, make things go backwards.
- Coherence / crosstalk — every core that tries the lock must pull its cache
line to its own cache in Modified state, invalidating everyone else. This traffic grows roughly as the
square of the number of contenders, and it is pure overhead stolen from useful work. This is the
term that turns the curve downward.
The Universal Scalability Law (USL) captures both. If N cores
would ideally give throughput N, the real throughput is
C(N) \;=\; \frac{N}{1 \;+\; \alpha\,(N-1) \;+\; \beta\,N\,(N-1)}.
Here \alpha is the contention (serialization) coefficient and
\beta is the coherence (crosstalk) coefficient. With
\beta = 0 you recover an Amdahl-like ceiling. But any
\beta > 0 gives the curve a peak: it rises, tops out, and then
declines — the retrograde scalability that defines a contended lock.
- Serialization: the critical section runs one-at-a-time — an Amdahl ceiling of
1/\alpha;
- Coherence: contenders bounce the lock's cache line, adding
O(N^2) traffic — the \beta term;
- together they give a throughput peak at
N^\star = \sqrt{(1-\alpha)/\beta}, beyond which more cores mean
less throughput.
Watch the curve turn over
Below is the USL. The dashed line is the fantasy — linear scaling, throughput equal to core count. The
solid curve is reality. Drag \alpha (contention) and watch the ceiling drop;
then nudge \beta (coherence crosstalk) up off zero and watch the curve stop
being a ceiling and become a hill — throughput peaks and then falls off a cliff as cores are
added. That downturn is the single global lock destroying your machine. The peak sits at
N^\star = \sqrt{(1-\alpha)/\beta}; adding cores past it is worse than useless.
The toolkit: how kernels claw scalability back
Every scalable-synchronization technique is, at heart, a way to make cores stop touching the same
cache line. If they don't share the line, there is no coherence traffic, and \beta
collapses toward zero. The kernel toolkit:
- Per-CPU data — give each core its own private copy of the counter/statistic/free-list,
so the common path touches only local memory (no sharing at all). Aggregate lazily when someone actually
needs the global total. This is why kernel statistics counters are per-CPU.
- Sharding / lock striping — replace one lock over a big table with
k locks, each over a slice, hashing keys to shards. Contention drops by a
factor of k because independent operations touch independent locks (Java's
old \texttt{ConcurrentHashMap} did exactly this).
- RCU — for read-mostly data, remove the read-side lock entirely (see
RCU);
readers touch no shared writable line at all.
- Cache-line padding — eliminate false sharing (below) by padding hot fields to
their own cache line.
False sharing: contention you never asked for
The cruellest scalability bug is false sharing. Two cores update two completely
independent variables — no logical contention whatsoever — but the two variables happen to sit in the
same 64-byte cache line. Coherence works at cache-line granularity, so each write invalidates the
whole line in the other core's cache, and the line ping-pongs exactly as if the cores were
fighting over one lock. Your carefully lock-free per-core counters silently serialize because the compiler
packed them adjacently. The demo shows the cost.
// False sharing: two "independent" per-core counters that land in the SAME cache line
// ping-pong that line between cores. Padding them to separate lines removes the traffic.
const LINE = 64; // bytes per cache line
// Model: coherence transfers = number of writes that hit a line another core owns.
function coherenceTransfers(layoutBytesApart: number, writesPerCore: number, cores: number): number {
const sameLine = layoutBytesApart < LINE; // do the two counters share a line?
// If they share a line, every write by any core steals the line from the previous writer.
return sameLine ? writesPerCore * cores : 0;
}
const writes = 1_000_000, cores = 2;
const packed = coherenceTransfers(8, writes, cores); // counters 8 bytes apart -> SAME line
const padded = coherenceTransfers(64, writes, cores); // counters padded to separate lines
console.log(`packed (8B apart, shared line): ${packed.toLocaleString()} coherence transfers`);
console.log(`padded (64B apart, own lines): ${padded.toLocaleString()} coherence transfers`);
console.log(`speedup from padding: ${packed === 0 ? "n/a" : "avoids " + packed.toLocaleString() + " bounces"}`);
// The scalability-commutativity rule of thumb: operations that COMMUTE can, in principle,
// be made to scale (no communication forced). Non-commuting ops force shared state.
function commutes(opA: string, opB: string): boolean {
// increment/increment commute (order-independent); read-after-write does not.
const commutingPairs = new Set(["inc|inc", "read|read"]);
const key = [opA, opB].sort().join("|");
return commutingPairs.has(key);
}
console.log(`inc & inc commute? ${commutes("inc", "inc")} -> can scale`);
console.log(`write & read commute? ${commutes("write", "read")} -> forces sharing`);
Reader-writer locks are not a free lunch
A tempting "fix" for a read-heavy lock is a reader-writer lock: many readers concurrently,
writers exclusive. But it disappoints on many cores for a subtle reason: readers still write. To
register as a reader, each must atomically bump a shared reader count — and that shared counter's cache
line ping-pongs between all the readers exactly like a plain lock. So a rwlock under heavy read load can
scale no better than a mutex, because the "read lock" itself is a contended write. This is
precisely the gap RCU fills: RCU readers write nothing, so there is no shared line to bounce. The
lesson: a lock that requires any shared writable state on the hot path cannot escape the coherence tax —
the only true fix is to stop sharing the line.
This crystallises into a design principle, the scalability commutativity rule: whenever
two operations commute — their results don't depend on the order they run — it is possible to implement
them so they scale (require no communication). If operations are forced to share mutable state, look
for a reformulation where they commute; if they genuinely don't commute, no amount of clever locking will
make them scale, and you must change the interface, not the lock.
Operators of big in-memory databases learn a counterintuitive trick: sometimes you cap the thread pool
below the core count and throughput rises. The USL explains it exactly. If a workload's
coherence coefficient \beta is high, its throughput curve peaks at some
N^\star and declines after. Running on more cores than
N^\star puts you on the downslope, where extra threads add more crosstalk than
work. Pinning to N^\star cores lands you at the summit. This is why "throw more
hardware at it" can backfire, and why serious tuning starts by measuring
\alpha and \beta from a scalability curve rather than
assuming linear speedup. The bottleneck is not the CPUs; it is the conversation between them.
Splitting one lock into a hundred, or going lock-free, attacks the serialization term
\alpha — but it does nothing for \beta if the cores
still hammer a shared cache line. A lock-free counter that every core increments with a CAS still bounces
one line and still collapses under the same O(N^2) crosstalk. Fine-grained
locking on a tree whose root pointer every operation reads-then-writes still serializes on the root's line.
The mental model to keep is not "locks bad, lock-free good" — it is "shared writable cache lines
bad." Ask of any hot path: how many distinct cache lines do N cores write?
If the answer doesn't grow with N (per-CPU data, RCU, sharding), you scale; if
it's a small constant that all cores touch, you don't — whatever the locking discipline.