The idea: a channel is a locked queue with two waiting lists
In Go, goroutines share data by sending it over a channel: ch <- v sends, v := <-ch receives. Behind the syntax, make(chan int, 3) allocates a runtime.hchan in the heap. It holds a ring buffer buf, one lock, and two queues of goroutines that are waiting: sendq and recvq. Every send and receive is a call into the runtime (chansend, chanrecv) that takes the lock, looks at these fields and either finishes at once or parks the goroutine.
On the canvas, the senders G1, G2 and G3 are on the left, the receivers G4 and G5 and a goroutine G6 that runs a select are on the right, and the channels ch and done are in the middle. Send lets the next free sender run ch <- v (with v = 10, 20, 30 …), Receive lets the next free receiver run v, ok := <-ch, and Close runs close(ch). Pick the buffer size under Options.
The fields of hchan
| field | what it holds |
|---|---|
dataqsiz | the buffer size from make; 0 for an unbuffered channel |
buf, qcount | the ring of dataqsiz slots and how many hold a value |
sendx, recvx | the next slot to write and the next slot to read; both wrap to 0 |
recvq, sendq | FIFO lists of sudogs: goroutines parked in a receive or a send |
closed | 0, or 1 after close |
lock | a runtime mutex; every operation holds it while it reads or changes the fields |
A sudog ("pseudo-g") stands for one goroutine waiting on one channel. It holds the goroutine g and elem, a pointer to the value: for a sender the value it wants to send, for a receiver the variable it receives into. Both live on that goroutine's own stack.
Unbuffered: a send waits for a receiver
With make(chan int) there is no buffer at all, so a send and a receive must meet. Whoever comes first parks: chansend finds recvq empty, puts a sudog for G1 on sendq and calls gopark, and G1 becomes _Gwaiting (Demo: unbuffered, send waits for a receiver). When G4 receives, chanrecv dequeues G1's sudog, copies the value from G1's stack straight into G4's variable (recvDirect) and calls goready(G1).
It works the same the other way round (Demo: unbuffered, receiver first): G4 parks on recvq, and G1's send finds it there and writes into G4's v with sendDirect. This is the direct handoff: the value goes from one stack to another in a single copy, and the buffer is never used. It is also why an unbuffered channel synchronizes: when the send returns, the receiver has the value.
Buffered: the ring, sendx and recvx
With make(chan int, 3) a send only parks when the buffer is full. If nobody waits on recvq and qcount < dataqsiz, the value is copied into buf[sendx], sendx moves on and wraps to 0 after the last slot, and qcount grows. A receive takes buf[recvx], clears the slot and moves recvx (Demo: buffered ring). Because recvx always follows sendx around the ring, values come out in the order they went in.
When the buffer is full and a sender is parked, a receive does two copies (Demo: full buffer): it takes the oldest value buf[recvx], then copies the parked sender's value into the slot it just emptied, and moves both indexes (sendx = recvx). The buffer stays full and FIFO order holds. A sender that finds a parked receiver skips the buffer, as on an unbuffered channel; that can only happen when the buffer is empty.
close
close(ch) sets closed = 1 and releases every sudog on both queues (Demo: close wakes every parked receiver). Parked receivers get the zero value and ok = false. Parked senders wake with success = false and panic. Values already in buf are not lost: receivers drain them first (Demo: close with values in buf).
| operation | open channel | closed channel |
|---|---|---|
ch <- v | hand off, buffer, or park | panic: send on closed channel |
v, ok := <-ch | take a value, or park | values left in buf first, then 0, false at once |
close(ch) | wake every waiter | panic: close of closed channel |
That is why the rule is that the sender closes a channel, and only once: a receiver cannot know whether another send is still coming. A nil channel (never made) blocks a send or a receive forever and panics on close.
select
A select waits on several channels at once. runtime.selectgo works in three passes:
- Lock all the channels, in address order (
lockorder) so two selects never deadlock, then check the cases in a random order (pollorder). If one can go now, do it and return. - Otherwise enqueue one sudog per case, with
isSelect = true, on each channel's queue, and park (Demo: select parks on two channels). - When one channel wakes the goroutine, lock all again and dequeue the sudogs from the other channels.
Two channels could try to wake the same goroutine at once. The waker must first flip the goroutine's selectDone flag from 0 to 1; a waker that loses skips that sudog. A select with a default case never parks: the compiler turns select { case ch <- v: … default: … } into selectnbsend, a chansend that returns false instead of blocking (Demo: select, close(done) wins, then select default). The page always checks ch before done; Go picks among ready cases at random so that no case starves.
Channels and the classic tools
A buffered channel is the bounded buffer of the producer–consumer problem, with the mutex and both waiting lists built in; it is the same thing a monitor with condition variables gives you, except that the runtime never wakes a goroutine for nothing: it hands the value to the waiter before waking it. Parking and waking goroutines is the job of the Go scheduler (goready puts the goroutine in its P's runnext), and what a goroutine and its stack are is shown on Inside a Goroutine.
What the page leaves out
The lock-free fast paths (a non-blocking receive on an empty open channel returns without locking); elemsize, element types and the write barriers when the value holds pointers; nil channels; the random poll order of select and selects with send cases; the race detector's and synctest's hooks; timers and time.After; and stack copying, which must adjust sudog pointers into a parked goroutine's stack.