15.2 Buffered Channels: Asynchronous with Capacity
Right, let’s talk about buffered channels. You’ve met their unbuffered cousins, which are basically high-stakes handoffs: I wait for you, you wait for me. A buffered channel is different. It’s more like a mail slot or a queue at a post office. You can put a certain number of letters in the slot before you have to wait for someone to come and take one out. This introduces a layer of asynchrony into our communication, which is both incredibly useful and a fantastic way to shoot yourself in the foot if you’re not careful.
Think of a buffered channel as having an internal queue of a fixed size. You create one by giving the make function a capacity—the number of elements it can hold before the sender blocks.
// A channel that can hold up to 3 string values before it blocks a sender.
messageQueue := make(chan string, 3)
Now, the sending and receiving behavior changes in a crucial way:
- Sending (
ch <- value): Only blocks if the buffer is full. If there’s an empty slot, the send completes immediately and the value is placed into the buffer. - Receiving (
<-ch): Only blocks if the buffer is empty. If there’s a value in the buffer, the receive completes immediately and gets that value.
This decouples the sender and receiver slightly. They don’t need to be perfectly synchronized at the exact moment of handoff, as long as the buffer has space or items.
How Send and Receive Order Works
This is critical to understand. The queue inside a buffered channel is FIFO (First-In, First-Out). The first value you send into the channel will be the first value received from it. The semantics are identical to an unbuffered channel; we’ve just added a waiting room for values. This predictable order is what makes them useful for task queues and producer-consumer patterns.
Let’s see it in action. Here, the main goroutine is a fast producer, and we’ll spin up a slow consumer.
func main() {
// Create a buffered channel with a capacity of 2.
queue := make(chan int, 2)
var wg sync.WaitGroup
// Slow Consumer Goroutine
wg.Add(1)
go func() {
defer wg.Done()
for task := range queue { // Range over the channel until it's closed.
fmt.Printf("Consumer: Processing task %d\n", task)
time.Sleep(2 * time.Second) // Simulate slow work.
fmt.Printf("Consumer: Finished task %d\n", task)
}
}()
// Fast Producer (the main goroutine in this case)
for i := 1; i <= 5; i++ {
fmt.Printf("Producer: Sending task %d\n", i)
queue <- i // This will only block once the buffer fills up (after sends 3 and 4).
fmt.Printf("Producer: Sent task %d\n", i)
}
close(queue) // We're done sending tasks.
wg.Wait() // Wait for the consumer to finish processing everything.
}
Run this. You’ll see the producer blaze through sending the first two tasks (1 and 2) because the buffer has space. It sends 3 and blocks only when the buffer is full (after 1 and 2 are already in there). The consumer slowly works through the queue in order. The producer only gets to send 4 and 5 as space frees up in the buffer. The buffer acts as a shock absorber, allowing the producer to get ahead without immediately blocking.
The Pit of Despair: Common Mistakes
This is where the “brilliant friend” part kicks in: I’ve made these mistakes so you don’t have to.
1. Thinking it’s a queue for goroutines. It’s not. It’s a queue for values. A common anti-pattern is to create a buffered channel with a capacity of, say, 100 and then spawn 100 worker goroutines that all receive from it, thinking you’ve “limited” your workers. You haven’t. You’ve just created a queue for 100 jobs. The number of goroutines is unrelated. To limit workers, you use a semaphore (a buffered channel of struct{} to limit concurrency) or a worker pool pattern.
2. Deadlock by underestimating capacity. Imagine a producer that needs to send 10 items but the buffer only has a capacity of 5. If there’s no consumer goroutine running to drain the channel, the producer will send 5 items, and on the 6th send, it will block forever. The program deadlocks. The buffer isn’t magic; it just delays the inevitable blocking. Always ensure there’s something on the other end of the channel.
3. The non-blocking select… a partial lie. You’ll often see this pattern to do a non-blocking send:
select {
case bufferedCh <- value:
fmt.Println("Sent!")
default:
fmt.Println("Buffer was full. Didn't send.")
}
This works, but understand what it’s telling you: “The buffer was full at that exact nanosecond.” It’s a snapshot, not a guarantee. By the time your code gets to the next line, the state may have changed completely. Use this for opportunistic sends or load shedding, not for core logic.
When to Reach for a Buffered Channel
Use them deliberately, not by default. Their primary job is to decouple the timing of goroutines.
- Performance Smoothing: When you have a bursty producer and a steady consumer (or vice-versa), a buffer can absorb the burst and prevent immediate blocking, increasing overall throughput. This is the post office queue.
- Semaphores: A buffered channel of
struct{}with capacitynis a classic way to model a semaphore to limit concurrent access to a resource. You “take” a slot (send), use the resource, and “return” it (receive). - Staging Areas: When you need to batch requests or collect a certain number of results before processing them.
The key takeaway? A buffered channel makes your program slightly more asynchronous and slightly more complex. It solves timing problems, not logic problems. Choose the capacity based on the semantics of your program—how far ahead you realistically need one part to get from another—not just a random number you think looks good. And for the love of all that is holy, always have a plan for what happens when the buffer is full.