Articles
Articles \ coding \ ring_buffer

Building a lock-free ring buffer

One producer, one consumer, in C#, Go and Python

A single-producer, single-consumer ring buffer built one step at a time, from a lock to 297 million items a second in C#, with every step measured in C#, Go and Python by BenchmarkDotNet, go test and pyperf.

Project Continuum Engine Authors @Tristan & @Claude Topic Coding
Created 2026-09-27 Updated 2026-09-27 Version 1.0

The structure

The ring buffer

A ring buffer with one producer and one consumer can drop its lock, because each of its two counters has exactly one writer. This article builds one in C#, Go and Python, a step at a time, from a plain array to 297 million items a second, and measures every step with the language's own benchmark tool.

The buffer is a fixed array and two counters. The producer writes at tail and moves it on; the consumer reads at head and moves it on. Neither counter ever wraps: they only grow, and the slot is the counter modulo the capacity. With a capacity that is a power of two, the modulo is a mask, count & (capacity - 1), and tail - head is how many items are waiting. Full is tail - head == capacity, empty is head == tail, and no slot is spent telling the two apart. A 64-bit counter moving a billion items a second takes 292 years to overflow.

Every measurement below moves 64-bit integers between two threads pinned to their own cores, through a buffer of 1,024 slots, and the consumer checks that each value is the next one expected.

A ring of 32 slots, with a producer writing at tail and a consumer reading at head. Below it, the two counters as the cache lines that hold them, on the producer's core and the consumer's. With both counters in one line, the line crosses to whichever core is working. Padded, each line stays home, and reading the other side's counter pulls a copy across. With cached indices, a copy crosses only when the ring looks full or empty. Slow it down, or step through it one push or pop at a time, to watch each event.

One thread

Single-threaded ring buffer

The whole structure, with nothing shared yet. Each language's version is the same four fields and two methods.

public sealed class RingBuffer(int capacity)
{
    private readonly long[] _slots = new long[capacity];   // capacity is a power of two
    private readonly long _mask = capacity - 1;
    private long _head;   // the next slot to read
    private long _tail;   // the next slot to write

    public bool TryPush(long item)
    {
        if (_tail - _head == _slots.Length) return false;   // full
        _slots[(int)(_tail & _mask)] = item;
        _tail++;
        return true;
    }

    public bool TryPop(out long item)
    {
        if (_head == _tail) { item = 0; return false; }     // empty
        item = _slots[(int)(_head & _mask)];
        _head++;
        return true;
    }
}

On one thread, a push then a pop per item, it is the cost of the buffer with nobody to race: under a nanosecond in the compiled languages.

One threadPer itemItems a second
C#0.86 ns1,167 million
Go0.87 ns1,148 million
Python, with the GIL128 ns7.8 million
Python, free-threaded153 ns6.5 million

Share it between two threads and nothing orders the producer's two writes, the slot and then tail, as the consumer sees them. The compiler may keep either counter in a register, or the processor may make the second write visible before the first. The rest of the article is the price of putting that order back.

One lock

Thread-safe ring buffer

A lock around both methods makes it correct for any number of threads. Both sides take the same lock, so the producer and the consumer take turns.

private readonly Lock _gate = new();

public bool TryPush(long item)
{
    lock (_gate)
    {
        if (_tail - _head == _slots.Length) return false;
        _slots[(int)(_tail & _mask)] = item;
        _tail++;
        return true;
    }
}
// TryPop takes the same lock
LockedPer itemItems a second
C#51.7 ns19.3 million
Go26.2 ns38.2 million
Python, with the GIL38.7 µs26,000
Python, free-threaded789 ns1.3 million

The lock costs C# 60 times the single-threaded time and Go 30 times. Python with the GIL is the outlier at 38.7 µs an item. The two threads already can't run Python code at the same time, and a thread spinning on a full or empty buffer keeps the GIL until the interpreter takes it away, every 5 ms (sys.getswitchinterval()). So each side spends most of its turn retrying against a buffer that only the other side, waiting for the GIL, could change. The free-threaded build runs the same code 49 times faster.

