Lock-Free Structures II: Seqlocks & Epochs
Readers that never block the writer.
Performance · Measure, Profile, Then Go Fast
Why this week
Your matching engine is a single writer. Many things want to read its state: market-data publishers want top of book, the risk engine wants reference data and limits, the gateway wants instrument status, monitoring wants everything. Week 5's queues move events from one thread to another. This week is about sharing state: one writer that must never wait, and any number of readers that must always see a consistent snapshot.
The obvious tool, a reader-writer lock, fails both requirements. Readers write to the lock word (every read() is an atomic read-modify-write), so the lock's cache line bounces between every reader core. A writer that wants in has to wait for readers to leave. You'll replace it with two structures that run, in some form, inside every trading system and every operating system kernel:
- A seqlock. Readers copy the data optimistically and check a sequence number to see whether a write overlapped. They never write shared memory and never block the writer. It's ideal for small, fixed-size, pointer-free data like top of book. Linux uses this pattern to let
clock_gettimeread the kernel's timekeeping data from user space. - An RCU-style pointer swap with epoch-based reclamation. The writer publishes a new immutable snapshot with one atomic pointer store, and readers follow the pointer. The hard part is deciding when the old snapshot can be freed, which leads you to epochs and to the ABA problem that shows up in every lock-free structure that recycles memory.
This week is all memory-ordering reasoning. Keep Mara Bos's book open.
Why does a reader-writer lock scale badly when eight cores read a 32-byte top-of-book ten million times a second, even though readers never conflict with each other?
Day 1 — Readers that leave no trace
Optimistic reading
Read first, then check whether you were allowed to
The seqlock idea in one paragraph. A sequence counter starts even. The writer increments it to an odd value, writes the data and increments it again to the next even value. A reader loads the counter. If it's odd, a write is in progress, so the reader retries. Otherwise it copies the data, loads the counter again, and if the counter hasn't changed, no write overlapped the copy and the snapshot is consistent. If it changed, the reader throws the copy away and retries.
The writer never waits. Readers never write. Each write invalidates the cache line in every reader's cache, so readers take one coherence miss per write (roughly 50–100 ns to pull a line from another core on the same socket, more across sockets) instead of one per read. When the book changes a million times a second and is read ten million times a second, that difference is the whole game.
The price is a set of hard constraints you have to respect:
- One writer. Two writers would interleave their odd/even steps. If you need several, serialise them with a mutex or a CAS on the counter. Readers are unaffected.
- The data must be plain bytes. A reader may copy a half-written value before it notices and retries. That's harmless for integers and fatal for pointers: if you dereference a torn pointer before the check, you've already crashed. Seqlock payloads are
Copy, fixed-size and pointer-free. - Readers can starve. A writer that writes continuously makes every read retry. Top of book changes at most a few million times a second and a read takes nanoseconds, so this is fine. A seqlock over data that's rewritten in a tight loop isn't.
- The copy must be cheap. Retry probability grows with copy time. A few cache lines is fine, but kilobytes of depth isn't (Day 3 has the answer for those).
Reread chapter 3's section on fences: release and acquire fences synchronising through relaxed operations is exactly what the seqlock needs. Then read chapter 10's short sections on RCU and the sequence lock. They're sketches of this week's two structures.
List every reader of engine state in your exchange (market data, risk, gateway, monitoring, admin). For each: what data, how big, how often read, how stale it may be. Mark each one seqlock, pointer swap, message queue or 'just ask the engine'.
Day 2 — The seqlock, correctly
Racing reads without undefined behaviour
Atomic words, two fences and one comparison
There's a trap in the paragraph above. "The reader copies the data while the writer may be writing it" is a data race if the data is ordinary memory, and in Rust (as in C++) a data race is undefined behaviour even if you throw the result away. The compiler may assume non-atomic memory isn't concurrently modified and optimise accordingly. Many real seqlocks copy with ptr::read_volatile plus fences. That works on today's compilers but is UB by the letter of the memory model. A proposed atomic memcpy (RFC 3301, AtomicPerByte) would make it sound, but it isn't stable.
The sound version, from Hans Boehm's analysis, stores the payload in atomics accessed with Relaxed and puts all the ordering in the sequence number and two fences:
// writer (single)
let s = seq.load(Relaxed);
seq.store(s + 1, Relaxed); // odd: write in progress
fence(Release); // (1) orders the odd store before the data stores
data.store(..., Relaxed) /* each word */;
seq.store(s + 2, Release); // (2) publishes the data
// reader
let s1 = seq.load(Acquire); // (3) pairs with (2): previous write's data is visible
if s1 is odd { retry }
let copy = data.load(Relaxed) /* each word */;
fence(Acquire); // (4) orders the data loads before the re-check
let s2 = seq.load(Relaxed);
if s1 != s2 { retry }Why it works. Suppose the reader's relaxed copy picked up even one word from a write that started after s1 was read. That word was stored after the writer's release fence (1), and the reader's acquire fence (4) comes after the load that saw it. Fence-to-fence synchronisation then makes the writer's odd store happen-before the reader's s2 load, so s2 is at least that odd value and differs from s1, and the reader retries. Conversely, (2) and (3) form an ordinary release/acquire pair, so a reader that sees an even s1 sees all of that write's data.
On x86, all of this is nearly free. Relaxed and acquire loads are plain movs, release stores are plain movs, and acquire and release fences emit no instruction: they only stop the compiler from reordering. On ARM they become dmb barriers or acquire/release loads and stores. Write the orderings for the model, not for the machine you happen to test on.
Step Reader 1 in each variant: the correct reader throws the mixed copy away, and each broken one returns a crossed book that no write ever produced. Then run all three threads and raise the writer rate until readers start to starve.
The reader's protocol is the classic message-passing pattern: data first, then a flag, with release on the flag store and acquire on the flag load. Step through it below and find the weakest orderings that still forbid the reader seeing the flag without the data.
data = flag = 0data.store(1, Relaxed); flag.store(1, Relaxed);
let r1 = flag.load(Relaxed); let r2 = data.load(Relaxed);
r1 == 1 && r2 == 0 happen?Nothing orders the two stores or the two loads, so the reader can see the flag before the data. x86 never shows it (TSO keeps stores in order and loads in order), which is exactly why this bug hides until you run on ARM.
In a seqlock reader, why is checking that the first sequence number is even not enough, and why does the reader need the acquire fence before the second load?
Seqlock reader/writer
use std::sync::atomic::{fence, AtomicU64, Ordering};
/// Top of book: what market-data readers want, consistently.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Tob {
pub bid_px: u64,
pub bid_qty: u64,
pub ask_px: u64,
pub ask_qty: u64,
}
/// Single-writer seqlock. The payload lives in atomics accessed with
/// Relaxed ordering, so racing reads are not data races (no UB); the
/// sequence number tells the reader whether what it read is consistent.
/// Keep the field names: the harness pokes `seq` to simulate a writer
/// caught mid-update.
pub struct SeqLock {
seq: AtomicU64,
words: [AtomicU64; 4],
}
impl SeqLock {
pub fn new(v: Tob) -> Self {
todo!()
}
/// Single writer only. Never blocks, never waits for readers.
pub fn write(&self, v: Tob) {
todo!()
}
/// One attempt: Some(snapshot) if no write overlapped the read.
pub fn try_read(&self) -> Option<Tob> {
todo!()
}
/// Retry until consistent. Readers never write shared memory.
pub fn read(&self) -> Tob {
todo!()
}
pub fn seq(&self) -> u64 {
self.seq.load(Ordering::Acquire)
}
}
Short and precise: why the naive seqlock is a data race in C++11, which fence placements are correct, and what they cost on real hardware. The reader in your drill is his recommended version.
Day 3 — Swapping pointers, and the reclamation problem
Publish a new world in one store
Reading is trivial; freeing is the hard part
Some shared state is too big to copy on every read: top-20 depth for the market-data publisher, the instrument reference table, the risk limits for every account. For those, flip the approach. The writer builds a complete new immutable snapshot off to the side and publishes it with one atomic pointer store:
let fresh = Box::into_raw(Box::new(build_snapshot(&state)));
let old = current.swap(fresh, AcqRel); // readers now see `fresh`
// ... when can we free `old`?
Readers load(Acquire) the pointer and read through it. There's no retry, no copy and no torn data, because nothing a reader can reach is ever mutated. This is the read side of Linux's RCU (read-copy-update).
The entire difficulty is the last comment. A reader may have loaded old a nanosecond before the swap and still be reading it, so freeing it immediately is a use-after-free. The ways out, and why most don't suit a hot reader:
- Never free, or rotate a fixed pool of N snapshots. That assumes no reader is ever slower than N writes. "Ever" includes a reader descheduled for 10 ms, so it's a time bomb.
- Reference counting (
Arc). Every read does an atomic increment and decrement on the snapshot's shared count. That's the RwLock cache-line problem again. There's also a subtle race: load the pointer, then increment the count, and the object can be freed in between. That's whyAtomicPtraround anArcdoesn't just work, and why crates likearc-swapexist. - Hazard pointers (Maged Michael, 2004). Each reader publishes the pointer it's about to use in a per-thread slot, then re-checks that it's still current. A reclaimer frees only objects that no slot mentions. Garbage stays bounded, but every protected load costs a store plus a full fence.
- Epoch-based reclamation. Readers announce when they're reading rather than what, and memory is freed only once every reader has moved past the epoch in which it was unlinked. A read costs almost nothing, and the bookkeeping is amortised across many operations. That's tomorrow.
The three mechanisms (publish-subscribe, wait for pre-existing readers, maintain multiple versions) and the grace-period idea. Epoch-based reclamation is a user-space cousin of the grace period, so map each RCU concept onto it as you read.
Read the algorithm and the bounded-garbage argument. Note precisely where the store-load fence is needed and why. You'll meet the same fence in epochs tomorrow, in a cheaper place.
Why is 'just use Arc for the snapshot' a poor fit for a market-data reader that reads ten million times per second across six cores?
Day 4 — Epoch-based reclamation
Free it two epochs later
Pin, retire, advance, collect
Epoch-based reclamation (EBR), from Keir Fraser's thesis and implemented in Rust by crossbeam-epoch, has four operations:
- Pin. Before touching shared pointers, a thread copies the global epoch
Einto its own slot and marks itself active. It may then load and follow pointers freely until it unpins. - Retire. When the writer unlinks an object (the old snapshot after a swap), it doesn't free it. It appends it to a garbage list tagged with the current epoch.
- Advance. The global epoch may move from
EtoE+1only when every active thread's slot saysE. - Collect. Garbage tagged
eis freed once the global epoch reachese+2.
Why two? An object retired in epoch e might be held by a reader pinned in e. A reader still pinned in e−1 could also have loaded it before it was unlinked. Advancing to e+1 requires every active thread to be at e, so e−1 readers are gone. Advancing to e+2 requires every active thread to be at e+1, so e readers are gone too. Anyone pinned now started after the unlink and can't reach the object. Three epochs of garbage in rotation is the whole data structure.
The subtle part is the pin. The reader stores its epoch, then loads the shared pointer. The writer stores the new pointer, then loads the reader slots to decide whether it may advance. That's the store buffering pattern. With anything weaker than sequential consistency, both sides can read the old value: the reader loads the old pointer while the writer doesn't yet see the reader's pin, advances, and frees the snapshot under the reader. Each side needs a SeqCst fence (a full store-load barrier) between its store and its load. Try it below with every combination of orderings.
x = y = 0x.store(1, Relaxed); let r1 = y.load(Relaxed);
y.store(1, Relaxed); let r2 = x.load(Relaxed);
r1 == 0 && r2 == 0 happen?Each store can sit in its core’s store buffer while the following load reads memory. This is the one reordering x86-TSO itself performs (store→load).
That fence is the one real cost of EBR. On x86 it's an mfence or a locked instruction, tens of cycles. crossbeam-epoch uses a locked read-modify-write on x86 because it's cheaper than mfence. So you pin once per batch (a burst of reads or one market-data publish cycle), not once per pointer load.
The failure mode to design against: a thread that stays pinned stops reclamation for everyone. A reader preempted while pinned, or one blocked on I/O inside a pinned section, freezes the global epoch, and garbage grows without bound. Pinned sections must be short, bounded and never block. Hazard pointers don't have this problem (garbage is bounded), which is their main argument. For an exchange, with a fixed set of pinned threads on isolated cores that pin for a few hundred nanoseconds at a time, EBR is the better fit.
Read the epoch-based reclamation section: the three-epoch argument in its original form and the costs Fraser measured against alternatives. Skim the rest unless you're curious about lock-free skip lists.
The blog post that introduced crossbeam's epoch scheme, written for Rust programmers. Pay attention to how guards tie pointer lifetimes to the pinned section, which is the type-system half of EBR's safety argument.
Guard, Atomic, Shared, defer_destroy. Read the source of pin() and find the SeqCst fence and its x86 special case. Then decide whether your capstone uses this crate or a smaller fixed-thread version you write yourself.
Why may an object retired in epoch e be freed only when the global epoch reaches e+2, and what single misbehaving thread can stop reclamation entirely?
Your market-data publisher reads the snapshot cell once per book update, at up to 2M updates per second. Where exactly do you pin and unpin? Estimate the cost of the SeqCst fence per pin, and the worst-case garbage if the publisher thread is descheduled for 5 ms while pinned.
Day 5 — ABA
Same value, different world
Why compare-and-swap compares the wrong thing
compare_exchange checks that a word still holds the value you saw, not that nothing happened to it. If the value went A → B → A while you were preempted, your CAS succeeds against a world that no longer matches your assumptions. That's the ABA problem, and the textbook victim is the Treiber stack (R. Kent Treiber, IBM, 1986):
- Thread 1 starts a pop. It reads
head = AandA.next = B, and is preempted before its CAS. - Thread 2 pops
A, popsB(and keeps using it), then pushesAback. The head isAagain, butA.nextis nowC. - Thread 1 resumes.
CAS(head, A → B)succeeds, because the head isA. The stack's head is nowB, a node Thread 2 owns. The next pop handsBout twice.
Nothing here needs free. Recycling nodes through a pool is enough, and the week 27 slabs recycle nodes constantly. With real deallocation it's worse: B may have been freed and reused for something else.
The fixes:
- Tagged pointers. Pair the pointer with a counter that every successful CAS increments, and compare both. A→B→A now changes the tag, so the stale CAS fails. Packing a 32-bit node index and a 32-bit tag into one
u64works on every platform. A full 64-bit pointer plus a tag needs a double-width CAS (cmpxchg16bon x86-64), which stable Rust doesn't expose portably. A 32-bit tag can wrap, so a thread stalled for exactly 2^32 operations on that word could still be fooled. That's astronomically unlikely, but know the bound. - Safe reclamation. If a node can't be reused while any thread might still hold a reference (EBR, hazard pointers), A can't come back while Thread 1 holds it, and the problem disappears with the memory-reuse bug.
- LL/SC. ARM's load-exclusive/store-exclusive fails if the location was written at all, value aside. Rust's
compare_exchangeis defined by value, though, and compiles to a real CAS instruction on ARMv8.1+, so don't count on it.
In the drill you'll make the interleaving deterministic by splitting pop into prepare (read head and next) and commit (the CAS), so the harness can play two threads by hand. On the untagged stack, the test passes when the bug reproduces. Then the tags make the same script fail safely, and a 4-thread stress run checks that the tagged stack never hands one node to two owners.
Reproduce and fix ABA with tags
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
pub const NIL: u32 = u32::MAX;
/// Head word layout: low 32 bits = node index, high 32 bits = tag.
pub fn pack(idx: u32, tag: u32) -> u64 {
todo!()
}
/// Returns (idx, tag).
pub fn unpack(word: u64) -> (u32, u32) {
todo!()
}
/// What the first half of a pop observed.
#[derive(Clone, Copy, Debug)]
pub struct PopAttempt {
pub head: u64,
pub next: u32,
}
/// Treiber stack over a fixed pool of node indices (nodes are never freed,
/// only recycled, which is exactly how ABA bites).
pub struct Stack {
head: AtomicU64,
next: Vec<AtomicU32>,
tagged: bool, // untagged: the tag is always 0; tagged: +1 on every successful CAS
}
impl Stack {
/// Empty stack over nodes 0..capacity.
pub fn new(capacity: usize, tagged: bool) -> Self {
Stack {
head: AtomicU64::new(pack(NIL, 0)),
next: (0..capacity).map(|_| AtomicU32::new(NIL)).collect(),
tagged,
}
}
/// Push node `idx` (the caller owns it). CAS loop.
pub fn push(&self, idx: u32) {
todo!()
}
/// First half of pop: read the head and its successor. None if empty.
pub fn pop_prepare(&self) -> Option<PopAttempt> {
todo!()
}
/// Second half: one CAS from what we saw to the successor.
/// Some(idx) on success, None if the head changed in between.
pub fn pop_commit(&self, a: PopAttempt) -> Option<u32> {
todo!()
}
pub fn pop(&self) -> Option<u32> {
loop {
let a = self.pop_prepare()?;
if let Some(i) = self.pop_commit(a) {
return Some(i);
}
std::hint::spin_loop();
}
}
}
Explain ABA on a Treiber stack in three sentences, and say why epoch-based reclamation prevents it even without tags.
Day 6 — Design the capstone
Two primitives for crates/sync
Generic where it's safe, concrete where it isn't
Before you write code, settle three decisions:
How generic is the seqlock? A SeqLock<T> has to copy T through atomics. The sound approach is to require T: Copy, check that size_of::<T>() is a multiple of 8, and require no padding (padding bytes are uninitialised, and reading them as u64 is UB). Then copy word by word through [AtomicU64; N]. If that feels like too much unsafe for one week, make a concrete TopOfBookLock and say so in your notes. Both are defensible. A generic version that quietly uses read_volatile isn't.
The repo's reference takes the read_volatile route, though not quietly. SeqLock<T: Copy> copies into a MaybeUninit<T>, so a torn copy is never treated as a T, and calls assume_init only after the sequence check passes. Its module docs explain why: this is the mitigation Linux, crossbeam's AtomicCell and the seqlock crate use, and it remains a data race by the letter of the memory model. The atomic-word design above is the sound alternative.
Where does it sit in memory? Put the sequence word and the payload in the same cache line (8 + 32 bytes fits in 64), so a reader takes one miss per write, not two. Align the whole thing to 64 bytes so no neighbouring field shares its line (week 4's false sharing).
Who may pin, and for how long? For the snapshot cell, a fixed set of registered reader threads, each with its own cache-padded epoch slot, is simpler and faster than crossbeam's dynamic registration. The repo sits in between: cell.reader() registers a reader by taking a lock and allocating one cache-padded slot, once per reading thread, and a dropped reader's slot is pruned the next time the writer reclaims. Whichever you choose, write down the maximum pinned-section length and test it.
Write the public API of your EpochCell<T>: how a reader obtains a guard, what lifetime the returned reference has, how the single writer publishes and when it collects garbage. Show the one line where the borrow checker prevents a reader from keeping a reference past unpin.
Capstone
Build both primitives in crates/sync:
SeqLock<T: Copy>(seqlock.rs):new,write,try_read,readandversion(completed writes). Writers are serialized by a CAS on the sequence number, so a second writer is safe but spins; the design target is still one writer.SnapshotCell<T>(snapshot.rs):publishandreader, with a per-threadSnapshotReader<T>. The reader'sread(|v| ..)pins only for the closure and never touches the version's reference count;load()returns anArc<T>to keep.
The cell's reclamation is a simplified epoch scheme, not crossbeam's three-epoch rotation. Each publish bumps the cell's epoch, retires the old version tagged with it, and frees every retired version older than the oldest pinned reader's epoch. The pin and publish steps are all SeqCst, which is what Day 4's store-buffering argument needs; the module docs give the proof.
In the repo the two primitives stand alone: the week 34 tests exercise them with reader threads, and the engine and market data don't use them yet. The WebSocket server's marketdata::SnapshotCell from week 16 is a separate, mutex-based cell. Putting them to work is the natural next step: the engine publishes top of book through the seqlock after every book change and market-data readers poll it, while a larger snapshot (top-N depth, instrument reference data) goes through the epoch-reclaimed cell.
What the tests prove beyond the happy path:
- No torn reads under stress. Readers check a payload whose eight words must always agree while one writer (then two) hammers it. Run the tests under Miri too if you have a nightly toolchain. Its data-race detector accepts the atomic-word design and would flag a
read_volatileone, which is what the repo's reference uses (Day 6). - No use-after-free in the snapshot cell. A reader pinned across ten publishes must still see the version it pinned, with nothing freed. Readers racing thousands of publishes, through both
readandload, must only see whole versions that never go backwards. If you can, model-check the pin and reclaim logic withloomon a reduced configuration. - Bounded garbage. With no reader pinned, each publish frees the previous version at once. A pinned reader holds back reclamation only until it unpins. And after a race, every version but the current one is freed exactly once.
Reading the benchmark. The target is a p99 for a seqlock read under a concurrent writer. The bench's writer, on another core, publishes a 40-byte quote about 2 million times a second (a busy instrument's top of book). The reader times batches of 64 reads and records each batch's per-read average, because one read is cheaper than the timer. An uncontended read is a few nanoseconds; a read right after a write pays a cross-core cache miss, typically 50–100 ns. Inside a batch that miss is spread over 64 reads, so the p99 says how many misses land in one batch, not what one unlucky read costs. Report the writer's rate next to your number, and don't tune the benchmark into irrelevance with a writer that never writes. Think about what the real ratio is in your exchange: book updates per second against reads per second.
crates/syncSnapshots for every reader, stalls for none
A seqlock for top-of-book and an epoch-reclaimed snapshot cell in crates/sync, used by market-data readers without ever making the engine wait.
cargo xtask test --week 34 # tests named w34_*
cargo xtask bench --week 34 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 34 tests pass w34_*
- Seqlock read, writer at ~2M updates/s (batched) w34.seqlock_readtarget p99 ≤ 10 ns
Where else in your exchange is there a reader-writer lock or a Mutex guarding state the engine writes and others read? Pick the worst one, decide whether it becomes a seqlock, a pointer swap or a message, and write the two-sentence argument you'd give in a code review.
Feeds into
- Week 50's low-latency interview deep dives lean heavily on this week: "implement a seqlock", "what is ABA", "how does RCU know when to free" and "why is a reader-writer lock slow" are standard questions, and you should be able to answer each with the memory-ordering argument, not just the slogan.