03 / 10
Internals / 03

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:

  1. Direct hand-off. If recvq is 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.
  2. Buffered. If recvq is empty but the buffer has room, copy the value into buf[sendx] and increment sendx and qcount. No goroutine swaps. Send returns immediately.
  3. Park. If recvq is empty and the buffer is full, enqueue this goroutine on sendq and call gopark. 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:

OperationBehaviour
Receive on closed channelReturns zero value, ok=false. Drains buffer first.
Send on closed channelPanics. There is no recovery.
Close already-closed channelPanics.
Send on nil channelBlocks forever.
Receive on nil channelBlocks forever.
Close nil channelPanics.

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.

The conventional rule for close. The sender closes; never the receiver. A channel with multiple senders is harder — you need a coordinator (often another channel) to decide who calls close. Closing twice panics, so "everyone closes" isn't an option.

select — what the compiler emits

A select with N cases compiles to roughly:

  1. Lock all involved channels in a stable order (to avoid deadlocks).
  2. 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.
  3. Otherwise, enqueue this goroutine on every channel's sendq or recvq and park.
  4. 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 select with default:, 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 select with no default: 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

OperationCost 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

Found this useful?