Setting up free-threaded Python

Free-threaded CPython is a separate build, installed beside the standard one as python3.14t. The installer options, by platform:

  • Windows: the Python install manager, py install 3.14t, then py -3.14t. This article's runs used it.
  • macOS: the python.org installer, with "Free-threaded Python" ticked under Customize, or brew install python-freethreading.
  • Fedora: sudo dnf install python3.14-freethreading.
  • Ubuntu: the deadsnakes PPA, sudo apt-get install python3.14-nogil.
  • Anywhere with uv: uv venv --python 3.14t.

To check which build is running, python -VV says "free-threading build", and in code sys._is_gil_enabled() returns False. PYTHON_GIL=1 turns the GIL back on for a comparison. The benchmarks give each build its own virtual environment:

py -3.14t -m venv .venv-free-threaded
.\.venv-free-threaded\Scripts\python.exe -m pip install pyperf==2.10.0
.\.venv-free-threaded\Scripts\python.exe -c "import sys; print(sys._is_gil_enabled())"   # False

Python's own guide is Python support for free threading, and the community's install guide for every platform is py-free-threading.github.io.

No lock

Lock-free ring buffer

With one producer and one consumer, the producer is the only writer of tail and the consumer the only writer of head. Each side reads its own counter plainly, reads the other's atomically, and publishes its own atomically once the slot is done. The publish is what orders the slot write before the counter move. Here it is a full fence: Interlocked.Exchange in C#, and in Go atomic.Int64.Store, both an xchg on x64.

public bool TryPush(long item)
{
    long tail = _tail;                                        // only we write it
    if (tail - Volatile.Read(ref _head) == _slots.Length) return false;
    _slots[(int)(tail & _mask)] = item;
    Interlocked.Exchange(ref _tail, tail + 1);                // publish: a full fence
    return true;
}

public bool TryPop(out long item)
{
    long head = _head;                                        // only we write it
    if (head == Volatile.Read(ref _tail)) { item = 0; return false; }
    item = _slots[(int)(head & _mask)];
    Interlocked.Exchange(ref _head, head + 1);                // free the slot: a full fence
    return true;
}
Atomic, full fencesPer itemItems a second
C#26.1 ns38.4 million
Go20.7 ns48.2 million

Dropping the lock halves C#'s time and takes a fifth off Go's. Python stops here: its standard library has no atomic types and no memory-ordering API, so the steps below have no Python version. Its answers for two threads are its queues, measured beside the others at the end.

Memory order

Acquire and release

A full fence orders everything before it against everything after it. The buffer needs less: the slot write has to be visible before the counter that publishes it, which is a release store, and the other side's read of that counter has to come before its read of the slot, which is an acquire load. In .NET those are Volatile.Write and Volatile.Read. On x64, where every ordinary store already has release semantics, Volatile.Write is a plain mov. It changes one line in each method.

_slots[(int)(tail & _mask)] = item;
Volatile.Write(ref _tail, tail + 1);   // release: a plain mov on x64
// and in TryPop: Volatile.Write(ref _head, head + 1);
Acquire and releasePer itemItems a second
C#4.07 ns246 million

6.4 times faster than the full fence, from one call on each side. Go has no step 4. Its memory model promises that all atomic operations "behave as though executed in some sequentially consistent order", and sync/atomic offers nothing weaker, so every store it publishes is the xchg from step 3. The compiler's own listing shows it for every buffer in this article:

; go build -gcflags=-S: every (*atomic.Int64).Store in the Go buffers
XCHGQ   CX, 32(AX)     ; Atomic: head
XCHGQ   CX, 40(AX)     ; Atomic: tail
XCHGQ   CX, 128(AX)    ; Padded and Cached: head
XCHGQ   CX, 256(AX)    ; Padded and Cached: tail

Cache lines

Padding the counters

.NET put step 4's counters 8 bytes apart, and Go puts step 3's at offsets 32 and 40: one 64-byte cache line each. A core has to own a line to write it, so every time the producer publishes tail, the line leaves the consumer's core, and every time the consumer publishes head, it comes back. Padding gives each counter a line of its own. The counters here are 128 bytes apart, two lines, the more cautious of the two common choices: .NET's own concurrent collections pad to 64 bytes on x64 and to 128 on Arm64.

