KnowSys
ConcurrencyChapter 14

Lock-Free & Wait-Free Programming

Follow two threads that add to one shared counter without a lock: how a failed compare-and-swap retries, what "lock-free" promises and what it doesn't, why a retry loop can be ten times slower than a mutex at eight threads, and the two bugs, ABA and freeing memory, that make real lock-free code hard.

⏱ 40 min read◆ IntermediateAssumes: a C++ compiler; chapter 03 (atomics) and chapter 13 (locks)
Start reading

Two threads share a counter that currently reads 5. Each is about to add 1, so once both have finished it has to read 7. That sounds easy, but "add 1" is three steps: read the counter, add one to what you read, write the result back. If both threads read 5 before either one writes, both write 6, and one of the additions disappears. Chapter 13 starts from that bug.

The usual cure is a lock. One thread takes the lock, does its three steps and lets go, and the other thread waits its turn and then does the same. The counter ends at 7, the code is simple, and most of the time a lock is the right answer. It has one oddity worth noticing, though. The thread that waits isn't waiting for the work to be done. It's waiting for another thread to get around to finishing it, and if the operating system stops that thread at the wrong moment, everyone queued behind it stops too.

So here is the question for this chapter: can both threads get the counter to 7 with neither of them ever holding anything the other one needs? They can, with a single hardware instruction that says "change this number to 6, but only if it's still 5", and a thread that gets told "no" just tries again. Code written this way is called lock-free. It sounds like a pure win, so the measurements later in the chapter come as a surprise: with eight threads on the same counter, the lock-free version is ten times slower than a mutex. We'll follow the counter first, then a stack, then ask exactly what lock-free promises, measure what it costs, and look at the two bugs that make real lock-free code hard.

01The weak spot in a lock

1.1Waiting on a thread

Let's put the counter behind a mutex, the lock from chapter 13. Thread 1 takes the lock, reads 5, writes 6 and releases the lock. Thread 2, which asked a moment too late, waits for the release, then reads 6, writes 7 and releases. The counter ends at 7 every time, and that reliability is why a lock is the first tool everyone reaches for.

The weak spot comes from how threads are run. A machine usually has far more threads than CPU cores, so the operating system's scheduler takes a thread off its core whenever it likes, to give the core to another thread. We'll say a thread that has lost its core this way has been descheduled. The thread gets no warning and no say, so it can be descheduled in the middle of its work while it holds the lock. Two other things can pause a thread just as abruptly. A page fault happens when the thread touches memory the kernel hasn't set up for it yet, and the kernel stops the thread to sort that out. A signal handler is a function the kernel runs on a thread when a signal arrives, interrupting whatever the thread was doing at the time.

Here is the first of those going wrong for our counter:

A lock, and a holder that gets stopped
Thread 1The lockone owner at a timeThread 2Shared counterin memorycounter5lockfreeadd 1wants the lockadd 1wants the lock
Step 1. The counter holds 5 and the lock is free. Both threads want to add 1, so the answer has to be 7.
1 / 6

Thread 2 was ready to work the whole time, and the only thing in its way was a thread that wasn't running.

1.2How bad is that?

?How bad is a holder that stops?

Usually the holder gets its core back soon, and the waiters pay some extra latency, meaning the time an operation takes from start to finish. Depending on how long the holder is stopped, that's anything from a few microseconds to a few milliseconds, which is annoying and rarely fatal. Two situations can't afford it at all. The first is an audio program, which has a callback: a function the sound system calls every few milliseconds, and which must hand back the next batch of samples on time. If the callback is stuck behind a lock held by a descheduled thread, the sound glitches. The second is a signal handler that asks for a lock its own thread already holds. The handler is running on top of the interrupted code, so that code can't continue to release the lock, and the thread waits for itself forever. Waiting forever like this is called a deadlock.

In both cases the trouble comes from one thing: a thread can be stopped while it holds something everyone else needs. Take away the holding and the trouble goes with it. So what could the threads use instead of a lock?

02Update, check, retry

2.1Work on a private copy

Think of a shared price list kept by an office. Instead of passing one pen around, everyone copies the master list, makes their change on their own copy, and hands it in. The master accepts a copy only if nobody has changed the master since it was copied. If someone has, your copy is stale, so you copy the master again and redo the work. Nobody holds a pen while thinking, so nobody can be stuck behind a colleague who went to lunch. The price is that some work gets thrown away.

