nouku logonouku
2026-03-21

Lock-Free Isn’t Free: Designing a Correct MPMC Queue in Modern C++

Lock-free data structures sacrifice simplicity for strict progress guarantees. In this deep dive, we design a bounded Multi-Producer Multi-Consumer (MPMC) queue in C++20 using std::atomic.

Lock-Free Isn’t Free: Designing a Correct MPMC Queue in Modern C++

TL;DR: Lock-free data structures sacrifice simplicity for strict progress guarantees. In this deep dive, we design a bounded Multi-Producer Multi-Consumer (MPMC) queue in C++20 using std::atomic. We focus on memory ordering, avoiding false sharing via cache-line alignment, and the hardware mechanical sympathy required for zero-cost abstractions to truly shine.


🚀 The Myth of Lock-Free Performance

A common misconception is that "lock-free" intrinsically means "faster than mutexes." In reality, lock-free programming is about system-wide progress guarantees. While a thread holding a mutex might be descheduled by the OS, bringing the entire system to a halt, a lock-free algorithm guarantees that at least one thread makes progress.

However, under heavy contention, spinning on compare_exchange_weak loops can hammer the CPU cache, causing severe bus traffic. This is where understanding hardware architecture becomes not just useful, but strictly necessary.


🏛️ Architecture: The Ring Buffer Approach

To build a high-performance MPMC queue, dynamically allocating nodes on the heap is a non-starter due to lock-contention inside the memory allocator itself. Instead, we use a pre-allocated array acting as a ring buffer (or circular buffer).

Ring Buffer Architecture Diagram

The magic lies in the sequence number. Every slot in the array keeps track of "which iteration" of the ring buffer it currently holds. Producers wait for the sequence to exactly match the slot's index, and consumers wait for the sequence to be index + 1.


🧠 Memory Layout & False Sharing

Cache lines on modern x86/ARM CPUs are typically 64 bytes wide. If two threads modify different variables that reside on the same cache line, the CPU invalidate the entire cache line for both cores—a devastating performance hit known as false sharing.

To achieve zero-error, high-performance C++, we must explicitly align our atomic variables and array elements to std::hardware_destructive_interference_size.

Memory Layout Diagram

|===============================================================|
| Cache Line 1 (Usually 64 Bytes)                               |
|---------------------------------------------------------------|
| enqueue_pos_ (8 bytes)                                        |
| Padding (56 bytes to prevent false sharing with dequeue_pos_) |
|===============================================================|
| Cache Line 2                                                  |
|---------------------------------------------------------------|
| dequeue_pos_ (8 bytes)                                        |
| Padding (56 bytes)                                            |
|===============================================================|
| ... Array Nodes ...                                           |
|---------------------------------------------------------------|
| Node 0: sequence (8B) | data (8B) | Padding (48B)             |
| Node 1: sequence (8B) | data (8B) | Padding (48B)             |
|===============================================================|

Notice how we heavily pad the Node structure. While this wastes memory, it ensures that one thread reading/writing Node 0 doesn't invalidate the L1 cache of a thread processing Node 1.

[!WARNING] While padding the array nodes prevents false sharing, it completely destroys cache density. For small types (like int), fitting 8 nodes per cache line might actually yield higher throughput due to hardware prefetching than isolating every single node. Profiling on your target architecture is mandatory!


💻 C++20 Implementation

Here is the correct, highly idiomatic C++20 implementation utilizing atomic acquires and releases.

#include <atomic>
#include <new>
#include <cstdint>
#include <cassert>
#include <utility> // For std::move

// Portability macro for Apple Clang which frequently lacks hardware_destructive_interference_size
#ifdef __cpp_lib_hardware_interference_size
    constexpr size_t CACHE_LINE_SIZE = std::hardware_destructive_interference_size;
#else
    constexpr size_t CACHE_LINE_SIZE = 64;
#endif

template <typename T>
class MPMCQueue {
private:
    // Align to cache-line boundaries to eliminate false sharing between nodes
    struct alignas(CACHE_LINE_SIZE) Node {
        std::atomic<size_t> sequence;
        T data;
    };

    // Separate head/tail heavily to prevent false sharing between Producers and Consumers
    alignas(CACHE_LINE_SIZE) std::atomic<size_t> enqueue_pos_;
    alignas(CACHE_LINE_SIZE) std::atomic<size_t> dequeue_pos_;
    
