A lock-free, concurrent Multi-Producer Multi-Consumer queue in C++ based on the Michael & Scott 1996 algorithm. Producers and consumers race via atomic CAS loops; no thread ever blocks on a mutex or context-switches into the kernel.
Status: functional correctness verified under stress (4 producers × 4 consumers × 100,000 items each, 400 K total — every item dequeued, sum matches exactly). ThreadSanitizer clean.
- Lock-free progress: no mutex, no condition variable, no kernel futex. Every operation completes in a bounded number of steps regardless of what other threads are doing (modulo the CAS retry on contention).
- The Michael-Scott two-CAS protocol: producers first CAS their new
node into
tail->next, then CAS thetailpointer itself. This makes the queue tolerate producer preemption between the two steps. - Cooperative tail-advancement: if any thread observes a non-null
tail->next, it knows a previous producer was preempted mid-enqueue. Rather than wait, it helps by advancing the tail on the preempted producer's behalf, then retries its own work. This is what makes the queue lock-free instead of merely obstruction-free. - Dummy sentinel node: the queue always contains at least one node
(the dummy at
head). This eliminates a class of "is it empty?" races on the head/tail pointers. - Cache-line padding:
headandtailarealignas(64)to keep them on separate cache lines. Otherwise every producer write would invalidate the consumer's cachedheadand vice versa — classic false sharing.
The naive Michael-Scott algorithm has an ABA hazard: a thread reads
tail = X, sleeps, and later sees tail = X again — but it's a different
node that happens to live at the same address because the original X
was freed and reused.
Production lock-free queues use one of:
- Tagged pointers — pack a generation counter into the high bits of each pointer so the same address with a new tag is recognized as a different value.
- Hazard pointers — each thread publishes which nodes it currently references, so the reclaimer waits until no hazard pointer references a node before freeing it.
- Epoch-based reclamation — defer frees until all threads have passed through a quiescent period.
This implementation takes the simplest path: it never frees dequeued nodes during operation (only at destruction). This is honest about what the queue is — a learning project that demonstrates the algorithm correctly — and avoids the substantial complexity of safe memory reclamation. A production version would pair this with a hazard-pointer scheme or an epoch-based pool.
g++ -O2 -std=c++17 -pthread -Wall -Wextra test_mpmc.cpp -o test_mpmc
./test_mpmcThreadSanitizer run:
g++ -O1 -g -std=c++17 -pthread -fsanitize=thread test_mpmc.cpp -o test_mpmc_tsan
./test_mpmc_tsan[ RUN ] single_threaded OK
[ RUN ] interleaved_enqueue_dequeue OK (FIFO ordering preserved)
[ RUN ] mpmc_stress OK (4 prod × 4 cons × 100 K items)
dequeued: 400000 (expected 400000)
sum: 79999800000 (expected 79999800000)
The stress test is the meaningful one: producers enqueue disjoint integer ranges; consumers accumulate everything they dequeue. After all threads join, the dequeued count and the sum must both match exactly — any lost, duplicated, or torn item would show up. The queue passes consistently across many runs.
ThreadSanitizer: clean. No data races, no missed synchronization.
Single-producer / single-consumer, 1 M items, x86-64:
| Run | ns / item |
|---|---|
-O2, no sanitizer |
105.5 |
-O1, TSan |
~9600 (TSan overhead, not representative) |
105 ns/item is fair for a Michael-Scott queue with per-item new and
move construction. A production lock-free queue specialized for SPSC
(single-producer single-consumer) — e.g., a Lamport ring buffer — would
clock in single-digit ns/item by eliminating the linked-list allocation
and the CAS entirely (a single producer just bumps a write index;
a single consumer just bumps a read index).
- No safe memory reclamation. Dequeued nodes leak until destruction. Discussed above.
newper enqueue. A typed node pool would amortize this to near-zero on the hot path. Pairs naturally with the companion arena allocator.- MPMC overhead even when uncontended. If you know your access pattern is SPSC, a Lamport ring buffer is dramatically faster (~5 ns vs ~100 ns).
- No backpressure. The queue is unbounded; producers always succeed. A bounded variant would CAS against a size counter.