circular buffer
lock-free programming
concurrent data structures
buffer design
non-blocking algorithms

Circular lock-free buffer

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

A circular lock-free buffer, usually called a lock-free ring buffer, is a fixed-size queue designed for fast communication between threads. The simplest and most practical version is a single-producer, single-consumer buffer, where one thread writes items and one thread reads them using atomic head and tail indices instead of a mutex.

Start With The Ring Buffer Idea

A circular buffer stores items in a fixed array. When the writer reaches the end, it wraps around to index zero again.

The queue state is usually tracked with two counters:

  • 'head, where the consumer reads next'
  • 'tail, where the producer writes next'

If head == tail, the buffer is empty. If advancing tail would make it equal to head, the buffer is full.

Why Lock-Free Is Harder Than It Sounds

Lock-free does not mean "no coordination." It means the coordination happens through atomic operations and memory ordering rather than a traditional lock.

A correct design still has to answer:

  • who owns each index update
  • when written data becomes visible to the other thread
  • how full and empty states are detected safely

These details are manageable in the single-producer, single-consumer case. They become much more complex for multiple producers or multiple consumers.

A Practical SPSC Example In C++

cpp
1#include <atomic>
2#include <array>
3#include <cstddef>
4#include <optional>
5
6template <typename T, std::size_t Capacity>
7class RingBuffer {
8public:
9    bool push(const T& value) {
10        const auto tail = tail_.load(std::memory_order_relaxed);
11        const auto next = increment(tail);
12
13        if (next == head_.load(std::memory_order_acquire)) {
14            return false; // full
15        }
16
17        data_[tail] = value;
18        tail_.store(next, std::memory_order_release);
19        return true;
20    }
21
22    std::optional<T> pop() {
23        const auto head = head_.load(std::memory_order_relaxed);
24
25        if (head == tail_.load(std::memory_order_acquire)) {
26            return std::nullopt; // empty
27        }
28
29        T value = data_[head];
30        head_.store(increment(head), std::memory_order_release);
31        return value;
32    }
33
34private:
35    static constexpr std::size_t increment(std::size_t index) {
36        return (index + 1) % Capacity;
37    }
38
39    std::array<T, Capacity> data_{};
40    std::atomic<std::size_t> head_{0};
41    std::atomic<std::size_t> tail_{0};
42};

This example is intentionally limited to one producer and one consumer. That limitation is what keeps the ownership model simple enough to reason about.

Memory Ordering Matters

The producer must publish the written element before publishing the updated tail. The consumer must observe the updated tail before reading the element. That is why the sample uses acquire and release semantics instead of only relaxed operations.

If you get the ordering wrong, the code may appear to work on one machine and fail on another under load.

Full Versus Empty Detection

A classic design leaves one slot unused so that full and empty states are easy to distinguish. That means a buffer declared with capacity N can store at most N - 1 elements.

There are other ways to track fullness, such as maintaining a separate count, but the one-slot-empty rule keeps the state logic simpler and common in real implementations.

Why MPMC Is A Different Problem

A multi-producer or multi-consumer ring buffer usually needs per-slot sequence numbers, compare-and-swap loops, or another stronger coordination design. Simply making both head and tail atomic is not enough for that case.

That is why most discussions should say explicitly whether the buffer is:

  • SPSC
  • MPSC
  • SPMC
  • MPMC

Those are different algorithms, not just minor variations.

Where Ring Buffers Shine

Lock-free circular buffers are useful in logging pipelines, audio processing, telemetry, networking, and any system where fixed-capacity, low-latency message passing is more important than unbounded growth.

The fixed size is a feature, not a limitation, when you care about predictable memory use.

Common Pitfalls

The most common mistake is calling a data structure "lock-free" when it is only safe for one producer and one consumer but is then used by multiple writers. Another is ignoring memory ordering and relying on relaxed atomics everywhere. Developers also often forget that many ring buffers intentionally waste one slot to distinguish full from empty. Finally, a fixed-capacity queue needs an overflow policy; if push fails, the caller must know whether to drop, retry, or block elsewhere.

Summary

  • A lock-free circular buffer is usually simplest and safest in the SPSC case.
  • Atomic indices replace a mutex, but memory ordering still matters.
  • Full and empty detection are core design choices, not afterthoughts.
  • MPMC ring buffers are a substantially harder algorithmic problem.
  • Fixed capacity gives predictable performance, but the caller still needs a clear overflow strategy.

Course illustration
Course illustration

All Rights Reserved.