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.

The hchan struct: qcount 2, dataqsiz 3, closed 0, lock; buf, a ring of 3 slots holding 10 and 20 with recvx at slot 0 and sendx at slot 2; recvq and sendq, each a FIFO list of sudogs. A sudog points to its goroutine g and to elem, the value on that goroutine's stack
A channel is one struct: a ring buffer with two indexes, a lock, and two lists of parked goroutines (sudogs).

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

fieldwhat it holds
dataqsizthe buffer size from make; 0 for an unbuffered channel
buf, qcountthe ring of dataqsiz slots and how many hold a value
sendx, recvxthe next slot to write and the next slot to read; both wrap to 0
recvq, sendqFIFO lists of sudogs: goroutines parked in a receive or a send
closed0, or 1 after close
locka 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.

Two columns with time running down. Left, sender first: G1 runs ch <- 10, finds recvq empty, parks its sudog on sendq; later G4 receives, recvDirect copies 10 from G1's stack to G4, goready G1. Right, receiver first: G4 parks a sudog on recvq; G1 sends, sendDirect copies 10 into G4's v, goready G4, and G1 never waits
On an unbuffered channel whoever arrives first parks; the second one copies the value directly and wakes the first.

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).

operationopen channelclosed channel
ch <- vhand off, buffer, or parkpanic: send on closed channel
v, ok := <-chtake a value, or parkvalues left in buf first, then 0, false at once
close(ch)wake every waiterpanic: 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:

  1. 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.
  2. Otherwise enqueue one sudog per case, with isSelect = true, on each channel's queue, and park (Demo: select parks on two channels).
  3. When one channel wakes the goroutine, lock all again and dequeue the sudogs from the other channels.
G6 runs select on ch and done. It has two sudogs, one on ch.recvq and one on done.recvq. G1 sends 10 on ch: it wins G6's selectDone flag and hands 10 over; the sudog on done.recvq is now stale and pass 3 removes it
A parked select has a sudog on every channel; the first waker wins selectDone and the others are removed.

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.

On the mind map

This page on the Concurrency mind map · 28 pages in 7 branches · open it to see the whole map and test yourself in recall mode.