1 Apr 2026

Lock Free Single Producer Single Consumer Ring Buffer

We'll take a look at how to build a Fixed size SPSC Ring Buffer in C++.

Important concepts:

  1. Memory Barriers
  2. Acquire / Release semantics
  3. Why mutexes are slower
  4. SPSC Queue - Implementation
  5. Calculate latency upto nanosecond level accuracy

Memory Barriers and Acquire / Release semantics:

Note: release and acquire are generally used together to pack and unpack the data from an atomic variable.


Why mutexes are slower?


SPSC Queue - Implementation

  1. SPSC-Queue class and member variables:
template <typename T> class SPSCQueue {
private:
  alignas(64) std::atomic<uint64_t> write_pos{0};
  alignas(64) std::atomic<uint64_t> read_pos{0};
  
  // RING_BUFFER_SIZE should be a multiple of 2, eg 2^4, 2^16 etc. for masking
  alignas(64) std::unique_ptr<std::array<T, RING_BUFFER_SIZE>> ring_buffer;


public:
  SPSCQueue()
      : ring_buffer(std::make_unique<std::array<T, RING_BUFFER_SIZE>>()) {}

  bool push(const T &item);
  bool pop(T &item);

  bool isFull(uint64_t writePos, uint64_t readPos) {
    return (writePos - readPos) == RING_BUFFER_SIZE;
  }
  bool isEmpty(uint64_t writePos, uint64_t readPos){
    return writePos == readPos;
  }
}

Push function:

  // push is only called by producer thread
  bool push(const T &item) {

    // relaxed because only this push function is going to update write_pos
    uint64_t writePos = write_pos.load(std::memory_order_relaxed);

    uint64_t readPos = read_pos.load(std::memory_order_acquire);

    if (isFull(writePos, readPos)) {
      // we are in this code means that the queue appears to be full
      return false;
    }

    // masking similar to writePos % size of array;
    auto index = writePos & MASK;

    // dereferrencing the ring_buffer unique_ptr
    auto &buffer = *ring_buffer;
    buffer[index] = std::move(item);

    // releasing to notify our consumer that data is ready to be consumed.
    write_pos.store(writePos + 1, std::memory_order_release);

    return true;
  }

Pop function:

bool pop(T &item) {
    // readPos is relaxed because only pop function modifies the read_pos atomic
    uint64_t readPos = read_pos.load(std::memory_order_relaxed);

    if (isEmpty(write_pos, readPos)) {
      // we are here means the the queue appears to be empty
      return false;
    }

    auto index = readPos & MASK;

    auto &buffer = *ring_buffer;
    // the item is by shared reference and will get the popped value in it.
    item = std::move(buffer[index]);

    // releasing to notify our producer that data has been consumed.
    read_pos.store(readPos + 1, std::memory_order_release);

    return true;
  }

Possible improvements:

Made with 2026