A buffered channel is a bounded queue¶
Level: 101 · anyone choosing the n in make(chan T, n)
One line: make(chan T, n) is a first-in, first-out queue with room for n values: sends succeed without any receiver until it is full, and then block until a receiver makes room — so a producer can never get more than n values ahead of its consumer, and that block is backpressure.
The program uses a channel with cap 3 twice. First main fills it and empties it again on its own, with no other goroutine there to receive. Then a producer tries to send seven jobs as fast as it can, and main takes a job only when the goroutine dump shows that the producer is stuck.
Verified output of a_buffered_channel_is_a_bounded_queue_go.go — regenerated by tools/run_examples.py, never hand-typed.
one goroutine, no receiver:
made len 0 cap 3
sent job-1 len 1 cap 3
sent job-2 len 2 cap 3
sent job-3 len 3 cap 3
took job-1 len 2 cap 3
took job-2 len 1 cap 3
took job-3 len 0 cap 3
a producer with 7 jobs, and main taking one only when the producer is stuck:
producer parked in [chan send] after 3 sends, len 3
main took job-1
producer parked in [chan send] after 4 sends, len 3
main took job-2
producer parked in [chan send] after 5 sends, len 3
main took job-3
producer parked in [chan send] after 6 sends, len 3
main took job-4
main took job-5
main took job-6
main took job-7
producer closed the queue after 7 sends
Reading the output¶
The sends went through with no receiver. In the first part only one goroutine exists, so nothing could have received, and yet all three sends returned, with len counting each value in. len is the number of values queued in the buffer and cap is its size (spec ↗). The same send on a channel without a buffer would never have returned — An unbuffered send waits for a receiver.
The values came out in the order they went in. job-1, job-2, job-3: the spec ↗ says that channels act as first-in-first-out queues.
The producer stopped three ahead. Each time the runtime showed the producer parked in [chan send], it had made exactly three more sends than main had taken, and len was 3; the send it was parked in was the fourth. Each job main took let exactly one more send through, and then the producer was stuck again. However fast it could have run by itself, it ran at main's pace. That is backpressure, and it is the spec's rule for a buffered channel: a send succeeds without blocking only if the buffer is not full.
The end. Once main had taken job-4, the last three jobs fitted in the buffer, so the producer finished without being stopped again, closed the queue, and main's range drained what was left — Closing a channel ends a range.
Why len is exact here. main reads len(queue) only while the producer is parked, when nothing can change it. Between two goroutines that are both running, a len can be out of date by the time the caller looks at it.
a_buffered_channel_is_a_bounded_queue_go.go
a_buffered_channel_is_a_bounded_queue_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// make(chan T, n) is a queue with room for n values. A send succeeds without a
// receiver until the buffer is full, and then blocks until a receiver makes
// room. That block is backpressure: a producer can get at most n values ahead
// of whoever is receiving.
//
// go build a_buffered_channel_is_a_bounded_queue_go.go && ./a_buffered_channel_is_a_bounded_queue_go
package main
import (
"fmt"
"runtime"
"strings"
"sync/atomic"
"time"
)
func main() {
fmt.Println("one goroutine, no receiver:")
jobs := make(chan string, 3)
fmt.Printf(" made len %d cap %d\n", len(jobs), cap(jobs))
for i := 1; i <= 3; i++ {
jobs <- fmt.Sprintf("job-%d", i)
fmt.Printf(" sent job-%d len %d cap %d\n", i, len(jobs), cap(jobs))
}
for range 3 {
job := <-jobs
fmt.Printf(" took %s len %d cap %d\n", job, len(jobs), cap(jobs))
}
const total = 7
fmt.Printf("\na producer with %d jobs, and main taking one only when the producer is stuck:\n", total)
queue := make(chan string, 3)
var sent atomic.Int64
go produce(queue, total, &sent)
for taken := 0; taken+cap(queue) < total; taken++ {
state := waitingOnChannel("main.produce")
fmt.Printf(" producer parked in [%s] after %d sends, len %d\n", state, sent.Load(), len(queue))
fmt.Printf(" main took %s\n", <-queue)
}
for job := range queue {
fmt.Printf(" main took %s\n", job)
}
fmt.Printf(" producer closed the queue after %d sends\n", sent.Load())
}
// produce sends total jobs as fast as the queue lets it, then closes the queue.
func produce(queue chan<- string, total int, sent *atomic.Int64) {
for i := 1; i <= total; i++ {
queue <- fmt.Sprintf("job-%d", i)
sent.Add(1)
}
close(queue)
}
// waitingOnChannel returns the runtime's own word for why the goroutine running
// fn is parked on a channel: "chan send" or "chan receive". It reads the same
// goroutine dump that a crash prints, and polls until that goroutine is parked
// on a channel; the sleep between polls only keeps the loop from spinning.
func waitingOnChannel(fn string) string {
buf := make([]byte, 1<<16)
for {
n := runtime.Stack(buf, true)
for _, g := range strings.Split(string(buf[:n]), "\n\n") {
header, frames, _ := strings.Cut(g, "\n")
if !strings.Contains("\n"+frames, "\n"+fn+"(") {
continue
}
_, state, _ := strings.Cut(header, "[") // "goroutine 7 [chan send]:"
state, _, _ = strings.Cut(state, "]")
state, _, _ = strings.Cut(state, ",") // a long wait reads "chan send, 2 minutes"
if strings.HasPrefix(state, "chan ") {
return state
}
}
time.Sleep(time.Millisecond)
}
}
What to do¶
- Choose the capacity for a reason you can say: how far a producer may run ahead, a burst the consumer should absorb, or how many goroutines may do something at once — A buffered channel as a semaphore. "Large enough that it never blocks" is not a reason; it only postpones the block to the day the buffer fills.
- Let the block do its job. The job queue in front of a worker pool is a buffered channel, and a producer that waits while every worker is busy is the pool working as designed.
- When a producer must not wait, decide what happens instead — drop the value, or give up after a deadline — and write it with a
select: A timeout is a channel. - Do not use
len(ch)to decide whether the next send will block while another goroutine is using the channel; the answer can change before the send.
In other languages¶
- The Rust library's Channels ↗ — Rust's bounded channel is
mpsc::sync_channel(n)↗, whose sends block when its buffer is full, as here. Its plainmpsc::channel()↗ has no bound at all, so a fast producer's values pile up in memory instead of making it wait. - The Concurrency library's chapter 05, Message passing, which will put bounded queues to all of its languages, is planned.
Sources¶
- The Go specification: Channel types ↗ and Length and capacity ↗.
- Effective Go, "Channels" ↗ — with a buffer, the sender blocks only until its value has been copied in, or, when the buffer is full, until a receiver has taken one; and a buffered channel used as a semaphore.
- A Tour of Go, "Buffered Channels" ↗.
runtime.Stack↗, which the program uses to read the goroutine dump.- James Cutajar, Learn Concurrent Programming with Go (Manning, 2024), chapter 7, "Communication using message passing" — the section on buffering messages with channels.
- Jon Bodner, Learning Go, 2nd ed. (O'Reilly, 2024), chapter 12, "Concurrency in Go" — "Know When to Use Buffered and Unbuffered Channels".