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.
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.
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 thread | Per item | Items a second |
|---|---|---|
| C# | 0.86 ns | 1,167 million |
| Go | 0.87 ns | 1,148 million |
| Python, with the GIL | 128 ns | 7.8 million |
| Python, free-threaded | 153 ns | 6.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
| Locked | Per item | Items a second |
|---|---|---|
| C# | 51.7 ns | 19.3 million |
| Go | 26.2 ns | 38.2 million |
| Python, with the GIL | 38.7 µs | 26,000 |
| Python, free-threaded | 789 ns | 1.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, thenpy -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())" # FalsePython'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 fences | Per item | Items a second |
|---|---|---|
| C# | 26.1 ns | 38.4 million |
| Go | 20.7 ns | 48.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 release | Per item | Items a second |
|---|---|---|
| C# | 4.07 ns | 246 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: tailCache 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
}
| Padded | Per item | Items a second | Against the step before |
|---|---|---|---|
| C#, acquire and release | 3.37 ns | 297 million | 17% less time |
| Go, full fences | 31.8 ns | 31.4 million | 53% more time |
| C#, full fences (a control) | 36.9 ns | 27.1 million | 47% 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 indices | Per item | Items a second |
|---|---|---|
| C#, 10 launches | 3.64 ns (step 5: 3.44) | 275 million |
| Go | 24.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 */ }
| Queue | Per item | Items a second |
|---|---|---|
C#, bounded channel, spinning on TryWrite and TryRead | 41.6 ns | 24.0 million |
| C#, unbounded channel as Continuum reads it | 53.6 ns | 18.6 million |
C#, unbounded channel, spinning on TryRead | 78.8 ns | 12.7 million |
| Go, buffered channel | 42.3 ns | 23.6 million |
Python, queue.Queue, with the GIL | 631 ns | 1.6 million |
Python, queue.Queue, free-threaded | 1.18 µs | 850,000 |
Python, deque, with the GIL | 30.0 µs | 33,000 |
Python, deque, free-threaded | 300 ns | 3.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 item | C# | Go | Python, GIL | Python, free-threaded |
|---|---|---|---|---|
| 1. Single-threaded, one thread | 0.86 ns | 0.87 ns | 128 ns | 153 ns |
| 2. Locked | 51.7 ns | 26.2 ns | 38.7 µs | 789 ns |
| 3. Atomic, full fences | 26.1 ns | 20.7 ns | no atomics | no atomics |
| 4. Acquire and release | 4.07 ns | none in sync/atomic | ||
| 5. Padded | 3.37 ns | 31.8 ns | ||
| 6. Cached indices | 4.14 ns, within step 5's spread | 24.3 ns | ||
| The standard library's queue | 41.6 ns bounded; 53.6 ns as Continuum reads it | 42.3 ns | queue.Queue 631 ns | deque 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
| Machine | AMD Ryzen 9 9950X3D, 96 GB, Windows 11 Pro (build 26200), Ultimate Performance power plan; 5.4% load across all logical processors before the runs |
| Threads | The producer pinned to logical processor 2 and the consumer to 4: two physical cores sharing one 96 MB L3, by Windows' own topology |
| Buffer | 1,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 |
| Go | go test -bench . -count 20 -benchtime 2s, go1.26.2, summarised by benchstat |
| Python | pyperf 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/pythonSources [ − ] [ + ]
Documents
Repos