Channel and Select Internals
Channel and Select Internals
Overview
Channels are not magic pipes — they are runtime objects (hchan) with a lock, optional buffer ring, and wait queues of sudogs (goroutine wait records). Select is a careful algorithm over multiple channel operations so that exactly one case proceeds (or default).
Usage patterns: Channel patterns.
Diagram: Channel park / wake
sequence (top → bottom):
actors: sender G, hchan, receiver G
sender G --> hchan : chansend
hchan --> receiver G : hand off / enqueue buf
hchan --> sender G : return
sender G --> sender G : gopark sendq
receiver G --> hchan : chanrecv
hchan --> sender G : goready
note: alt buffer space or waiting recv
note: else must wait
Channel Structure (Conceptual)
hchan
qcount // elements in buffer
dataqsiz // buffer capacity (0 = unbuffered)
buf // circular queue
sendx/recvx // indices
recvq // waiters for receive
sendq // waiters for send
lock
closed
Unbuffered (make(chan T))
Send and receive must rendezvous: sender copies directly into receiver (or vice versa). One side parks on a sudog queue until the other arrives.
Buffered (make(chan T, n))
Send succeeds without a receiver while qcount < n. Receive succeeds without a sender while qcount > 0. Still synchronized via the channel lock and happens-before rules.
Send / Receive Paths
| Situation | Behavior |
|---|---|
| Recv, buffer non-empty | Pop buffer; maybe wake a sender |
| Recv, buffer empty, sender waiting | Direct hand-off |
| Recv, nothing available | Park on recvq |
| Send, receiver waiting | Direct hand-off |
| Send, buffer space | Push buffer |
| Send, no space | Park on sendq |
| Op on closed | Recv: zero + ok=false; Send: panic |
v, ok := <-ch // ok false => closed and drained
close(ch) // idempotent close panics on second closeSelect Algorithm (Intuition)
For select with multiple cases:
- Randomize case order (fairness).
- Probe channels for a case that can proceed immediately.
- If none and there is
default, take default. - Otherwise, enqueue this G on all involved channels’ wait queues, then park.
- On wake, dequeue from other channels; complete the winning case only.
Implications:
- There is no priority among cases — do not assume left-to-right wins.
- A busy channel can starve another statistically; redesign if fairness is a product requirement.
selectwith one case ≠ quite the same codegen as bare op, but same semantics.
Close Semantics
close
|
+--> fail future sends (panic)
+--> wake all recvers (they get zero values)
+--> drained buffer still readable until empty
Only the sender side should close (by convention). Multiple closers require a single closer protocol.
Common Failure Modes
| Bug | Symptom |
|---|---|
| Send on closed | Panic |
| Never closed, receivers blocked | Goroutine leak |
| Unbuffered send without recv | Deadlock |
| Select without default in tight loop | 100% wake churn if misused with timers |
| Timer.C not drained after Stop (older patterns) | Rare wake bugs — prefer time.After carefully in loops |
Cost Model
- Channel ops take a runtime lock and may park the G — fine for coordination, expensive as a per-byte I/O bus.
- For huge fan-in of tiny messages, batch or use a shared structure under a mutex (measure).
Experiment
go mod init example
go run .package main
import (
"fmt"
"time"
)
func main() {
// Rendezvous
unbuf := make(chan string)
go func() {
unbuf <- "ping"
}()
fmt.Println("unbuf", <-unbuf)
// Buffer absorbs one send
buf := make(chan int, 1)
buf <- 1
fmt.Println("buf", <-buf)
// Select fairness demo: both cases ready each iteration
counts := map[string]int{}
for i := 0; i < 1000; i++ {
a := make(chan int, 1)
b := make(chan int, 1)
a <- 1
b <- 2
select {
case <-a:
counts["a"]++
case <-b:
counts["b"]++
}
}
fmt.Println("select counts", counts)
// Close
ch := make(chan int, 2)
ch <- 7
close(ch)
v, ok := <-ch
fmt.Println("after close", v, ok)
v, ok = <-ch
fmt.Println("drained", v, ok)
// Default non-block
select {
case <-time.After(10 * time.Millisecond):
fmt.Println("timer")
default:
fmt.Println("default path")
}
}What to notice: Both a and b win some iterations — not always a first. Closed channel receives are safe; sends would panic.
Try next: Deliberately leak a goroutine blocked on send; find it with //go:debug tracebackancestors=1 or pprof goroutine profile.