Processors offer exactly this for one word of memory, an integer of the size the processor handles in one go (8 bytes on today's 64-bit machines). The instruction is called compare-and-swap, or CAS, and you met it in chapter 13, where the lock itself was built from it. Here we'll use it on the counter directly. You give it two values: the one you expect to find (what you read earlier) and the one you want to leave behind. In a single step that no other thread can interrupt, it checks whether the word still holds the expected value, and if so, replaces it with the new one. If the word holds anything else, it changes nothing and reports the value it found. An operation that no other thread can ever see half-done is called atomic, and CAS is atomic.

Wrapped in a loop, that gives a recipe: read the counter, work out the new value privately, try the CAS, and if it fails, go round again with the value it reported. We'll call that a CAS loop. Here it is for our counter:

Two threads add 1 with a CAS loop, no lock
Thread 1Shared counterin memoryThread 2counter5read 5new value 6read 5new value 6
Step 1. The counter holds 5 and there is no lock. Thread 1 and Thread 2 will each add 1.
1 / 6

Compare the two ways of handling a collision side by side:

MutexCAS loop
While updatingHolds the lock; others waitHolds nothing; others carry on
If it's descheduled midwayEveryone waiting is stuckNothing shared has changed yet
When two collideOne sleepsOne retries

The middle row matters most, because it answers the problem from section 1. A thread that is stopped halfway through a CAS loop has only touched its own private values, so no other thread has to wait for it.

2.2Trying it: two threads, no lock

Let's run it for real. The program below starts two threads that each add 1 to a shared counter a million times, using a CAS loop, and counts how many times a thread had to go round again. Three pieces of it need explaining. std::atomic<long> is a long whose operations are atomic. compare_exchange_weak(seen, seen + 1) is the CAS: it expects the counter to hold seen and wants to leave seen + 1. It returns true if the swap happened. On failure it returns false and also writes the value it found into seen, so the loop can try again without reading the counter a second time. The weak version is allowed to fail occasionally even when the value matched. Some processors build CAS from a pair of instructions, a load and a conditional store, and the store can fail for reasons that have nothing to do with the value, such as another core touching nearby memory. A retry loop absorbs those extra failures for free. Finally, each thread counts its failures in a local variable and adds them to retries once at the end, so the counting doesn't become a second shared hot spot.

Save it as cas.cpp, build it with clang++ -std=c++20 -O2 -pthread cas.cpp -o cas, and run ./cas. First, a prediction.

Predict before you read on

Each thread adds 1 a million times, and a failed CAS is retried. What does the counter read at the end?

Two threads increment one counter with compare-and-swap and count their failed attempts
cpp
C++
#include <atomic>
#include <cstdio>
#include <thread>
 
std::atomic<long> counter{0};
std::atomic<long> retries{0};
 
void work(long n) {
    long failed = 0;
    for (long i = 0; i < n; i++) {
        long seen = counter.load();
        while (!counter.compare_exchange_weak(seen, seen + 1))   // lost the race: try again
            failed++;
    }
    retries += failed;
}
 
int main() {
    const long n = 1000000;
    std::thread a(work, n), b(work, n);
    a.join(); b.join();
    std::printf("counter %ld\nfailed attempts %ld\n", counter.load(), retries.load());
}
output
C++
counter 2000000
failed attempts 398646

The counter is exactly 2,000,000, so no update was lost, and no thread ever held a lock. The second line is the cost. In this run the threads made 2,398,646 attempts to complete 2,000,000 increments, so about one attempt in six was thrown away. The number changes a lot between runs, anywhere from about 200,000 to about 620,000 failures, because it depends on how the two threads happen to overlap. Every one of those failures is work done and discarded, and that is what the CAS loop pays instead of waiting.

A counter is a single word, though, which makes it the easiest case. Most shared data is bigger: lists, queues, stacks. Can the same trick protect a chain of linked nodes?

03From one word to a stack

3.1Pushing without a lock

A stack is a pile where the last thing put on is the first thing taken off. Putting a node on is a push and taking the top one off is a pop. We'll build it as a chain of nodes, each holding a value and a pointer called next to the node below it, with one shared pointer, head, pointing at the top node. Say the stack holds one node, A, and two threads want to push X and Y at the same moment.

An animation of pushing 33 onto a linked-list stack holding 22 and 11: the new node's pointer is set to 22, then Top moves to 33
A stack built from nodes. Each node holds a value and a `next` pointer to the node below, the last one points at nothing (NULL), and `Top` is our `head`. A push is two steps: point the new node at the current top, then move `Top` to the new node. Everything in this section is about the gap between those two steps.Image: Michel Bakni, CC BY-SA 4.0, via Wikimedia Commons
C++
struct Node { int value; Node* next; };
std::atomic<Node*> head{nullptr};   // the top of the stack; nullptr when empty

head is atomic, so each read and each write of it happens in one piece. The obvious push has two steps. Point the new node's next at the current top, then make head point at the new node:

C++
void push_unsafe(int v) {
    Node* n = new Node{v, head.load()};   // 1. point at the current top
    head.store(n);                        // 2. make this node the new top
}

Run that on two threads and the same thing goes wrong as with the counter, even though each step on its own is atomic. Both threads read head and see A, and both set their node's next to A. Thread 1 writes head = X, then Thread 2 writes head = Y, overwriting it. Node X is still there, but nothing points at it any more. The push was lost, because there was a gap between reading head and writing it, and the other thread slipped into the gap.

We already know the cure for that gap: replace the final write with a CAS that says "make head point at my node, but only if it still points at the node I saw". This is the classic lock-free stack, called a Treiber stack after R. Kent Treiber, who described it in 1986:

C++
void push(int v) {
    Node* n = new Node{v, head.load(std::memory_order_relaxed)};
    while (!head.compare_exchange_weak(n->next, n,
               std::memory_order_release, std::memory_order_relaxed)) {}
}

The std::memory_order arguments say how much ordering the CAS imposes on the memory operations around it. You can read them as noise for now. Section 7.4 explains why the release matters.

?Why is the loop body empty?

Because there's nothing left to do. On failure, compare_exchange_weak writes the head it found into its first argument, which here is n->next. That is exactly the "point my node at the current top" step, so the retry is already set up, and the loop just calls CAS again.

3.2Two pushes at once

Here are the same two threads pushing X and Y onto a stack that holds A. Watch what each CAS expects, and what a failed one hands back.

Two pushes race for the head
Thread 1The stackhead points at the topThread 2head→ Anode Anode Xnode Y
Step 1. The stack holds one node, A, and head points at it. Thread 1 wants to push X and Thread 2 wants to push Y.
1 / 6

One CAS succeeded, and the other saw the head had moved, failed and retried. Neither waited for the other.

?What if Thread 1 is descheduled halfway through?

Nothing happens to anyone else. Thread 1 has only set up its private node X, so it hasn't changed anything shared. Thread 2's CAS goes through as before, and the stack stays correct while Thread 1 is stopped. When Thread 1 wakes, its CAS fails, it retries, and it finishes.

That property has a name. An operation is lock-free when, however the scheduler treats the threads, some thread's operation always completes in a finite number of steps. That's a weaker promise than it sounds, and the next section looks at what it leaves out.

04What lock-free promises

4.1A promise about progress

Look again at what the definition says. It says some thread always completes, and nothing about every thread, or about how fast. In the stack scene Thread 1's CAS failed precisely because Thread 2's succeeded. If Thread 2 kept winning, Thread 1 could lose again and again, and the stack would still be lock-free, because someone wins every round. The more threads fight over the same data, the more often this happens. That fighting is called contention, and it's where these promises get tested.

People who study this rank the possible promises about progress into four standard levels, and lock-free sits third. Blocking is the mutex from section 1: it promises nothing, because one stopped holder stops everyone. Obstruction-free means a thread finishes if it gets to run with no interference. Two threads that keep interfering can undo each other's work forever, which is called livelock: everyone is busy, and nobody gets anywhere. Lock-free rules out livelock for the system as a whole, but a single thread can still starve, meaning it keeps losing and never finishes. Wait-free is the strongest: every thread finishes within a fixed number of its own steps, no matter what the others do. Getting there usually takes a helping scheme, where a thread that finds another's operation half done finishes it for them before doing its own. That's why wait-free code is so much more complex and usually slower. Here are the four side by side:

GuaranteeWhat it promisesWhat it costs
BlockingNothing. A suspended holder blocks everyone.Simplest, and often fastest under real contention.
Obstruction-freeA thread running alone finishes.Livelock is permitted.
Lock-freeAt least one thread progresses, always.Individual threads may starve indefinitely.
Wait-freeEvery thread finishes in bounded steps.Usually far slower; needs helping schemes.

The sentence to remember from the lock-free row is this one: the system progresses, but your thread might not.

4.2What you're buying

So what does lock-free get you, if not speed?

What does the trade cost? We have the counter, so we can measure.

05What it costs

5.1The same increment, three ways

Here's the plan. We'll make threads increment one shared counter in three ways: with a std::mutex, with the CAS loop from section 2, and with fetch_add. The last one you may remember from chapter 03. It adds a number to a word atomically in one step, with no retry loop, because adding is common enough that processors do it directly. Each thread does 200,000 increments, so adding threads adds work, and each figure is the time per increment. They are the median of three runs on a 10-core ARM chip that has the ARMv8.1 atomic instructions, which we'll meet in section 6.

Before you look at the numbers, a prediction:

Predict before you read on

At eight threads incrementing one shared counter, which is slowest: std::mutex, a CAS retry loop, or fetch_add?

threadsstd::mutexCAS retry loopfetch_add
115.6 ns2.6 ns3.0 ns
214.1 ns5.2 ns6.1 ns
416.0 ns15.0 ns5.1 ns
816.3 ns162.0 ns18.4 ns

With one thread, the CAS loop is about six times faster than the mutex, because there's nobody to collide with, so the loop runs once and never retries. With eight, it's about ten times slower. The crossover sits around four threads, and the mutex column barely moves the whole way down. Blocking turns out to be a very stable strategy. When threads collide, the mutex makes the loser wait, and a waiting thread isn't touching the counter at all. Section 6 shows why touching it is what costs so much.

Two cautions come with this table. The mutex's 15.6 ns at one thread is higher than the 4 ns chapter 13 measured for an uncontended lock, because the two chapters use different benchmark harnesses, so compare rows within this table and not across chapters. And the exact crossover depends on the hardware, so treat the direction as the lesson: the CAS loop gets worse as threads are added, and the mutex doesn't.

Here are the eight-thread numbers side by side:

16.3 ns
Increment under a std::mutex, 8 threads
includes taking and releasing the lock
18.4 ns
Increment with fetch_add, 8 threads
one hardware instruction, no retry
162 ns
Increment with a CAS retry loop, 8 threads
includes every discarded attempt
≈ 10×
CAS loop compared with the mutex
162 ÷ 16.3

fetch_add never retries, so it never falls off this cliff. At eight threads it costs about what the mutex does. The question is why the CAS loop falls so far.

06Why the retry loop collapses

6.1The two increments in assembly

The answer starts in the machine code. The compiler turns fetch_add and a CAS loop into very different instructions, and here are both, for an ARM processor:

clang++ -O2 -c, disassembled with objdump
aarch64 @ ARMv8.6-A ↗
Assembly
;; a.fetch_add(1, relaxed)  —  ONE instruction, no branch
add:
    mov   w8, #0x1
    ldadd x8, x0, [x0]        ; LSE atomic: hardware resolves it
    ret
 
;; compare_exchange_weak loop  —  a SOFTWARE retry branch
cas:
    ldr   x8, [x0]
    add   x10, x8, #0x1
    mov   x9, x8
.Lretry:
    cas   x9, x10, [x0]
    cmp   x9, x8
    b.eq  .Ldone
    mov   x8, x9
    add   x10, x9, #0x1
    b     .Lretry             ;        GO ROUND AGAIN

Start with the top half. ldadd belongs to LSE, the large-system atomics that ARM added in version 8.1 of its instruction set. It adds to the word in memory in one step. When several cores execute it at once, the interconnect, the hardware that links the cores and memory, lines them up and applies each add in turn. A core that arrives late waits its turn and re-executes nothing.

In the bottom half the retry is in your program. ldr reads the counter, add works out the new value, and cas tries the swap. Then cmp and b.eq check whether it worked, and if it didn't, mov, add and b .Lretry recompute from the fresh value and go back. (The listing stops where the loop exits.) Every trip round the loop is a whole attempt, and the next step shows what an attempt costs.

Two more terms first. A register is one of the few tiny storage slots inside a core, and the loop keeps its working values there. A cache line is the small fixed-size chunk of memory, typically 64 bytes, that cores copy between their private caches. Only one core can write to a cache line at a time, so if two cores keep updating the same counter, the line has to travel back and forth between them. Here is one losing attempt:

One CAS increment that loses a race
●
⇣
ldr
read the counter
+
add
compute new value
⇄
cas
try to swap
▦
Cache line
moves between cores
?
cmp · b.eq
did it work?
↺
b .Lretry
go round again
Step 1. ldr x8, [x0] reads the counter: say 5.
1 / 7

Notice the cost of the failed attempt. It did no useful work, and it still pulled the cache line to its core, which takes the line away from whoever had it. With eight threads, every core is doing this to every other core.

6.2Counting the wasted attempts

We can count it. Let's call the number of attempts per successful increment the number of tries. One thread never collides, so it makes exactly one try per increment. Here is how it grows in the benchmark from section 5:

1 threadno competition1.00 tries
2 threadsoccasional collision1.04 tries
4 threadscollisions common1.35 tries
8 threads8.58M attempts discarded6.36 tries
wasted work at 8 threads84% of all attempts

At eight threads the benchmark completes 1.6 million increments (eight threads times 200,000), and at 6.36 tries each that is about 10.2 million attempts. Of those, 8.58 million are thrown away, 84% of the total. Every discarded attempt still paid for a cache line transfer, and that's where the 162 ns goes. (Tries depend on timing. The two-thread run in section 2 saw about 0.2 failures per increment, against 0.04 here, but in both cases collisions grow with the number of threads.)

That is the cost you can measure. The next problem is that it's hard to see in a profile, and the ones after it are bugs.

07Where lock-free code goes wrong

Writing a lock-free stack takes an afternoon. The trouble is in four places, and the first one is about noticing slowness. The next two, ABA and freeing memory, are the famous ones, and the fourth is about memory ordering.

7.1Livelock that looks like healthy CPU

Under heavy contention, threads can spend most of their time losing CAS attempts. The algorithm keeps its lock-free guarantee, since someone wins every round, while total throughput collapses, as the 162 ns row showed. Strictly speaking that isn't livelock, because by section 4's definition livelock means nobody gets anywhere. In practice it looks and feels so similar, with every thread busy and little finished, that engineers usually call it livelock anyway, and so do the tables at the end of this chapter.

?Why doesn't profiling catch it?

Because it looks like healthy CPU utilisation. A blocked thread shows up as time spent off the CPU, and perf sched, a Linux tool that records when each thread goes on and off a core, will find it. A thread spinning in a CAS retry loop is 100% on the CPU. In a flame graph, a chart where wider boxes mean more CPU time, it shows up as one hot function that someone will conclude is "just expensive".

The way to catch it is to count instructions retired (the instructions the processor finished) against operations completed. If you finished a million pushes but retired six million loop bodies, that's your answer, and no flame graph would have shown it.

7.2ABA

The next bug is in correctness. To see it we need pop, which removes the top node. It works like push: read head, find the node below it, and CAS head from the top node to the one below:

C++
Node* pop() {
    Node* old = head.load();
    while (old && !head.compare_exchange_weak(old, old->next)) {}
    return old;
}

Look at what the CAS checks: that head still holds the same address as before. It doesn't check that the stack is unchanged. Compare-exchange compares the value in the word and knows nothing of its history. If a pointer goes from A to something else and back to A, the CAS succeeds, and the algorithm can be wrong. This is the ABA problem.

One more piece is needed to see it happen. When a thread frees a node with delete, the allocator, the part of the runtime behind new and delete (chapter 05), keeps that memory and hands it out again for the next new, often straight away. So a brand-new node can land at exactly the address of one that was just freed. Here is a stack of A, B and C, where that leads to ABA:

ABA: the head is A again, but not the same A
Thread 1The stacktop firstFreed memorythe allocator can hand it out againhead→ Anode Anext → Bnode Bnext → Cnode CplanCAS A→B
Step 1. The stack is A, B, C from the top. Thread 1 starts a pop: it reads head (A) and A's next (B), and plans to change head from A to B.
1 / 6

The CAS did exactly what it promised. The address matched. What it couldn't know is that everything behind that address had changed.

There are two fixes, roughly. One is a tagged pointer: keep a counter next to the address, add one to the counter on every change, and compare both together with a CAS on two words at once (x86-64 and ARM both have such a double-width CAS). Then the value differs even when the address repeats, and Thread 1's stale CAS fails. The other is never to reuse memory until you can prove that no thread still holds a reference to it. That second fix sounds easier than it is.

7.3When can you free a node?

Without a lock, nobody can say when it's safe to free a popped node, and the simple answer, free it immediately, creates a bug even without ABA. A thread reads head, gets pointer A, and is descheduled before it looks inside A. Another thread pops A and frees it. The first thread wakes and reads freed memory, which is a use-after-free.

?Why is this so much harder than with a lock?

With a lock, the holder knows nobody else is looking at the stack, so it can free what it removes. Lock-free code has no such moment. Any thread might be holding a pointer it read a moment ago, and there's no lock to ask. Deciding when a removed node can safely be freed is called memory reclamation. For lock-free structures it's a research area in itself, and three schemes have emerged to answer "when can I free this?".

In hazard pointers, each thread writes the pointer it's about to use into a slot that every other thread can read, and a node may be freed only if no slot holds it. The cost is that each read must publish its pointer and then use a fence, a special instruction that forces that write to become visible before anything after it, and the thread doing the freeing, the reclaimer, must scan every thread's slots. In epoch-based reclamation, a global counter marks the current epoch, a numbered period of time. Threads announce when they start and finish an operation, and a removed node is freed only after every thread has left the epoch it was removed in. Reads are nearly free, but one thread stalled in the middle of an operation holds the epoch open, and memory piles up until it moves.

RCU, short for read-copy-update, goes furthest. Readers take no lock and, on many builds, execute no atomic instructions at all. A writer replaces the data with a new copy, then waits for a grace period, a time long enough that every reader who could have seen the old copy has finished, before freeing it. It makes reads free and pushes all the cost onto writers. Linux uses RCU for its routing tables, its cache of recently looked-up file names (the directory entries from chapter 08) and much else, which makes it one of the most widely used techniques of this kind in production. It only pays off for data that's read far more often than it's written, because writers take turns and each one waits out a grace period.

Four stages of a linked list A, B, C. B is unlinked so A points to C, the writer waits for readers, then B is freed
RCU removing node B from the list A, B, C. (1) Readers may be anywhere in the list. (2) The writer unlinks B, so new readers go straight from A to C, but a reader already standing on B can still follow it to C: two versions of the list exist at once. (3) The writer waits out the grace period, after which no reader can still be on B. (4) Only then is B freed. Step 3 is how RCU answers the question of when a node can be freed.Image: Paul McKenney, CC BY-SA 3.0, via Wikimedia Commons

Here are the three side by side:

SchemeRead costFails when
Hazard pointersA store plus a fence per readReclaimer must scan every thread's slots
Epoch-basedNearly freeOne stalled thread blocks reclamation for everyone
RCULiterally zero on many buildsWriters serialise; bad for write-heavy data

crossbeam-epoch is a Rust library for epoch-based reclamation and is a large part of why lock-free structures are approachable in that ecosystem. The stalled-thread failure mode from the table is described in its documentation, and the epoch bookkeeping is clearer there than in most C++ implementations.

7.4Dropping release ordering because x86 didn't care

The last bug sits in the arguments we told you to ignore. The memory_order_release in the push above is load-bearing. It says that when another thread sees the new head, it must also see everything this thread wrote to the node before the CAS: the value and the next pointer. Downgrade it to relaxed and the code still compiles and passes light testing, then fails under load on a weakly ordered machine, one that may make writes visible out of order.

The reason is that a core doesn't write to memory the instant it executes a store. It parks the write in a small queue of its own, the store buffer, and drains it to the cache a little later. On some processors those writes can become visible to other cores in a different order from the one the program wrote them in. So another core can see the new head pointer while the node's value and next are still sitting in the writing core's store buffer, and read garbage from the node. (The reading side needs its half too: pop must load head with at least acquire, the partner of release. The pop above passes no ordering, which gives the strongest one, so it's covered. Chapter 03 explains the pair.)

