A Single Producer Single Consumer (SPSC) lock-free ring buffer for Go. Zero-allocation, zero-mutex, low-latency implementation for passing data between goroutines.
- Lock-free: Uses atomic operations instead of mutexes for maximum throughput
- Zero allocation: No heap allocations during Push/Pop operations
- Cache-line optimized: Prevents false sharing between producer and consumer
- Type-safe: Generic implementation using Go generics
- High performance: Up to 6x faster than channels for single-producer/single-consumer operations
Benchmarks comparing grin vs Go channels vs container/ring (Apple M1 Pro, Go 1.25.5):
BenchmarkGrin_Push-8 97138131 11.96 ns/op 0 B/op 0 allocs/op
BenchmarkStdRing_Push-8 137294083 8.800 ns/op 8 B/op 0 allocs/op
BenchmarkChannel_Push-8 16363477 71.60 ns/op 0 B/op 0 allocs/op
BenchmarkGrin_PushPop-8 100000000 10.58 ns/op 0 B/op 0 allocs/op
BenchmarkStdRing_PushPop-8 132342357 9.282 ns/op 8 B/op 0 allocs/op
BenchmarkChannel_PushPop-8 52933585 22.76 ns/op 0 B/op 0 allocs/op
BenchmarkGrin_Sequential-8 659934 1820 ns/op 0 B/op 0 allocs/op
BenchmarkStdRing_Sequential-8 2572219 465.9 ns/op 0 B/op 0 allocs/op
BenchmarkChannel_Sequential-8 407391 2957 ns/op 0 B/op 0 allocs/op
BenchmarkGrin_FillDrain-8 164268 7300 ns/op 0 B/op 0 allocs/op
BenchmarkStdRing_FillDrain-8 345164 3455 ns/op 2048 B/op 256 allocs/op
BenchmarkChannel_FillDrain-8 101649 11808 ns/op 0 B/op 0 allocs/op
Key Takeaways:
- grin vs Channels: 6x faster for Push, 2x faster for PushPop, 1.6x faster for FillDrain
- grin vs container/ring: Slower for sequential bulk operations (4x), but grin is concurrent-safe for SPSC and tracks buffer fullness. Different use cases—container/ring has no atomics overhead but isn't thread-safe.
- Zero allocations: grin allocates nothing during operation, container/ring allocates on every value assignment
SPSC ring buffers are ideal for high-performance, low-latency communication between exactly one producer and one consumer goroutine:
✅ Use grin when:
- You have exactly one producer and one consumer goroutine
- Maximum throughput and minimum latency are critical
- You want zero allocations during operation
- You can size the buffer appropriately upfront (power of 2)
- You need predictable, bounded memory usage
- Examples: High-frequency trading, audio/video processing, network packet handling, log aggregation
- You have multiple producers or consumers (use channels instead)
- You need Go's channel synchronization primitives (select, close, etc.)
- Buffer size can't be determined upfront
- You need dynamic resizing
The standard library's container/ring is a circular doubly-linked list:
✅ Use container/ring when:
- You need to iterate forwards and backwards through a circular buffer
- You don't need to track buffer fullness (it overwrites old data)
- You're storing interface{} values and type safety isn't critical
- Performance isn't the primary concern
- Examples: Recent history/cache, circular iterators, round-robin algorithms
- You need zero allocations (it allocates on every value assignment)
- You need to know if the buffer is full/empty
- You need type safety with generics
- You need multi-threaded access (not thread-safe)
Go channels are the general-purpose communication primitive:
✅ Use channels when:
- You have multiple producers and/or multiple consumers
- You need select statements for multiplexing
- You need close() semantics for signaling completion
- You want the scheduler to handle goroutine synchronization
- Code clarity is more important than raw performance
- Examples: General goroutine communication, fan-out/fan-in patterns, cancellation
- You need the absolute lowest latency (use SPSC ring buffers)
- You're doing high-frequency operations (millions/sec)
- Lock-free algorithms are required
grin uses several optimizations:
- Power-of-2 sizing: Allows fast modulo operations using bitwise AND
- Cache-line padding: 56-byte padding prevents false sharing between CPU cores
- Lock-free atomic operations: Producer owns tail, consumer owns head
- Separate cache lines: Head and tail pointers are on different cache lines to prevent contention
go get github.com/andrewwormald/grin
type RingBuffer[T any] interface {
// Push adds an item to the buffer.
// Returns false if buffer is full (non-blocking).
Push(t T) bool
// Pop removes and returns an item from the buffer.
// Returns (zero value, false) if buffer is empty (non-blocking).
Pop() (T, bool)
// Cap returns the total capacity of the ring buffer.
Cap() int
// Len returns the current number of elements in the buffer.
Len() int
// Available returns the number of free slots in the buffer.
Available() int
}
// New creates a new ring buffer with the specified size.
// Size must be a power of 2, otherwise it panics.
func New[T any](size int) RingBuffer[T]- Buffer size must be a power of 2 (enforced by panic)
- Single producer goroutine only
- Single consumer goroutine only
See LICENSE file for details.
