Channel Internals: Buffered vs Unbuffered
Channels are typed queues with blocking semantics coordinated by the runtime. An unbuffered channel forces a rendezvous between sender and receiver; a buffered channel stores values in a fixed ring until capacity fills. Close changes send/receive rules and is the idiomatic broadcast signal.
Summary
A channel variable is a descriptor to an hchan structure holding a buffer, send/receive queues of goroutines, and lock-based synchronization. Understanding blocking edges prevents deadlocks, leaks, and misuse of close.
Recipe
Quick-reference recipe card - copy-paste ready.
ch := make(chan int) // unbuffered - sync handoff
buf := make(chan int, 8) // buffered - up to 8 sends without receiver
buf <- 1
v := <-buf
close(buf)
v, ok := <-buf // ok false after buffer drainedWhen to reach for this:
- Signaling between goroutines with happens-before guarantees
- Worker pools with bounded backlog (buffered)
- Pipeline stages handing off ownership of pointers
- Shutdown broadcast after
close(receivers exit loops)
Working Example
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int, 2)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch <- 1
ch <- 2
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
wg.Wait()
}What this demonstrates:
- Buffered channel accepts two sends without a receiver waiting
closeallowsrangeto drain remaining values then exit- Only the sender should
closein most designs
Deep Dive
How It Works
- hchan structure: Buffer ring,
qcount, send/receive wait queues (sudog linked lists), lock, and element type info. - Unbuffered send: Blocks until a receiver executes receive; values copy directly goroutine to goroutine (handoff).
- Buffered send: Copies into buffer if space; blocks when buffer full until receive frees slot.
- Receive: Blocks when empty (unless closed); on closed empty channel returns zero value and
ok == false.
Blocking Rules
| Operation | nil chan | open, empty | open, has data | closed, drained |
|---|---|---|---|---|
| receive | block forever | block | value, ok true | zero, ok false |
| send | block forever | unbuf: block until recv; buf: maybe block | send if buf space | panic |
Close Semantics
- close(ch): No more sends; receivers get remaining buffered values; future receives get zero with
ok == false. - Multiple receiveers: All blocked receivers wake; one wins each value.
- Select with default: Non-blocking operations use
defaultcase when channel not ready.
Go Notes
// Ownership: sender closes when done producing
go func() {
defer close(out)
for item := range work {
out <- process(item)
}
}()
// Do not close from receiver side unless protocol says so
// Range exits when channel closed and drainedGotchas
- Close by receiver - Closing from the wrong goroutine races with sends. Fix: document producer-closes; use
sync.Onceonly when pattern is clear. - Send on closed channel - Panics immediately. Fix: guard with
select+ctx.Done()or stop sending before close. - Double close - Panics. Fix: single owner goroutine closes; others signal via context.
- Buffered size 0 vs unbuffered -
make(chan T, 0)is unbuffered; still different from forgetting capacity intent. Fix: omit capacity for sync; set explicit buffer for throughput. - Leaked goroutine blocked on send - No receiver and no cancel path. Fix: combine with
contextcancellation andselect. - Using channel length for synchronization -
len(ch)is a snapshot, not a queue depth contract. Fix: track counts explicitly if protocol requires.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
sync.Mutex + cond | Shared structured state | Handing off ownership of values |
errgroup + context | Task fan-out with cancel | Fine-grained item pipelines |
| Buffered ring buffer (custom) | Lock-free hot paths with experts | Normal business logic |
sync.WaitGroup | One-shot join | Streaming values between stages |
FAQs
What is the difference between buffered and unbuffered?
Unbuffered synchronizes at the moment of handoff. Buffered allows sends to proceed until capacity without a waiting receiver.
Who should close a channel?
Usually the sender/producer when no more values will be sent. Receivers detect completion via ok or range.
What happens receiving from a closed channel?
Drains buffered values first, then returns zero value with ok == false without blocking.
Why does send on closed channel panic?
It indicates a protocol bug - sending after close means producers disagree about lifecycle.
What does a nil channel do?
Send and receive block forever - useful in select to disable a case dynamically.
Is channel receive fair?
The runtime wakes waiting goroutines in FIFO order for the channel wait queues in typical cases, but do not depend on fairness for correctness.
Can I peek at channel length?
len returns queued elements; cap returns buffer capacity. Both are snapshots and race under concurrency.
Are channels copyable?
Channel variables are descriptors; copying the variable duplicates the handle to the same hchan.
Do channels provide happens-before?
Yes. A send happens before the corresponding receive completes. Close happens before receive that returns zero with ok false.
How big should buffer be?
Enough to absorb producer bursts without blocking; small enough to apply backpressure before unbounded memory growth.
Can I range over nil channel?
Blocks forever - same as receive on nil channel.
Are channels comparable?
Only to nil with ==. Comparing two non-nil channels is not allowed.
Related
- Concurrency Basics - goroutines and select
- Avoiding Goroutine Leaks - shutdown patterns
- Data Types Basics - channel creation intro
- Go's Memory Model - happens-before with channels
- Context Package - cancel alongside channel pipelines
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).