Four stages: gptr points nowhere, a new node is allocated with unknown fields, its fields are set to 1, 2 and 3, then gptr is pointed at it
The same publish step in Linux's RCU. The writer allocates a node (2), fills in its fields (3), and only then points the shared pointer `gptr` at it (4). `rcu_assign_pointer` is a release store, so a reader that sees the new `gptr` also sees a=1, b=2 and c=3. Without the release, a reader on a weakly ordered machine could follow `gptr` and still find the `?` values from stage 2.Image: Paul McKenney, CC BY-SA 3.0, via Wikimedia Commons

x86-64, the processor family in most desktops and servers, hides that almost perfectly, because its hardware never lets one store become visible before an earlier one. ARM does allow that, so the same code can fail there. The operational advice is to run your tests on ARM.

Those are the traps. With the costs and the traps in view, we can choose.

08Choosing between the four

8.1Four ways to update shared state

Back to the original question of two threads and a counter. Here are the four options, from the simplest to the strongest guarantee, now that you know what each costs.

1std::mutex

Chapter 13 has the mechanism. For this decision what matters is that the uncontended path (no other thread competing) is about 4 ns with no call into the kernel, and the contended path degrades gracefully instead of catastrophically.

where it breaks
A holder descheduled mid-section blocks everyone. Unusable in a signal handler or interrupt context.
reach for it when
Almost everything. Notably flat under contention (see section 5) and trivially debuggable.
2A single hardware atomic

