3 · Sharing between threads Draft
Designing around messages
On this page
3.9 Sharing by message
Each of the mechanism sections ended on what its mechanism costs. This section turns those costs into how a program’s threads are laid out, how data crosses between them, and how the result is checked, and ties each choice to the mechanism it answers. It is the only place in the chapter that reads as a list of things to do, and that is deliberate. The principle under all of it fits in one line: one writer per cache line, and share by message, not by memory.
How the threads are laid out
- Each piece of mutable state has exactly one thread that writes it. A thread’s own counters, its own indices, its own buffers are written by that thread alone, and other threads read them or receive copies. Separate independently written values into different lines. One writer per value alone does not prevent false sharing, and even a single-writer line must regain ownership after readers acquire copies.
- Each hot thread is pinned to a core of its own, and threads that hand data to each other are pinned to cores that share an L3. On one socket that is a transfer of 50–100 ns; between two L3s or two sockets it is two to three times that, on every hand-off.
lscpu -egives the L3 of every core, and the ping-pong matrix of Lab 3.3 confirms it. Hyperthread siblings share an L1 and would make transfers nearly free, but they also share the core’s execution units, so a hot thread’s sibling is left idle rather than given its partner. - No lock on the hot path. A lock’s uncontended cost is affordable; its contended cost is a wake-up of 5–200 µs, and a preempted holder’s is milliseconds, both landing in the tail. Locks stay where their simplicity is worth more than their tail: startup, configuration, the control path, anything that runs a few times a second.
How data crosses
- A stream of items between two threads goes through a single-producer single-consumer ring: no CAS, no ABA, nothing to reclaim, and the producer’s
Releaseon its write counter paired with the consumer’sAcquire. A thread with several inputs gets one ring per input and polls them in turn, instead of sharing one multi-producer queue whose tail every producer writes. - A value that many threads read and only the latest of which matters goes through a seqlock when it fits in a line or two, and through a snapshot pointer when it is larger and changes rarely. Never through an
RwLockor anArcclone on every read, because both make readers write a shared line. - Every independently written value sits on its own line.
crossbeam_utils::CachePadded<T>aligns to 128 bytes on x86-64, AArch64 and powerpc64 (on x86 because the adjacent-line prefetcher pulls lines in pairs), to 256 on s390x, to 32 on some 32-bit targets, and to 64 elsewhere. The producer’s fields go together on one line and the consumer’s on another, so that each line has one writer. - Counters bumped by several threads become one counter per thread, each with a single writer, summed by whoever reads them. A counter with one writer can use a local increment and a relaxed atomic store for publication; concurrent readers still need atomic loads.
How the hand-offs are ordered
- Every hand-off is a
Releasestore paired with anAcquireload, and each pair is named in a comment at both ends.Relaxedis kept for values that order nothing, such as statistics and the single-writer counters above.SeqCstis kept for the rare case that needs a single total order, a thread that must see another’s store before it acts on its own, as in the two-flag pattern, and the comment says why. A design that avoids the two-flag pattern avoids the one ordering that costs on x86. - Each side of a ring keeps its own counter in a plain local variable and a cached copy of the other side’s, and reads the other side’s line only when the cached copy says the ring is full or empty. That keeps the cross-core reads to as few as the design allows.
How it is checked
- Every structure that uses an ordering has a loom test, run with
RUSTFLAGS="--cfg loom"in continuous integration, that drives it through full, empty and wrap-around with two or three threads. - The unit tests also run under Miri, which catches data races in the plain accesses around the atomics.
- The test suite and the litmus tests of Lab 3.1 run on an AArch64 machine as well as on x86-64, because the x86 box cannot show three of the four reorderings.
perf c2con a running system shows HITMs only on lines that were designed to be shared: ring counters, ring slots, seqlocks. A HITM anywhere else is a line with two writers, or a read that was not meant to cross.strace -f -e trace=futex -candperf trace -sshow zerofutexcalls from the hot threads after warm-up, and theirvoluntary_ctxt_switchesstay flat.
The table
| Need | Mechanism it answers | How | How to check |
|---|---|---|---|
| No lock on the hot path | futex sleep and wake; convoys; a preempted holder | Rings and seqlocks for every hot hand-off; locks only off the hot path | Zero futex calls on hot threads; voluntary_ctxt_switches flat |
| One writer per line | Line ownership; false sharing | Each mutable value owned by one thread; CachePadded | perf c2c shows HITMs only on lines meant to be shared |
| Hand-offs inside one L3 | Transfer cost across L3s and sockets | Pin each talking pair to cores with the same L3 | lscpu -e; the Lab 3.3 matrix |
| Every hand-off ordered | Compiler and CPU reordering | A Release store paired with an Acquire load, commented at both ends | A loom test per structure; a run on AArch64 |
| A total order only where needed | Store buffering | SeqCst accesses or a correctly fenced design | Hardware litmus test; model the documented loom-supported fence form |
| Counters without contention | Contended RMW on one line | One padded counter per thread, single writer, summed by the reader | Lab 3.2 numbers |
| Latest value to many readers | Readers writing a shared line (RwLock, Arc) | Seqlock with atomic data and the two fences | Torn reads zero; retries per read |
| No ABA, nothing to reclaim | CAS on a recycled value; freed memory | Single-producer single-consumer rings; tagged indices where sharing cannot be avoided | loom with three threads |
| Few cross-core reads | Ownership moving on every read after a write | Cached copy of the other side’s counter | HITMs per item in perf c2c |
| Correct on every target | x86-TSO hiding three reorderings | loom, Miri, an AArch64 runner in CI | All three pass |
Three caveats
Correctness is proved against the model, not measured. A structure that has run for a month on x86 under full load has been tested against one of the four reorderings, and against the compiler’s choices of that month.
Lock-free code is harder to write than locked code, and its bugs are rarer and worse. The proven crates, crossbeam for channels and CachePadded, rtrb for a single-producer single-consumer ring, are the default; a hand-written structure earns its place with its loom tests.
And all of it assumes each hot thread has a core to itself. A spinning consumer on a shared core is preempted like any other thread, and its producer then waits behind it exactly as it would behind a lock.