For sub-second event routers, latency is not one number. Mean latency, tail latency, allocations, and scheduler behavior all matter, and the obvious optimization can still be the wrong one.
It is easy to assume that Go channels add too much jitter under load, and that a custom lock-free queue will be faster by default.
That assumption did not hold. Native channels beat the custom ring buffer in both the single-producer and multi-producer cases.
The Anatomy of a Sub-Second Event Loop
The event loop ingests, evaluates, and routes messages. To understand the trade-offs between standard library channels and custom lock-free structures, the important question is how each path interacts with the Go scheduler.
- Unbuffered Channels: Introduce a hard synchronization point between the sender and receiver. This guarantees delivery but forces context switches.
- Buffered Channels: Reduce the synchronization cost by adding an intermediate queue. The usual concern is contention in the channel implementation.
- Lock-Free Ring Buffers: Use atomic operations (CAS - Compare and Swap) on pre-allocated contiguous memory. The goal is to avoid locks on the hot path.
I assumed adopting a lock-free ring buffer for the primary event bus in rteval would reduce tail latency by an order of magnitude. I was wrong.
Benchmark: Channel Contention vs. Atomics
To measure the impact, I built a microbenchmark comparing standard Go buffered channels against a naive lock-free ring buffer.
| Workload | Native Channel (1024 cap) | Lock-Free Ring Buffer | Winner |
|---|---|---|---|
| 1:1 (Single Prod/Cons) | 32.1 ns/op | 53.4 ns/op | Native Channel |
| MPMC (10 Prods) | 61.7 ns/op | 496.5 ns/op | Native Channel |
The benchmark points to a practical lesson: Go’s native channels are not a naive queue. A CAS-based ring buffer avoids some lock paths, but Go’s hchan implementation is integrated with the goroutine scheduler and manages wait queues efficiently. In this workload, that integration beats the custom atomic queue.
Methodology & Reproduction
To verify the result, run the benchmark suite and compare the channel path against the ring-buffer path.
- Clone the Benchmark:
git clone https://gist.github.com/omar391/9882eca078a59936582437727b691e4b go-ring-bench cd go-ring-bench - Run Benchmarks:
go mod init bench go test -bench=. -benchmem
Technical Implementation: Lock-Free Primitives
The ring buffer uses sync/atomic for head and tail pointers on a pre-allocated slice. The design keeps the hot path allocation-free.
type RingBuffer struct {
head uint64
_ [56]byte // Padding to prevent false sharing
tail uint64
_ [56]byte // Padding
mask uint64
data []interface{}
}
func (rb *RingBuffer) Push(item interface{}) bool {
tail := atomic.LoadUint64(&rb.tail)
head := atomic.LoadUint64(&rb.head)
if tail-head >= rb.mask+1 {
return false // Buffer full
}
rb.data[tail&rb.mask] = item
atomic.StoreUint64(&rb.tail, tail+1)
return true
}
CPU Cache Efficiency
Beyond avoiding locks, the benchmark uses Cache Line Padding (the [56]byte arrays in the struct above). Without padding, the head and tail pointers can share a 64-byte L1 cache line. When one core updates tail, it can invalidate the line another core uses for head, causing false sharing.
Conclusion
For most Go services, channels are still the right first choice. Lock-free code needs a benchmark on the real workload; without that proof, it can add complexity, hide bugs, and still lose to the runtime.