[StructLayout(LayoutKind.Explicit, Size = 3 * 128)]
public sealed class PaddedRingBuffer
{
    [FieldOffset(0)] private readonly long[] _slots;
    [FieldOffset(8)] private readonly long _mask;
    [FieldOffset(128)] private long _head;   // the consumer's line
    [FieldOffset(256)] private long _tail;   // the producer's line
    // TryPush and TryPop as in step 4
}
PaddedPer itemItems a secondAgainst the step before
C#, acquire and release3.37 ns297 million17% less time
Go, full fences31.8 ns31.4 million53% more time
C#, full fences (a control)36.9 ns27.1 million47% more time than 25.0 ns unpadded

Padding helped C# and hurt Go. The difference is the fence, not the language: step 3's full-fence C# buffer, padded the same way and measured in the same session, went from 25.0 ns to 36.9, as Go's did. With both counters in one line, a side that fetches the line to publish its own counter gets the other's in the same transfer, and a locked xchg holds that line while it does. Padded, each operation needs two lines, its own and the other side's. That reading fits both languages' numbers; nothing here measures the transfers directly. With release stores, which don't lock the line, the separate lines win.

Local copies

Cached indices

Each side still reads the other's counter on every call, and that read pulls the other side's line across. The producer needs head only to tell whether the buffer is full, and the consumer needs tail only to tell whether it is empty. So each keeps its last look at the other's counter on its own line, and reads the real one only when that look says it must.

[FieldOffset(136)] private long _cachedTail;   // the consumer's line, beside _head
[FieldOffset(264)] private long _cachedHead;   // the producer's line, beside _tail

public bool TryPush(long item)
{
    long tail = _tail;
    if (tail - _cachedHead == _slots.Length)
    {
        _cachedHead = Volatile.Read(ref _head);   // looks full: look again
        if (tail - _cachedHead == _slots.Length) return false;
    }
    _slots[(int)(tail & _mask)] = item;
    Volatile.Write(ref _tail, tail + 1);
    return true;
}
// TryPop does the same with _cachedTail, when the buffer looks empty
Cached indicesPer itemItems a second
C#, 10 launches3.64 ns (step 5: 3.44)275 million
Go24.3 ns (step 5: 31.8)41.2 million

In C# the step changes nothing measurable. Over ten launches each, steps 5 and 6 ran at 3.44 and 3.64 ns, with standard deviations of 0.3 and 0.5 ns, and their fastest iterations overlap. The JIT inlined both versions' calls, so it isn't the extra code. Counting what the cached copies did says why. The ring runs almost empty: this consumer is faster than this producer, and in four of five counted transfers 17 to 32% of its pops found nothing to take (69% in the fifth). The producer's copy of head went stale on 0.3 to 1.5% of pushes in the same four, so the read the step removes was one it seldom had to make.

In Go the step recovers most of what padding cost, 31.8 ns to 24.3: fewer reads of the other side's line, with every store still a full fence.

What stays, when the ring runs empty

With the consumer a few items behind the producer, the two work on the same slots. Eight longs share a cache line, so the line the producer is writing is the one the consumer is reading, whatever the counters do. That is the likely floor under steps 5 and 6 at this capacity, read from the counts, not measured. A consumer that does real work per item, and falls behind, is where caching the indices pays.

Off the shelf

The standard libraries' queues

Each language ships a queue for exactly this. Continuum uses .NET's: the worker that runs a model call hands each token from the loop reading the model to the one writing the output stream through a Channel<T> created with SingleReader and SingleWriter, promises that let the channel pick a cheaper implementation.

// Continuum's InferenceStreamAccumulator
var pending = Channel.CreateUnbounded<Piece>(
    new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });

pending.Writer.TryWrite(new Piece(Collating: false, text));   // as the model streams

while (await reader.WaitToReadAsync(cancellationToken))       // the writer's loop
    while (reader.TryRead(out var piece)) { /* coalesce, flush every 50 ms */ }
