The Need for Speed: Beyond Traditional Locking
In high-performance computing, especially in concurrent systems, the traditional approach of using mutexes and locks to protect shared data structures becomes a significant bottleneck. Locks serialize access, meaning only one thread can operate on the data at a time. This contention can severely limit scalability as the number of threads increases. Lock-free data structures, on the other hand, aim to allow multiple threads to access and modify shared data concurrently without the need for locks. This article details the process of building a fast, lock-free queue from scratch in modern C++, focusing on the challenges and techniques involved.
A queue is a fundamental First-In, First-Out (FIFO) data structure. In a multithreaded environment, multiple producer threads might add elements to the queue, while multiple consumer threads remove them. Without proper synchronization, this can lead to race conditions, data corruption, or lost updates. While locks are a straightforward solution, their overhead—context switching, priority inversion, and deadlocks—can be prohibitive for latency-sensitive applications. Lock-free programming offers an alternative, leveraging atomic operations to ensure progress for at least one thread, even under heavy contention.
Designing the Lock-Free Queue: Core Principles
The foundation of a lock-free queue lies in atomic operations provided by C++'s `
A common approach for a lock-free queue is the Michael & Scott algorithm. This algorithm uses a linked list where each node contains the data and a pointer to the next node. The queue itself is represented by two pointers: `head` and `tail`. The `head` points to the first node (or a dummy node before the first element), and the `tail` points to the last node. Producers enqueue by appending to the tail, and consumers dequeue from the head.

Implementing Enqueue
The enqueue operation involves adding a new node to the tail of the list. This is where CAS becomes essential. A producer thread first creates a new node. Then, it enters a loop that continues until the element is successfully enqueued.
Inside the loop:
- Atomically read the current `tail` pointer.
- Atomically read the `next` pointer of the node pointed to by `tail`.
- If the `tail` pointer is still the one we read (i.e., it hasn't been updated by another thread), check the `next` pointer of this `tail` node.
- If `next` is null, it means this `tail` is indeed the last node. We attempt to atomically set its `next` pointer to our new node using CAS. If this CAS succeeds, we have successfully linked our node. The next step is to try and advance the `tail` pointer to our new node using another CAS. This second CAS is important for helping other threads that might be trying to enqueue or dequeue.
- If `next` is not null, it means another thread has already enqueued a node but hasn't yet advanced the `tail` pointer. In this case, we help by trying to advance the `tail` pointer to the node pointed to by `next`.
- If the `tail` pointer was updated by another thread between our initial read and our CAS attempt, we simply retry the entire loop.
The use of `std::atomic
Implementing Dequeue
The dequeue operation removes an element from the head of the list. Similar to enqueue, it involves atomic operations and a retry loop.
Inside the loop:
- Atomically read the current `head` and `tail` pointers.
- Atomically read the `next` pointer of the node pointed to by `head`.
- If the `head` pointer is still the one we read, we check if the queue is empty. The queue is empty if `head == tail`. If it is empty, we return a special value (e.g., `nullptr` or an optional) to indicate this.
- If the queue is not empty, we examine the `next` node. If `next` is null, it implies the `tail` is lagging behind, and we should try to advance the `tail` pointer.
- If `next` is not null, this is the node to be dequeued. We attempt to atomically set the `head` pointer to this `next` node using CAS.
- If the CAS succeeds, we have successfully removed the node from the head. We then retrieve the data from the node we just detached and return it. The detached node can then be safely deallocated (or returned to a memory pool).
- If the `head` pointer was updated by another thread, or if `head == tail` and `next` is null (indicating a race condition where the tail is ahead of head but no element is ready), we retry the loop.
For dequeue, `memory_order_acquire` is typically used when reading the `head` and `next` pointers to ensure that any writes made by producers prior to enqueuing are visible. If the CAS for `head` succeeds, `memory_order_release` might be used to make the dequeue operation visible to other consumers.
Memory Management and ABA Problem
One of the trickiest aspects of lock-free programming is memory management. When a node is dequeued, its memory must be reclaimed. However, if memory is immediately deallocated, another thread might still be holding a pointer to it, leading to a use-after-free error. This is related to the ABA problem: a value changes from A to B and then back to A. A thread that read A earlier might incorrectly assume the state hasn't changed when it sees A again.
Solutions to the ABA problem include:
- Epoch-Based Reclamation (EBR): Nodes are deallocated only after all threads have passed a certain