fetch_add, fetch_or, exchange. On ARMv8.1 and later these compile to one LSE instruction that the interconnect resolves. No retry loop exists, and section 5 shows them holding up where a CAS loop collapses.

where it breaks
Only works for operations the processor implements: add, or, exchange, compare-exchange. No good for anything compound.
reach for it when
Counters, flags, bitsets (words whose individual bits are flags). Should be your first thought, and often isn't.
3A compare-exchange loop

The push from section 3: read the current value, compute the new one, swap it in if nothing changed, retry if something did.

where it breaks
Throughput collapses under contention (162 ns at eight threads against a mutex's 16 ns), and freeing memory safely becomes a research problem.
reach for it when
Low contention, or where blocking is a correctness problem. The Treiber stack and most lock-free structures are this shape.
4Wait-free

Every thread finishes in a bounded number of steps regardless of what others do. Rare in production code, and you'll probably know when you need it, because someone handed you a latency budget with no asterisk.

where it breaks
Usually needs helping schemes where threads complete each other's operations. Complexity goes up sharply and throughput usually goes down.
reach for it when
Hard real-time, where a bounded worst case is the actual deliverable.

8.2Five questions

Most decisions are settled by the first two questions.

  1. Can you avoid sharing the thing at all? Give each thread its own copy, such as its own counter, and add the copies together now and then. That beats every algorithm here. Try this first, always.
  2. Is there a single hardware atomic for it? Counters, flags and bitsets want fetch_add or fetch_or, never a CAS loop.
  3. Is contention low, or the critical section long? Use a mutex. (The critical section, from chapter 13, is the code that runs while the lock is held. When it's long, a CAS loop would throw away a lot of work on every collision.)
  4. Is blocking a correctness problem? Signal handler, interrupt, hard real-time: then lock-free, and use an existing implementation.
  5. Otherwise measure both. The crossover depends on the hardware, and the table in section 5 is one data point.

Once something is running, how do you tell which of these trouble spots you're in?

09Finding trouble in a running program

9.1Commands for each question

Each question this chapter raised has a tool that answers it. These are Linux commands, and the comment on each points back to the section that raised the question.

Shell
# Are threads burning CPU on retries? (sections 6.2 and 7.1)
perf stat -e instructions,cycles ./yourprogram     # compare instructions retired with operations completed
 
# Is it blocked, or spinning? (section 7.1)
perf sched record -- ./yourprogram                 # off-CPU time from blocking shows up here
perf sched latency
 
# Did the compiler emit one atomic instruction or a retry loop? (section 6.1)
objdump -d ./yourprogram | grep -E 'ldadd|cas'     # on ARM
clang++ -O2 -march=armv8.1-a ...                   # target LSE atomics explicitly
 
# Is a node being used after it was freed? Is there a data race? (sections 7.3 and 7.4)
clang++ -fsanitize=address ...                     # catches use-after-free
clang++ -fsanitize=thread ...                      # catches data races

perf stat is the most useful. It prints the instructions the processor retired, and dividing by the number of operations your program completed gives instructions per operation. When that number climbs as you add threads while throughput falls, threads are doing work and discarding it.

9.2Rules that hold up

  1. Share less before you share better. Per-thread state with a periodic merge needs no atomics at all.
  2. Use fetch_add, fetch_or or exchange when one atomic does the job. A CAS loop for a counter is the slow row in the table.
  3. Keep a mutex unless blocking is a correctness problem. It's flat under contention and easy to debug.
  4. Use a library for lock-free structures. The hard parts are ABA, reclamation and ordering, and all three fail rarely and under load.
  5. Test on ARM, and under load. Dropped orderings and use-after-free don't show up in a quiet test on x86.
  6. Measure your own crossover. Count attempts per success at 1, 2, 4 and 8 threads, and compare with a mutex version.

9.3What you trade for what

You getYou payWhen the bill arrives
No thread can be stopped while holding something others needDiscarded work on every collisionAs throughput that falls when you add threads
A system-wide progress guaranteeIndividual threads may starveAs one thread that never finishes while others do
No lock to take or releaseYou must decide when memory can be freedAs rare, load-dependent crashes
A fast path with no kernel callARM can reorder what x86 doesn'tAs code that passes on x86 and fails on ARM
Wait-free: every thread boundedHelping schemes and much more codeAs complexity, and usually lower throughput

9.4Symptom, cause, fix

SymptomLikely causeFix
CAS loop at 100% CPU, throughput falling as threads are addedContention close to livelock: most attempts losefetch_add if one atomic will do; otherwise a mutex
Intermittent crashes after pops, under load onlyA node freed while another thread still held a pointerHazard pointers, epochs or RCU
A pop returns a node that was already removedABATagged pointer, or safe reclamation
Passes on x86, fails on ARMA relaxed where release was neededRestore release/acquire; test on ARM
One thread never finishes while others doLock-free isn't wait-freeAccept it, or pay for a wait-free design

10Summary

  1. A mutex's weak spot is the holder. If the scheduler stops the holder, every waiter stops with it.
  2. CAS lets a thread update without holding anything. Read, compute privately, swap in if unchanged, retry if not. Two threads adding to one counter ended at exactly 2,000,000 with no lock, after some 400,000 discarded attempts.
  3. The same idea works for a stack. Push is a CAS on head, and a failed CAS hands back the head it found, so the retry is already set up.
  4. Lock-free is a progress guarantee, not a speed claim. Some thread always progresses, and yours might not.
  5. Wait-free bounds every thread, and usually costs throughput and a great deal of complexity.
  6. CAS loops collapse under contention. 2.6 ns at one thread, 162 ns at eight, against a nearly flat mutex.
  7. 84% of attempts were thrown away at eight threads, and each still paid for a cache line transfer.
  8. A single hardware atomic never retries. fetch_add compiles to one ldadd on ARMv8.1 and later.
  9. ABA happens because CAS compares values, not history. A reused address passes the check.
  10. Freeing memory is the hard half. Hazard pointers, epochs and RCU each trade read cost against a failure mode.
  11. Try not sharing first. Per-thread state with a periodic merge beats every algorithm in this chapter.

11Build this

Write the Treiber stack, then break it on purpose.

  • Implement push and pop with compare_exchange_weak. Thirty lines.
  • Free popped nodes immediately, run eight threads, and watch it crash intermittently and unhelpfully. That's section 7.3, experienced instead of read.
  • Add a retry counter. Print attempts per success at 1, 2, 4 and 8 threads, and find your crossover against a std::mutex stack.
  • Then implement epoch reclamation and measure what the read path now costs.

The number to hunt is where your two curves cross. It depends on your hardware, and knowing which side of it you're on is the whole decision.

12Interview questions

beginnerDoes lock-free mean faster?›

No. It's a progress guarantee: at least one thread always progresses, so no thread can be blocked by another being descheduled. Under real contention a mutex is often faster.

In one benchmark, a CAS retry loop cost 2.6 ns with one thread against a mutex's 15.6 ns, and at eight threads those became 162 ns and 16.3 ns. The lock-free version was ten times slower.

intermediateWhat is ABA and why doesn't compare-exchange catch it?›

Compare-exchange compares a value, not a history. A pointer goes from A to B and back to A while your thread is descheduled; your CAS sees A, succeeds, and installs a next pointer you read before any of that happened.

The classic fix is a tagged pointer (address plus a monotonically increasing counter in a single wide compare-exchange) so the value differs even when the address repeats.

intermediateWhy can a lock-free algorithm starve a thread?›

Because the guarantee is system-wide, not per-thread. Lock-freedom requires that someone completes; nothing says it can't always be someone else. A thread that keeps losing its compare-exchange makes no progress while the algorithm remains formally lock-free.

Wait-freedom is the stronger property that bounds each thread's steps, and it usually costs enough that people don't use it.

deepWhy is fetch_add so much faster than a compare_exchange loop under contention?›

They compile to different things. On ARMv8.1 and later fetch_add becomes a single LSE instruction, ldadd, and the interconnect resolves contention without the core re-executing anything. A compare_exchange_weak loop compiles to a cas, a compare and a backward branch, so a losing thread re-reads, recomputes and retries in software.

At eight threads the benchmark showed 6.36 attempts per successful increment: 84% of the work discarded, each discarded attempt still paying a cache line transfer. That's the difference between 18 ns and 162 ns.

deepYou have a lock-free queue. Why can't you just delete popped nodes?›

Another thread may hold a pointer it read before you popped. There's no lock and no obvious moment the node becomes unreachable, so freeing creates a use-after-free that's timing-dependent and nearly impossible to reproduce.

You need a reclamation scheme. Hazard pointers publish in-use pointers per thread and cost a store and a fence on every read. Epoch reclamation is cheaper to read but a thread stalled inside an epoch blocks all reclamation and memory grows until it leaves. RCU makes reads free and defers everything to writers, which only works if you're overwhelmingly read-mostly.

13Go deeper

check yourself
Your CAS loop is at 100% CPU and throughput is falling. Deadlock or livelock?›

Livelock. Deadlock consumes no CPU. Threads burning cycles losing races look identical to useful work in a flame graph, so check attempts per success.

Why does dropping release to relaxed pass on x86 and fail on ARM?›

x86-64 is strongly ordered and won't reorder the stores publishing the node. ARM is weakly ordered, so another core can see the new head before the node's fields.

When is a tagged pointer not enough to fix ABA?›

When the counter wraps. With a 16-bit tag on a hot structure, 65,536 operations is maybe a millisecond, so a thread descheduled for that long can wake to find the tag back where it started.

What's the first thing to try before any of this?›

Not sharing. Per-thread state with a periodic merge beats every algorithm in the chapter, and it's the option people skip past fastest.

Operating Systems: Three Easy Pieces, chapter 32

The "Common Concurrency Problems" chapter shows compare-and-swap replacing a lock for an increment and for a linked-list insert, the same shape as the push in this chapter. Free online at ostep.org.

Herlihy & Shavit — The Art of Multiprocessor Programming

The textbook. Its chapters on concurrent objects, queues and the ABA problem, and stacks cover the progress-condition hierarchy and the stack in this chapter, and most other writing paraphrases them.

Maged Michael — Hazard Pointers (2004)

The original paper, still the clearest statement of why reclamation is the hard half.

Paul McKenney — What is RCU, Fundamentally?

Three-part LWN series. Explains grace periods better than the kernel docs.

ARM LSE atomics

The ARMv8.1-A extension turning fetch_add into one instruction. Check whether your build targets it: -march=armv8.1-a or later.

The Memory Model & Atomics

Compare-and-swap, and why release in a push is load-bearing on ARM. Chapter 03.

Locking Primitives, End to End

The mutex this chapter measures against, down to the futex word. Chapter 13.

Contention, Queueing & Tail Latency

What contention does to the tail, whichever primitive you pick. Chapter 16.

Concurrent Data Structures

Queues, maps and stacks built on the patterns here. Chapter 17.