QueuePer itemItems a second
C#, bounded channel, spinning on TryWrite and TryRead41.6 ns24.0 million
C#, unbounded channel as Continuum reads it53.6 ns18.6 million
C#, unbounded channel, spinning on TryRead78.8 ns12.7 million
Go, buffered channel42.3 ns23.6 million
Python, queue.Queue, with the GIL631 ns1.6 million
Python, queue.Queue, free-threaded1.18 µs850,000
Python, deque, with the GIL30.0 µs33,000
Python, deque, free-threaded300 ns3.3 million

The hand-built buffer moves 16 times as many items as Continuum's channel, and Continuum keeps the channel. Its producer is a language model: the Nano decodes a token every 2.7 to 2.9 ms on LM Studio, about 360 a second, and the channel can carry 18.6 million. Waiting costs the channel's consumer nothing, since WaitToReadAsync parks it until a token arrives, where a spinning consumer holds a core at full load between tokens. The hand-built buffer's speed would go unused at that rate, and its spinning would take a whole core.

In Python the ordering flips with the build. With the GIL, the blocking queue.Queue moves 48 times as many items as the spinning deque, because blocking hands the GIL over. Free-threaded, the deque is Python's fastest queue and the blocking one its slowest.

The ladder

Summary

Per itemC#GoPython, GILPython, free-threaded
1. Single-threaded, one thread0.86 ns0.87 ns128 ns153 ns
2. Locked51.7 ns26.2 ns38.7 µs789 ns
3. Atomic, full fences26.1 ns20.7 nsno atomicsno atomics
4. Acquire and release4.07 nsnone in sync/atomic
5. Padded3.37 ns31.8 ns
6. Cached indices4.14 ns, within step 5's spread24.3 ns
The standard library's queue41.6 ns bounded; 53.6 ns as Continuum reads it42.3 nsqueue.Queue 631 nsdeque 300 ns

The largest step on the ladder is memory ordering: 26.1 ns to 4.07 in C#, from asking for the order the buffer needs instead of a full fence. The padding and the caching that follow move the last nanosecond, and which way depends on the fence and on how full the ring runs. Go can't take the largest step, so its best buffer is its unpadded step 3, and its mutex takes only 26% longer. Python's best is a deque on the free-threaded build.

By the book

Method

MachineAMD Ryzen 9 9950X3D, 96 GB, Windows 11 Pro (build 26200), Ultimate Performance power plan; 5.4% load across all logical processors before the runs
ThreadsThe producer pinned to logical processor 2 and the consumer to 4: two physical cores sharing one 96 MB L3, by Windows' own topology
Buffer1,024 slots of 64-bit integers; every value checked in order by the consumer
C#BenchmarkDotNet 0.15.8, .NET 10.0.12 (RyuJIT, x86-64-v4), 3 launches, 10 million items an invocation; the two threads live for the whole benchmark
Gogo test -bench . -count 20 -benchtime 2s, go1.26.2, summarised by benchstat
Pythonpyperf 2.10.0, 20 processes of 3 values; CPython 3.14.3 with the GIL and 3.14.7 free-threaded

C#'s numbers carry a launch-to-launch spread of up to 15%: step 6, run again at the end of the session, came in at 3.71 ns against 4.14 in the first pass. The comparison of steps 5 and 6 in C# is from a separate 10-launch run of those two, and the padding control from a separate run of step 3 against its padded twin. The counts behind step 6 come from a copy of the buffer with counters on each side's own line, which isn't timed. One script takes every run again, sets up the virtual environments and benchstat it needs, and keeps each tool's raw output:

powershell -File benchmarks/ring-buffer/run.ps1

# or one tool at a time
dotnet run -c Release --project benchmarks/ring-buffer/dotnet -- --filter *
go test -run '^$' -bench . -count 20 -benchtime 2s        # in benchmarks/ring-buffer/go
.\.venv-free-threaded\Scripts\python.exe bench.py -o free-threaded.json   # in benchmarks/ring-buffer/python
← Back to Articles