Buffered Channels + Select: Boost Go Concurrency Performance
Buffered channels let Go producers run ahead, boosting pipeline throughput. Using select you can add graceful shutdown, timeouts, and non‑blocking coordination. This blog shows a concrete example, buffer‑size trade‑offs, and checks to keep concurrency safe.
10 Feb 2026, 22:56 UTC

Problem: Tight‑Loop Producer & Consumer Deadlocks
In many Go services you write a producer goroutine that feeds data into a channel, and a consumer that processes it. If the channel is unbuffered, the producer blocks until the consumer receives the value. In a tight loop this can cause a cascade of context switches, making the system slower and more prone to subtle deadlocks when a consumer panics or a channel is closed early.
Thesis: Use a Small Buffer + select for Throughput & Graceful Shutdown
By giving the channel a modest buffer you let the producer run ahead, reducing blocking and improving throughput. Coupling this with a select that listens for a shutdown signal, a timeout, or a “ready” notification gives you a safe, non‑blocking way to coordinate goroutines. The result is higher performance and easier fault handling.
Section 1 – Building a Buffered Pipeline
Consider a simple pipeline that reads integers, squares them, and writes the result to an output channel. We’ll compare unbuffered and buffered versions.
package main
import (
"fmt"
"sync"
"time"
)
// squareWorker reads from in, squares, writes to out.
func squareWorker(in, out chan int, wg *sync.WaitGroup) {
defer wg.Done()
for n := range in {
out <- n * n
}
}
func main() {
const items = 100000
in := make(chan int) // unbuffered
out := make(chan int, 1000) // buffered
var wg sync.WaitGroup
wg.Add(1)
go squareWorker(in, out, &wg)
start := time.Now()
for i := 0; i < items; i++ {
in <- i
}
close(in)
wg.Wait()
close(out)
// Consume results (in real code you might process them further)
for range out {
}
fmt.Println("elapsed", time.Since(start))
}
Running this with go run shows that the buffered out channel reduces the number of context switches. If you change in to make(chan int, 1000) you’ll see a measurable speedup in the benchmark. The buffer lets the producer send up to 1000 values without waiting for the consumer, smoothing out spikes in processing time.
Section 2 – Multiplexing with select
When you need to shut down cleanly or add timeouts, select lets you listen on multiple channels simultaneously. Below we add a quit channel that signals the worker to stop early, and a timeout that aborts if processing takes too long.
func squareWorker(in, out chan int, quit chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case n, ok := <-in:
if !ok { // input closed
return
}
out <- n * n
case <-quit:
// Graceful shutdown requested
return
case <-time.After(5 * time.Second):
// Timeout – pretend we’re stuck and give up
fmt.Println("worker timed out")
return
}
}
}
In production you’d wire quit to a context cancel or a SIGINT handler. The select guarantees that the goroutine doesn’t block forever on the input channel; it can exit promptly when a shutdown is requested.
Section 3 – Choosing a Buffer Size
Buffer size is a trade‑off. A buffer that’s too small behaves like an unbuffered channel, offering little performance benefit. A buffer that’s too large can:
- Wastage of memory – especially if the channel holds large structs.
- Hide back‑pressure bugs – the producer may continue at full speed while the consumer lags, leading to unbounded memory growth.
- Make debugging harder – errors that surface under load may be masked during development.
A common rule of thumb is to size the buffer to the expected burst rate of producers or the latency tolerance of consumers. For example, if a consumer processes items in 10ms and producers can emit 100 items per second, a buffer of 50–100 is reasonable.
Section 4 – Verifying Correctness & Avoiding Leaks
After closing a channel you should drain it or close it properly to avoid goroutine leaks. A quick sanity check is:
fmt.Println("goroutines after close:", runtime.NumGoroutine())
If the number remains higher than expected, you likely have a leaked goroutine still waiting on a channel. Ensure that every goroutine exits when its input channel is closed or when a quit signal is received.
Take‑away Checklist
- Use a small, well‑justified buffer to improve throughput without excessive memory use.
- Wrap channel operations in
selectto handle shutdown, timeouts, and non‑blocking coordination. - Benchmark unbuffered vs. buffered to quantify the benefit for your workload.
- Verify goroutine counts after channel closure to catch leaks early.
- Document the chosen buffer size and its rationale so future maintainers understand the trade‑off.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.