Go channels are smaller than they look
For years I used Go channels every day and treated them as something a bit magical. They're not.
A channel is one struct on the heap. It has a ring buffer, two lists of goroutines that are waiting, and a lock. That's the whole thing. Buffering, the famous "blocking," close, select. All of that is just rules layered over those three pieces.
The walk below frames it as scenes in a coffee shop. The barista is a goroutine putting things in. The customer is a goroutine taking them out. The channel is a shelf where the cups sit in between. Each scene has a tiny runnable program and the output it prints.
#1. A channel is a shelf
Start here: you write ch <- 5 and nobody's reading. Where does the 5 actually go?
Picture the shelf at a coffee shop where finished drinks wait for pickup. The shelf holds a fixed number of cups, that's the channel's capacity. The cups on it right now are the count. A barista putting a cup up is a send. A customer taking one is a receive. That's it. A channel is a shelf for things in transit between two people who don't have to be there at the same moment.
In the runtime that shelf is one struct, hchan, in runtime/chan.go:
type hchan struct {
qcount uint // values in the buffer now. len(ch) reads this.
dataqsiz uint // size of the buffer. cap(ch) reads this.
buf unsafe.Pointer // the ring buffer (the shelf)
sendx uint // where the next send writes
recvx uint // where the next receive reads
recvq waitq // receivers parked, waiting
sendq waitq // senders parked, waiting
lock mutex // one operation at a time
}
len(ch) and cap(ch) aren't computed. They read qcount and dataqsiz straight out of that struct. Which means you can watch the runtime's own fields move:
make(chan int, 3) -> cap = 3 (this is hchan.dataqsiz)
start len = 0 [ _ _ _ ]
send 10 -> len = 1 [ 10 _ _ ]
send 20 -> len = 2 [ 10 20 _ ]
send 30 -> len = 3 [ 10 20 30 ]
Three cups go on the shelf. The count climbs 0 to 3. The channel stopped being magic and turned into a struct you can count cups on.
#2. A send does one of three things
So you write ch <- v. What does Go actually do with the value?
Back to the barista. Cup in hand, here's what can happen:
- A customer is already standing at the counter. Hand the cup straight over. It never touches the shelf.
- No customer, but the shelf has room. Set it down and move on.
- No customer, and the shelf is full. Stand there holding the cup until someone takes one down.
That's the whole decision. In the runtime it's one function, chansend, with three exits checked in order:
if sg := c.recvq.dequeue(); sg != nil {
send(c, sg, ep, ...) // CASE 1: a receiver is waiting. Hand it over directly.
return true
}
if c.qcount < c.dataqsiz {
typedmemmove(...) // CASE 2: the buffer has room. Copy it in.
c.qcount++
return true
}
c.sendq.enqueue(mysg) // CASE 3: no room, no receiver.
gopark(...) // Park this goroutine until a receive frees a slot.
The order matters. A waiting receiver beats the buffer. Even when the shelf has space, a send goes straight to a parked receiver and never touches the buffer. Running it shows exactly that:
CASE 1, a receiver is already waiting:
send 7 -> len = 0 (value bypassed the buffer)
CASE 2, no receiver, the buffer has room:
send 7 -> len = 1 (value sits in the buffer)
CASE 3, no receiver, buffer full:
launched a sender for value 2 -> goroutines alive: 2 (+1, the parked sender)
the sender has NOT finished. It is parked, waiting for room.
receive once -> got 1
...the parked sender then completed -> goroutines alive: 1
That third case is the cliffhanger. A "blocked" send is a parked goroutine. Which raises a thing I had no earthly idea about when I started: where does a parked goroutine actually go?
#3. "Blocking" is just parking
The wrong mental model: a blocked goroutine is frozen, waiting for the channel to wake it up. Not how it works.
The right one: when a goroutine can't proceed, the runtime parks it. The thread the goroutine was running on doesn't wait around. It runs something else.
A barista who froze at a full shelf would shut the whole shop down. So they don't. They scribble the order onto a wait list, go help other customers, and pick that one back up when a slot opens. The barista (the thread) is never idle. The order (the goroutine) just sits on a note.
In runtime/proc.go, parking is two steps inside park_m:
casgstatus(gp, _Grunning, _Gwaiting) // mark this goroutine "waiting"
dropg() // the thread LETS GO of the goroutine
schedule() // the thread goes to run someone else
The thread hands the goroutine back and runs something else. A parked goroutine isn't burning a thread. It's a small object on a list.
To prove that's actually how it works, park a thousand goroutines on one channel with only one OS thread allowed:
GOMAXPROCS = 1 (at most one OS thread runs Go code at a time)
parked 1000 goroutines on one unbuffered channel
goroutines alive: 1001
...
drained all 1000 senders -> sum = 500500
Every parked goroutine woke and finished. On a single thread.
A thousand "blocked" goroutines, one thread, no deadlock. That is why a Go program can run a million goroutines. Parking lets the thread go do other work. And a goroutine's stack starts at about 2 KB and grows on demand, where an OS thread's is fixed at around 1 MB and stays held the whole time. Two reasons goroutines are cheap, not one.
#4. Closing a channel: last orders
Why can you keep receiving from a closed channel, but sending to one crashes your program?
close(ch) is the shop calling last orders. We're done, no new orders coming. Customers can still grab cups already on the shelf. Once it's empty, anyone who asks gets told "nothing left, and we're closed," instantly, no waiting. A barista who makes a new drink after last orders is making a mistake. So is calling last orders twice. And because it's a shop-wide announcement, everyone waiting hears it at once.
In the runtime, close flips a one-way flag and wakes every waiter:
if c.closed != 0 { panic("send on closed channel") } // sending: a bug
// receiving, when closed and empty:
if c.closed != 0 && c.qcount == 0 { return zero, false } // safe, no panic
Receiving is safe. You drain whatever was buffered, then get zeros forever. Sending is a bug, it panics. So does closing twice or closing a nil channel. Running it:
RECEIVE from a closed channel is SAFE:
<-ch -> value=10 ok=true
<-ch -> value=20 ok=true (buffered values survive the close)
<-ch -> value=0 ok=false (then zero, ok=false, forever)
SEND to a closed channel is a BUG:
ch <- 1 -> panic: send on closed channel
close(ch) -> panic: close of closed channel
close(nil) -> panic: close of nil channel
CLOSE is a broadcast:
5 goroutines parked, waiting to receive...
one close() woke all 5.
That's why the sender closes the channel, never the receiver, and only once. The broadcast part is also exactly how for v := range ch knows when to stop.
#5. An unbuffered channel is a rendezvous
A buffered channel is a shelf. So what is an unbuffered one, make(chan T) with no size?
No shelf at all. The barista hands the cup directly into the customer's hands. If the customer isn't there yet, the barista stands holding it. If the customer's there first, they wait. The cup only moves when both are present. It never sits anywhere in between.
In the runtime, with no buffer (dataqsiz == 0), the "buffer has room?" check is never true. So a send either hands off to a waiting receiver, or it parks. When they actually meet, the value gets copied straight from one goroutine's stack to the other's. The runtime comment says it plainly:
// Sends and receives on unbuffered channels are the only operations where one
// running goroutine writes to the stack of another running goroutine.
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
dst := sg.elem // a slot on the OTHER goroutine's stack
memmove(dst, src, t.Size_) // copy straight across. No buffer.
}
An unbuffered channel is a meeting, not a container. The send blocks until a receiver shows up:
make(chan int) -> cap = 0, len = 0 (no shelf at all)
[ 0ms] sender: ch <- 7 (blocks. No receiver yet.)
[100ms] sender: send returned. The value is now in the receiver.
[100ms] receiver arrives and takes 7
after the rendezvous, data = 42 (guaranteed visible, no lock needed)
The ~100ms gap is the send sitting there waiting for the meeting to happen. And because both sides do meet, the hand-off doubles as a synchronization point. Anything the sender did before the send is guaranteed visible to the receiver after. Why that's guaranteed is the Go memory model, which is the next post.
#What's actually in the runtime
That's the whole thing. Five rules over three pieces of state. If you sit down with runtime/chan.go and a coffee it reads in about 300 lines, with most of the lines being safety checks and race-detector hooks. The actual decisions are exactly the ones above.
I've got a go-internals lab repo with the runnable programs for each of these scenes, plus the tests that prove the claims and walking guides into the source. I'll link it once I've got it cleaned up enough to show.
Next post: the memory model, which is where that "guaranteed visible" line at the end of scene 5 actually comes from. The full spec is still settling in my head, but the first half is starting to make sense.