    Node* buffer_;
    size_t buffer_mask_;

public:
    explicit MPMCQueue(size_t capacity) {
        // Capacity MUST be a power of 2 for fast modulo bitwise operations
        assert((capacity & (capacity - 1)) == 0 && capacity >= 2);
        buffer_mask_ = capacity - 1;
        buffer_ = new Node[capacity];
        
        for (size_t i = 0; i < capacity; ++i) {
            // Initial sequence marks slot readiness
            buffer_[i].sequence.store(i, std::memory_order_relaxed);
        }
        
        enqueue_pos_.store(0, std::memory_order_relaxed);
        dequeue_pos_.store(0, std::memory_order_relaxed);
    }
    
    ~MPMCQueue() {
        delete[] buffer_;
    }

    bool enqueue(const T& data) {
        Node* cell;
        size_t pos = enqueue_pos_.load(std::memory_order_relaxed);
        for (;;) {
            cell = &buffer_[pos & buffer_mask_];
            // Acquire semantic ensures we see correct state of the Node
            size_t seq = cell->sequence.load(std::memory_order_acquire);
            intptr_t dif = static_cast<intptr_t>(seq) - static_cast<intptr_t>(pos);
            
            if (dif == 0) { // Slot is ready for us
                if (enqueue_pos_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
                    break;
                }
            } else if (dif < 0) { // Queue is full
                return false; 
            } else { // Another thread pushed the position forward
                pos = enqueue_pos_.load(std::memory_order_relaxed);
            }
        }
        
        cell->data = data; // Copy constructor
        // Release semantic ensures data payload is visible before sequence is bumped
        cell->sequence.store(pos + 1, std::memory_order_release);
        return true;
    }

    // Overload for Rvalue references (move semantics)
    bool enqueue(T&& data) {
        Node* cell;
        size_t pos = enqueue_pos_.load(std::memory_order_relaxed);
        for (;;) {
            cell = &buffer_[pos & buffer_mask_];
            size_t seq = cell->sequence.load(std::memory_order_acquire);
            intptr_t dif = static_cast<intptr_t>(seq) - static_cast<intptr_t>(pos);
            
            if (dif == 0) {
                if (enqueue_pos_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
                    break;
                }
            } else if (dif < 0) {
                return false; 
            } else {
                pos = enqueue_pos_.load(std::memory_order_relaxed);
            }
        }
        
        cell->data = std::move(data);
        cell->sequence.store(pos + 1, std::memory_order_release);
        return true;
    }

    bool dequeue(T& data) {
        Node* cell;
        size_t pos = dequeue_pos_.load(std::memory_order_relaxed);
        for (;;) {
            cell = &buffer_[pos & buffer_mask_];
            size_t seq = cell->sequence.load(std::memory_order_acquire);
            intptr_t dif = static_cast<intptr_t>(seq) - static_cast<intptr_t>(pos + 1);
            
            if (dif == 0) { // Slot contains unread data
                if (dequeue_pos_.compare_exchange_weak(pos, pos + 1, std::memory_order_relaxed)) {
                    break;
                }
            } else if (dif < 0) { // Queue is empty
                return false; 
            } else {
                pos = dequeue_pos_.load(std::memory_order_relaxed);
            }
        }
        
        data = std::move(cell->data); // Expert Fix: Use std::move to avoid massive copy overhead!
        
        // Expert Fix: Properly destruct lingering objects (prevents memory retention leaks for std::shared_ptr)
        if constexpr (!std::is_trivially_destructible_v<T>) {
            cell->data.~T(); 
        }

        // Release semantic signals space is ready for next iteration
        cell->sequence.store(pos + buffer_mask_ + 1, std::memory_order_release);
        return true;
    }
};

Key Takeaways

  1. Memory Ordering: Notice the lack of std::memory_order_seq_cst. We use the acquire/release idiom. Updating the sequence variable with release guarantees that the write to cell->data is fully visible to any consumer thread that does an acquire load on that exact sequence variable.
  2. Bitwise Masking: pos & buffer_mask_ is vastly faster than the modulo % operator, which involves expensive division instructions. Thus, the capacity must be a power of two.

🏎️ Execution Performance Showcase

To demonstrate the real-world impact of true zero-cost abstractions, we built and ran a benchmark on a standard development machine pushing millions of operations through the lock-free MPMC queue. The results speak for themselves:

C++ Execution Performance Benchmark


🏁 Conclusion

Writing a truly zero-error lock-free MPMC queue is an exercise in extreme mechanical sympathy. Between cache hierarchies, false sharing, and compile-level memory reordering, C++ developers must be hyper-vigilant. Wherever possible, leverage proven libraries (like moodycamel's ConcurrentQueue or boost::lockfree). But when absolute raw control is demanded, deeply understanding these atomic properties is your greatest weapon.