A ring buffer is one array, allocated once, with a read index and a write index that move forward and wrap at the end of the array. Adding an element writes one slot and increments one index. Removing one reads a slot and increments the other. No element is ever shifted, nothing is reallocated after construction, and the memory the structure uses is decided before the first element arrives. That last property is why the ring is the queue in audio callbacks, network drivers and microcontrollers, where allocating during the work is either slow enough to be audible or impossible.
An array, two indices, and one ambiguity
Draw eight boxes in a row and bend the row into a circle so the eighth box sits next to the first. Put an arrow labelled read on one box and an arrow labelled write on another. The live elements are the boxes from read forward to write, travelling around the circle; everything from write forward to read is free space. Writing puts an element in the box write points at and moves write one step clockwise. Reading takes the element from the box read points at and moves read one step clockwise. Neither arrow ever moves anticlockwise, and neither element ever moves at all.
The invariant is that the elements present are exactly the slots in that forward range, in that order, and no slot outside the range holds anything the structure will return. Stated that way it hides the problem, so walk the two arrows to the edge of it. Start empty with both arrows on box zero. Write eight elements and write travels all the way round and lands back on box zero. Read eight elements and read does the same. In the first case every box holds a live element; in the second every box is free. Both cases have read and write pointing at the same box.
Two indices into an array of n slots can distinguish n states. A ring holding between zero and n elements inclusive has n+1 of them, so the pair of positions is one state short, and the distinction it loses is the one between full and empty. Production ring buffers pick one of three resolutions.
| Resolution | Empty when | Full when | Usable slots | Index writers |
|---|---|---|---|---|
| Keep a count | count == 0 |
count == cap |
cap |
both ends write count |
| Leave one slot unused | w == r |
(w+1) % cap == r |
cap - 1 |
one writer per index |
| Monotonic indices | w == r |
w - r == cap |
cap |
one writer per index |
Keeping a count is the one people write first, and it reads clearly. Adding an element increments count and removing one decrements it, so both ends write the same variable, and a ring whose producer and consumer are different goroutines then needs a lock or an atomic read-modify-write on every operation.
Leaving one slot unused removes the count and keeps each index owned by one side, at the cost of a capacity that is not the number you asked for. An array of eight holds seven elements. For a 64-slot ring of pointers nobody notices; for a ring sized to a hardware constraint, the arithmetic is now off by one everywhere and the comments have to say so.
Monotonic indices keep every slot and keep one writer per index. w counts the elements ever written and r counts the elements ever read, and neither is ever reduced or wrapped. The number of live elements is w - r, which is zero when they are equal and cap when the ring is full, and those two states are now distinct because the indices are not positions. The position is derived when a slot is touched: element number i lives at i % cap. This is the resolution production rings usually settle on. It brings two things with it: what happens when a monotonic counter runs out of range, and why the capacity has to be a power of two.
Where the cost actually sits
Every operation is constant time in the worst case, with no amortised qualifier anywhere: a push writes one slot and adds one to an integer, a pop reads one slot and adds one to an integer, and neither touches any other element. There is no growth, no copy and no reallocation, so the structure has no operation whose cost depends on how many elements it holds. Space is cap element slots plus two integers and a slice header, decided at construction and never revised.
The constants are better than the complexity table suggests, because the slots are adjacent in memory. The slice header is three words and the array is somewhere else, and a ring touches that array at two moving points, with the producer writing a short distance ahead of the consumer’s reads. A ring small enough to sit inside the cache stays there. A larger one streams through it in the order the hardware prefetches. Compare that with a linked queue of the same length, where each pop is a dependent load through a pointer to an address the prefetcher has no way to predict.
The modulus is the one arithmetic cost in the operation, and it is the reason capacities are powers of two. For an unsigned i and a power-of-two cap, i % cap and i & (cap-1) give the same answer, and a bitwise and is one instruction with no division in it. When cap is a compile-time constant power of two the compiler can make that substitution on its own. When cap is a field decided at construction, which is the usual case for a reusable type, there is no constant to fold, and current compilers emit a divide. Store the mask in the struct and use it. If you want a number for the difference on your hardware, run the benchmark below and read its output, because the answer belongs to the processor rather than to Go.
Two costs are specific to Go and neither is in the complexity table. The first is that a ring cannot hand out its contents as one slice. The live range may cross the end of the array, so the honest return type is two slices, and any code that drains a ring into something else does two copy calls rather than one. Those two slices alias the backing array, so they stay valid only until the next push. That is the append-aliasing hazard in its ring-shaped form: the slice you were handed and the array the ring is writing into are the same memory, and nothing in the type system says so.
The second is where the array lives. A ring built with make([]T, n) for a capacity the compiler cannot see at compile time is normally a heap allocation, and one allocation for the life of the structure is what the structure is chosen for. A ring whose store is a fixed-size array field, in a struct that does not escape the function, is a candidate for the stack, and whether the compiler keeps it there is a decision it makes per build. go build -gcflags=-m prints what it decided for the version you are building with.
The shape of problem it fits
Reach for a ring when the memory has to be decided in advance and the work arrives faster than it leaves, at least sometimes. An audio callback is the clearest case. The driver calls your code every few milliseconds, hands it a block of samples and expects it back before the next block is due, and anything that allocates, locks or waits inside that window risks a gap the listener hears. A ring sized at construction solves the whole problem: the callback writes samples into slots that already exist and returns, and some other goroutine reads them out. A network driver has the same shape one level down, where a receive ring of fixed descriptors lets the card deliver packets into memory reserved before any packet arrived. A microcontroller reading a serial port has no allocator at all, so the ring is not an optimisation but the only structure available: the interrupt handler writes one byte and returns, the main loop reads what has accumulated.
The bound itself is the feature, which is easy to miss when an unbounded queue looks more accommodating. Put an unbounded queue in front of a consumer that is slower than its producer and the queue grows until the process runs out of memory, which converts a throughput problem into a crash, some minutes or hours after the throughput problem started. A ring cannot do that. When the producer outruns the consumer, the ring reaches cap and something has to give immediately, in the push, where you can see it: either the oldest element goes or the producer waits. The structure forces the decision at design time instead of deferring it to the night it matters.
Which way to resolve it depends on which data matters, and the two answers are genuinely opposite. A buffer of metrics samples, log lines kept for a crash report, or the most recent frames of telemetry should overwrite the oldest entry. The newest readings are the ones anybody will look at, and stalling the producer to preserve a sample from four minutes ago is worse than losing it. A queue of payment instructions must not drop anything: the producer blocks, or the push returns an error that the caller handles by refusing the request upstream, and either of those is correct where a silent drop is a lost payment. Write the behaviour you need into the type’s name and its signature, so that PushOverwrite returning the element it displaced and Push returning an error are two different calls rather than one call with a flag.
The last case is a handoff between exactly two goroutines, one writing and one reading. With monotonic indices and one writer per index, that handoff needs no mutex, which the last of the code below works through.
When not to, and what instead
Try a buffered channel first. make(chan T, 64) is a bounded FIFO queue with the blocking already written, the memory model already satisfied, and no restriction on how many goroutines use either end. The runtime keeps its elements in a circular buffer, so a hand-rolled ring with a mutex around it usually reimplements what the language already provides, and worse. What a channel will not do is drop the oldest element: the familiar non-blocking send with select and a default drops the newest value instead. It also exposes nothing of its contents beyond len and cap, and it moves one element per operation, where a byte ring takes a whole buffer with two copy calls. Write your own when one of those is what you need, or when the scheduler is not available to you at all.
Use a dynamic array or a linked queue with head and tail pointers when the bound is genuinely unknown and dropping is not acceptable. A ring has to be sized, and sizing it to the largest plausible burst means holding that memory permanently for a burst that happens weekly.
Use a plain slice whenever the interesting operations are not at the ends. Indexing by position, sorting, searching, iterating in order: all of these are easier on a contiguous array with a single start, and the slices package already covers most of them. Wrapping an array in a type whose contents are split across the end of the buffer makes every one of those operations harder for no return.
container/ring is not this structure, despite the name. It is a circular doubly linked list, an element with a value and two pointers, with no concept of full or empty and no fixed capacity; its Value field is any, so every element of a value type is boxed on the way in. It suits a rotation through a fixed set of participants, which is what it was written for. It is the wrong tool for a bounded FIFO.
Store pointers rather than values once the element is large. A ring copies each element twice, once on push and once on pop, so a ring of 4 KB structs moves 8 KB per element through the pipeline where a ring of pointers moves 16 bytes. The consequence of switching to pointers is that an abandoned slot keeps whatever it points at alive, so the pop has to zero the slot it has read.
Do not hand-roll a lock-free ring for several producers. The single-producer case below is short because each index has exactly one writer. With two producers, reserving a slot and filling it are two separate steps, and a second producer can reserve and publish past a slot the first has not filled yet. The structure then needs a per-slot sequence number saying which pass the slot is on, and the ordering argument gets long. A mutex over the simple ring costs one uncontended lock and unlock per operation and is correct the first time. A channel is correct and already written.
Writing it in Go
The monotonic version is a struct with four fields, and the comment above it is the specification.
// Package ringkit is a fixed-capacity FIFO over one array. The indices count
// elements written and read for the life of the ring and are never reduced;
// the slot for element i is i & mask. Capacity is a power of two, which is
// what keeps that mapping continuous when the indices wrap at 2^64.
package ringkit
import (
"errors"
"fmt"
"math/bits"
"sync/atomic"
)
// maxCap is low enough that rounding a requested capacity up to a power of
// two cannot overflow an int on a 32-bit build.
const maxCap = 1 << 30
var ErrFull = errors.New("ringkit: ring is full")
// Ring holds between 0 and len(buf) elements. The invariants, which check
// verifies, are that len(buf) is a power of two, that mask is len(buf)-1, and
// that write-read is at most len(buf).
type Ring[T any] struct {
buf []T
mask uint64
write uint64
read uint64
}
// New returns a Ring whose capacity is minCap rounded up to a power of two.
// bits.Len(0) is 0, so a request of 1 gives a capacity of 1.
func New[T any](minCap int) *Ring[T] {
if minCap < 1 {
minCap = 1
}
if minCap > maxCap {
panic(fmt.Sprintf("ringkit: capacity %d above the ceiling of %d", minCap, maxCap))
}
c := 1 << bits.Len(uint(minCap-1))
return &Ring[T]{buf: make([]T, c), mask: uint64(c) - 1}
}
func (b *Ring[T]) Cap() int { return len(b.buf) }
func (b *Ring[T]) Len() int { return int(b.write - b.read) }
// Push adds v at the back, or returns ErrFull without modifying anything.
// The receiver is a pointer because a value receiver copies the struct: the
// element would land in the shared backing array and the index increment
// would be discarded with the copy, which compiles and loses every push.
func (b *Ring[T]) Push(v T) error {
if b.write-b.read == uint64(len(b.buf)) {
return ErrFull
}
b.buf[b.write&b.mask] = v
b.write++
return nil
}
// Pop removes and returns the front element. The zero assignment matters for
// any T holding a reference: without it the slot keeps the element reachable
// from the backing array until some later push overwrites that slot.
func (b *Ring[T]) Pop() (T, bool) {
var zero T
if b.write == b.read {
return zero, false
}
i := b.read & b.mask
v := b.buf[i]
b.buf[i] = zero
b.read++
return v, true
}
The indices are uint64 and they are allowed to run out of range, because the Go specification defines addition on unsigned integers as modulo two to the power of the type’s width. When write passes the largest uint64 it continues from zero, and write - read still gives the right count: with read three short of the maximum and write three past zero, the subtraction wraps the same way and the difference comes back as six. The live count is bounded by the capacity, so it is never large enough for the wrap to be visible in it.
The slot mapping is where the wrap does show, and only for a capacity that is not a power of two. The index sequence restarts at zero after the largest uint64, so the slot mapping is continuous across that restart exactly when the capacity divides two to the sixty-fourth, which for a positive capacity means exactly when it is a power of two. Take a capacity of three: two to the sixty-fourth leaves a remainder of one when divided by three, so the largest uint64 maps to slot zero and so does the next index, and two consecutive elements are written to the same slot. There is a test for this below, and that is the second reason for a power-of-two capacity.
How far away the wrap itself is depends on the width of the index, which is why uint64 rather than uint32. At a million elements a second, a 32-bit counter runs out in about 72 minutes; a 64-bit counter at a billion elements a second lasts something over five hundred years. The first of those is a bug a long-running process will find and the second is not, and both are arithmetic on the width rather than anything measured.
Overwriting the oldest element is a second push method, and it is shorter than it looks because of a coincidence of the mask.
// PushOverwrite adds v, displacing the oldest element when the ring is full
// and returning it. When full, write-read is cap, so write&mask and
// read&mask name the same slot: the assignment below overwrites the element
// being dropped, and no separate zeroing is needed.
//
// It advances read, so a producer must not call it while a separate consumer
// owns read. Overwrite-oldest and the lock-free handoff are different designs.
func (b *Ring[T]) PushOverwrite(v T) (T, bool) {
var dropped T
didDrop := false
if b.write-b.read == uint64(len(b.buf)) {
dropped, didDrop = b.buf[b.read&b.mask], true
b.read++
}
b.buf[b.write&b.mask] = v
b.write++
return dropped, didDrop
}
// Segments returns the live elements in order as at most two slices, which is
// what a ring can offer in place of one: the live range may cross the end of
// the array. Both views alias the buffer and are valid only until the next
// push.
func (b *Ring[T]) Segments() ([]T, []T) {
n := int(b.write - b.read)
if n == 0 {
return nil, nil
}
start := int(b.read & b.mask)
if start+n <= len(b.buf) {
return b.buf[start : start+n], nil
}
first := len(b.buf) - start
return b.buf[start:], b.buf[:n-first]
}
// check verifies the invariants. read ahead of write would make the
// subtraction wrap to something near 2^64, which the capacity comparison
// catches, so there is no separate ordering check.
func (b *Ring[T]) check() error {
c := uint64(len(b.buf))
if c == 0 || c&(c-1) != 0 {
return fmt.Errorf("capacity %d is not a power of two", c)
}
if b.mask != c-1 {
return fmt.Errorf("mask is %d, want %d", b.mask, c-1)
}
if n := b.write - b.read; n > c {
return fmt.Errorf("%d live elements in a ring of %d", n, c)
}
return nil
}
A ring of bytes needs a bulk path, and in Go that path cannot be a method. The language has no way to give Ring[T] a method that applies only to Ring[byte], so a specialised path for one instantiation lives in a function instead.
// WriteBytes copies as much of p into b as there is room for and returns how
// many bytes it took. Two copies, because the free space may be split by the
// end of the array. min is a builtin from Go 1.21.
func WriteBytes(b *Ring[byte], p []byte) int {
n := min(len(b.buf)-int(b.write-b.read), len(p))
start := int(b.write & b.mask)
first := min(n, len(b.buf)-start)
copy(b.buf[start:start+first], p[:first])
copy(b.buf[:n-first], p[first:n])
b.write += uint64(n)
return n
}
// ReadBytes drains up to len(p) bytes into p. The abandoned slots are left
// alone: a byte holds no reference, so there is nothing for them to keep
// alive.
func ReadBytes(b *Ring[byte], p []byte) int {
n := min(int(b.write-b.read), len(p))
start := int(b.read & b.mask)
first := min(n, len(b.buf)-start)
copy(p[:first], b.buf[start:start+first])
copy(p[first:n], b.buf[:n-first])
b.read += uint64(n)
return n
}
// NextFrame removes one length-prefixed frame and returns its payload, or
// reports that b does not hold a whole frame yet. The length byte stays in
// the ring until its payload has arrived, so a partial frame is
// left where it is. A one-byte prefix caps a payload at 255, so a ring of 256
// or more can always make progress; a smaller one deadlocks on a large frame.
func NextFrame(b *Ring[byte]) ([]byte, bool) {
live := int(b.write - b.read)
if live < 1 {
return nil, false
}
n := int(b.buf[b.read&b.mask])
if live < 1+n {
return nil, false
}
b.read++
out := make([]byte, n)
ReadBytes(b, out)
return out, true
}
The single-producer single-consumer ring is the design the monotonic indices make possible. The producer is the only writer of write and the consumer is the only writer of read, so neither index needs a read-modify-write and neither side needs a lock. What it does need is an ordering guarantee, and the guarantee comes from the Go memory model: the operations in sync/atomic behave as though executed in some sequentially consistent order, and when an atomic read observes an atomic write, that write is synchronised before the read. Chain that with program order on each side and the argument closes. The producer fills the slot, then stores the new write. A consumer that loads that value therefore also sees the filled slot, because the fill came before the store in the producer’s own order and the store is synchronised before the load.
// SPSC is a ring for exactly one producer goroutine and one consumer
// goroutine. Push may be called only by the producer and Pop only by the
// consumer. Two producers, or two consumers, is a different structure.
type SPSC[T any] struct {
buf []T
mask uint64
write atomic.Uint64
_ [56]byte // padding, on the assumption of a 64-byte cache line
read atomic.Uint64
}
func NewSPSC[T any](minCap int) *SPSC[T] {
r := New[T](minCap)
return &SPSC[T]{buf: r.buf, mask: r.mask}
}
// Push fills the slot and then publishes the index, in that order. Reversing
// the two lines leaves a consumer reading a slot the producer has not written
// yet, and go test -race reports it only on the runs where the two accesses
// actually overlap.
func (q *SPSC[T]) Push(v T) bool {
w := q.write.Load()
if w-q.read.Load() == uint64(len(q.buf)) {
return false
}
q.buf[w&q.mask] = v
q.write.Store(w + 1)
return true
}
// Pop reads the slot, zeroes it, and then publishes the index. The zeroing is
// safe before the store because the producer cannot write that slot until it
// observes the new read index, and it is unsafe after it.
func (q *SPSC[T]) Pop() (T, bool) {
var zero T
r := q.read.Load()
if q.write.Load() == r {
return zero, false
}
i := r & q.mask
v := q.buf[i]
q.buf[i] = zero
q.read.Store(r + 1)
return v, true
}
The padding field rests on two assumptions. The first is that the cache line is 64 bytes, which is common and not universal; the effect it avoids is a store to write invalidating the line that holds read. The second is that Go offers no way to require a particular alignment for a struct field, so padding raises the chance the two indices land on separate lines without guaranteeing it. Measure before keeping it. The atomic.Uint64 type is doing a second job here: the package documentation notes that the plain 64-bit atomic functions need the caller to arrange 64-bit alignment on 32-bit platforms, and that the named types arrange it themselves.
How to test it
Most of what follows calls check after every operation, because the defects this structure ships are off-by-one errors in the index arithmetic and those produce right-looking output for a long time. Everything below is in ringkit_test.go in the same package, so it can reach buf, the indices and check; the loops range over integers and the randomised test calls slices.Concat, so it needs Go 1.22 or later.
Table-driven tests cover the boundaries you can name, and for a ring those are all about where the live range sits relative to the end of the array.
package ringkit
import (
"bytes"
"fmt"
"math/rand/v2"
"runtime"
"slices"
"testing"
)
func TestSegmentsAcrossTheWrap(t *testing.T) {
tests := []struct {
name string
pushes, pops, repushes int
wantFirst, wantSecond []int
}{
{"empty", 0, 0, 0, nil, nil},
{"contiguous from zero", 3, 0, 0, []int{1, 2, 3}, nil},
{"full and contiguous", 4, 0, 0, []int{1, 2, 3, 4}, nil},
{"offset and still contiguous", 4, 2, 0, []int{3, 4}, nil},
{"wrapped", 4, 2, 2, []int{3, 4}, []int{5, 6}},
{"wrapped and full", 4, 3, 3, []int{4}, []int{5, 6, 7}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := New[int](4)
next := 1
for range tt.pushes {
if err := b.Push(next); err != nil {
t.Fatalf("push %d: %v", next, err)
}
next++
}
for range tt.pops {
if _, ok := b.Pop(); !ok {
t.Fatal("pop on a non-empty ring returned false")
}
}
for range tt.repushes {
if err := b.Push(next); err != nil {
t.Fatalf("push %d: %v", next, err)
}
next++
}
first, second := b.Segments()
if !slices.Equal(first, tt.wantFirst) || !slices.Equal(second, tt.wantSecond) {
t.Errorf("segments %v and %v, want %v and %v",
first, second, tt.wantFirst, tt.wantSecond)
}
if err := b.check(); err != nil {
t.Error(err)
}
})
}
}
The wrap of the index type needs a test of its own, and it takes two lines to set up, because the indices are ordinary fields and a test in the same package can set them. Start three elements short of the largest uint64 and run a few hundred operations across the boundary.
func TestFIFOOrderSurvivesTheIndexWrap(t *testing.T) {
b := New[int](4)
b.write = ^uint64(0) - 2 // three pushes reach the wrap
b.read = b.write
next, expect := 0, 0
for range 200 {
for b.Len() < b.Cap() {
if err := b.Push(next); err != nil {
t.Fatalf("push %d: %v", next, err)
}
next++
}
for range 3 {
got, ok := b.Pop()
if !ok {
t.Fatal("pop on a non-empty ring returned false")
}
if got != expect {
t.Fatalf("popped %d, want %d, with write at %d", got, expect, b.write)
}
expect++
}
if err := b.check(); err != nil {
t.Fatal(err)
}
}
}
That test passes for a capacity of four and fails for a capacity of three, which is a claim about arithmetic rather than about this implementation, so assert it directly. The slot mapping is continuous across the restart of the index exactly when the capacity divides two to the sixty-fourth.
func TestSlotMappingIsContinuousOnlyForPowerOfTwoCapacity(t *testing.T) {
last := ^uint64(0) // the largest uint64; the next index is 0
for _, c := range []uint64{2, 3, 4, 5, 8, 16} {
powerOfTwo := c&(c-1) == 0
continuous := (last+1)%c == (last%c+1)%c
if continuous != powerOfTwo {
t.Errorf("capacity %d: continuous across the wrap %v, power of two %v",
c, continuous, powerOfTwo)
}
}
}
The test that finds what the tables miss is a randomised sequence against a reference, and for a ring the reference is a plain slice with the operations written the obvious way. Generate a mixed sequence, apply each operation to both, and compare after every one, including the invariant.
func TestMatchesSliceReference(t *testing.T) {
rng := rand.New(rand.NewPCG(7, 11)) // fixed seed, so a failure replays
for range 500 {
b := New[int](1 + rng.IntN(8)) // capacities round to 1, 2, 4 and 8
var ref []int
for range rng.IntN(80) {
switch rng.IntN(4) {
case 0:
v := rng.IntN(1000)
err := b.Push(v)
if full := len(ref) == b.Cap(); (err != nil) != full {
t.Fatalf("Push returned %v with %d of %d used", err, len(ref), b.Cap())
}
if err == nil {
ref = append(ref, v)
}
case 1:
got, ok := b.Pop()
if ok != (len(ref) > 0) {
t.Fatalf("Pop ok %v with %d reference elements", ok, len(ref))
}
if ok {
if got != ref[0] {
t.Fatalf("Pop returned %d, want %d", got, ref[0])
}
ref = ref[1:] // fine on ints in a test; the reason the ring exists in production
}
case 2:
v := rng.IntN(1000)
dropped, didDrop := b.PushOverwrite(v)
if didDrop != (len(ref) == b.Cap()) {
t.Fatalf("PushOverwrite dropped %v with %d of %d used",
didDrop, len(ref), b.Cap())
}
if didDrop {
if dropped != ref[0] {
t.Fatalf("dropped %d, want %d", dropped, ref[0])
}
ref = ref[1:]
}
ref = append(ref, v)
case 3:
first, second := b.Segments()
if live := slices.Concat(first, second); !slices.Equal(live, ref) {
t.Fatalf("segments hold %v, reference %v", live, ref)
}
}
if err := b.check(); err != nil {
t.Fatal(err)
}
if b.Len() != len(ref) {
t.Fatalf("Len is %d, reference holds %d", b.Len(), len(ref))
}
}
}
}
check runs after every single operation rather than once at the end, so a failure names the operation that broke the invariant instead of the one that happened to notice. Small capacities are deliberate: the interesting states are near the boundaries, and a ring of 1,024 reached by random operations spends almost no time full.
The framing decoder gets a fuzz target, because its input is bytes and the inputs that matter are the ones where a frame straddles the end of the array. The reference is the same decode over a plain slice, which does no wrapping at all.
func referenceFrames(in []byte) [][]byte {
var out [][]byte
for len(in) >= 1 {
n := int(in[0])
if len(in) < 1+n {
break // an incomplete frame at the end, which the decoder also holds back
}
out = append(out, in[1:1+n])
in = in[1+n:]
}
return out
}
func FuzzFramer(f *testing.F) {
f.Add([]byte("\x03abc\x00\x01z"), uint8(1))
f.Add([]byte("\xffshort"), uint8(5))
f.Fuzz(func(t *testing.T, in []byte, chunk uint8) {
step := int(chunk)%7 + 1 // small writes, so frames straddle the end
b := New[byte](512) // above 1+255, so a whole frame always fits
drain := func(got [][]byte) [][]byte {
for {
fr, ok := NextFrame(b)
if !ok {
return got
}
got = append(got, fr)
}
}
var got [][]byte
for i := 0; i < len(in); {
// After draining, the ring holds at most 255 bytes of a partial
// frame, so WriteBytes always takes at least one byte and this
// loop always advances.
i += WriteBytes(b, in[i:min(i+step, len(in))])
got = drain(got)
if err := b.check(); err != nil {
t.Fatal(err)
}
}
got = drain(got)
want := referenceFrames(in)
if len(got) != len(want) {
t.Fatalf("decoded %d frames, reference found %d", len(got), len(want))
}
for i := range got {
if !bytes.Equal(got[i], want[i]) {
t.Fatalf("frame %d is %q, want %q", i, got[i], want[i])
}
}
})
}
The lock-free ring needs a test under -race with a real producer and a real consumer, and the assertion is stronger than “no race was reported”. The consumer checks that it received every element exactly once in order, which fails on a published-too-early slot even on a build without the detector.
func TestSPSCDeliversEveryElementInOrder(t *testing.T) {
const n = 200_000
q := NewSPSC[int](64)
done := make(chan error, 1)
stop := make(chan struct{})
go func() {
defer close(stop) // whichever way the consumer leaves, the producer learns
for i := range n {
for {
v, ok := q.Pop()
if !ok {
runtime.Gosched()
continue
}
if v != i {
done <- fmt.Errorf("element %d is %d", i, v)
return
}
break
}
}
done <- nil
}()
// Without the stop channel, a consumer that reports a bad element and
// returns leaves this loop spinning on a ring nobody will drain again, so
// the failure arrives as a package timeout instead of a named error.
producing:
for i := range n {
for !q.Push(i) {
select {
case <-stop:
break producing
default:
runtime.Gosched()
}
}
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
Run that one with go test -race, and treat a clean run as evidence rather than proof. The detector reports a pair of conflicting accesses when it observes them overlap during the run, so a race that needs an unlucky interleaving can survive a passing test. The mutation to try is swapping the two lines at the end of Push so the index is published before the slot is filled. That version passes a plain go test almost always and is reported under -race once the timing lines up.
Where the claim is about cost, the test is a benchmark and the output is a measurement on one machine rather than a fact about Go.
var (
benchCap uint64 = 1024 // package-level, so neither form is constant-folded
benchMask uint64 = benchCap - 1
benchSink uint64
)
func BenchmarkSlotIndex(b *testing.B) {
b.Run("mask", func(b *testing.B) {
var acc uint64
for i := range uint64(b.N) {
acc += i & benchMask
}
benchSink = acc
})
b.Run("modulus", func(b *testing.B) {
var acc uint64
for i := range uint64(b.N) {
acc += i % benchCap
}
benchSink = acc
})
}
func BenchmarkPushPop(b *testing.B) {
b.ReportAllocs() // zero after construction is the claim worth checking
r := New[int](1024)
for i := range b.N {
if err := r.Push(i); err != nil {
if _, ok := r.Pop(); !ok {
b.Fatal("pop on a full ring returned false")
}
if err := r.Push(i); err != nil {
b.Fatal(err)
}
}
}
benchSink = uint64(r.Len())
}
Read allocs/op before the nanoseconds. Allocations per operation are a property of the code, and for a ring of a value type the number is zero once the array exists. If it is not zero, something in the push path is escaping and the structure is not doing the one thing it was chosen for. The nanoseconds belong to the hardware and the load on it, and the only honest way to quote them is with the machine attached.