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:

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.

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:

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.