Channels
A channel is a typed conduit for moving values between goroutines. Underneath it's a small
struct in runtime/chan.go with a circular buffer, two queues of waiting
goroutines, and a mutex. Most of the surprising behaviour around close, nil, and select
falls out cleanly once you've read the struct.
The hchan struct
Every channel is one of these:
// runtime/chan.go (simplified)
type hchan struct {
qcount uint // items currently in the buffer
dataqsiz uint // size of the buffer (cap(ch))
buf unsafe.Pointer // pointer to the buffer
elemsize uint16
closed uint32
elemtype *_type
sendx uint // send index into buf (circular)
recvx uint // receive index into buf (circular)
recvq waitq // list of receive-waiters (sudog)
sendq waitq // list of send-waiters (sudog)
lock mutex
}A sudog is a small struct representing a parked goroutine plus the value
it's trying to send or the slot it's trying to receive into. The two waitqs are
doubly-linked lists of those.
Sending — three paths
A send to a channel takes one of three paths after acquiring the lock:
- Direct hand-off. If
recvqis non-empty, dequeue a waiting receiver and copy the value directly into its destination. Wake the receiver. No buffer involved. Unbuffered channels always take this path; buffered channels take it when there's a hungry consumer. - Buffered. If
recvqis empty but the buffer has room, copy the value intobuf[sendx]and incrementsendxandqcount. No goroutine swaps. Send returns immediately. - Park. If
recvqis empty and the buffer is full, enqueue this goroutine onsendqand callgopark. When a later receive dequeues us, we wake up with the value already delivered.
Receiving is symmetric: hand-off if a sender is waiting, take from buffer if any, park otherwise. The "direct hand-off" case is what makes unbuffered channels feel like synchronisation primitives — they do swap goroutines on the spot.
Close, nil, and the surprising rules
Three operations have non-obvious semantics. They make sense once you remember they follow from the struct:
| Operation | Behaviour |
|---|---|
| Receive on closed channel | Returns zero value, ok=false. Drains buffer first. |
| Send on closed channel | Panics. There is no recovery. |
| Close already-closed channel | Panics. |
| Send on nil channel | Blocks forever. |
| Receive on nil channel | Blocks forever. |
| Close nil channel | Panics. |
The "blocks forever on nil" rule is occasionally useful. If you're in a
select that watches multiple channels and you want to disable a branch
temporarily, set the channel variable to nil. That branch will never be
selected, but the others keep working.
select — what the compiler emits
A select with N cases compiles to roughly:
- Lock all involved channels in a stable order (to avoid deadlocks).
- Walk the cases in randomised order. If any case is immediately ready (a receiver waiting, a buffered slot, etc.), execute that case and unlock everything.
- Otherwise, enqueue this goroutine on every channel's sendq or recvq and park.
- When some channel wakes us, find the case that fired, dequeue ourselves from all the others, execute the case, and unlock.
The randomised order in step 2 is what makes select fair — if two cases
are ready, neither has a structural advantage over the other.
Buffered vs unbuffered, in practice
Three rules of thumb that hold up in real code:
- Unbuffered when you want a handshake. Sender blocks until receiver is ready, and vice versa. Use this when the value is the synchronisation — done signals, request/response between two goroutines.
- Small buffer (1) for "fire and forget" if you're willing to drop.
Common pattern: a metrics channel with capacity 1 that a worker pulls from. If the
worker falls behind, sends become non-blocking via a
selectwithdefault:, and the metric is dropped instead of stalling production code. - Larger buffers for batching, not for "more parallelism". A buffer of 100 doesn't make 100 parallel workers; it just decouples the sender from the receiver by 100 items. If you need parallelism, spawn 100 goroutines that all read from the same unbuffered channel.
Common mistakes
- Closing a channel from the receiver side. Producer panics on the next send. The convention "sender closes" is load-bearing.
- Forgetting to read all values from a closed channel before exit. If you spawn a goroutine that ranges over a channel and then exit early, you may leak the producer waiting for someone to consume.
- Treating channels as queues for high-throughput work. The lock becomes a bottleneck above a few million ops/sec per channel. For high-throughput message passing, look at sharding or lock-free queues.
- Deadlocks from
selectwith nodefault:when every channel might be unready. The runtime's deadlock detector catches the all-goroutines-blocked case, but a partial deadlock (specific goroutines blocked while the rest of the program runs) doesn't trigger it.
Performance, roughly
| Operation | Cost on a recent x86 |
|---|---|
| Send/recv to a buffered channel, no contention | ~30–60 ns |
| Send/recv with hand-off (one goroutine wakes) | ~150–300 ns |
| Send/recv that requires parking and unparking | ~300–600 ns plus context-switch effects |
| select over 4 channels, ready immediately | ~100–200 ns |
| select with all branches blocking, then waking | ~600 ns + scheduler |
These are the right order of magnitude for choosing whether channels can carry your
workload. For raw throughput, run go test -bench on your hardware — the
numbers move with each Go release.
Further reading
- runtime/chan.go — the implementation. About 800 lines, well-commented, worth reading in full.
- research!rsc — The Go memory model — Russ Cox's three-part series. Channels are the easiest way to satisfy the happens-before relation Go's memory model is built on.
- Cox — Go from a kernel-engineer's perspective — motivation for the M/P/G + channels design.
- Hoare — Communicating Sequential Processes — the 1978 paper Go's channels are descended from.