Go Channels Under the Hood With the Real Source Code
How a Go channel is built: the hchan struct, the ring buffer, wait queues, direct hand-off to a waiting receiver, and what the compiler emits for select.

Channels look like magic in the language and are about eight hundred lines of ordinary Go in the runtime. Reading that code once changes how you use them, because every behaviour people memorize as a rule (unbuffered sends block until a receiver arrives, closing wakes everyone, a nil channel blocks forever, select picks at random) turns out to be a visible consequence of a simple data structure and a few lines of code. This article tours it with the actual source layout as of Go 1.25, and checks two of the claims with programs.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
It belongs to the concurrency patterns cluster, assumes channels and select, and leads into select fairness. The implementation is in src/runtime/chan.go, and I quote its structure rather than its text. Everything here ran on Go 1.25.6. Internals change between releases, so treat the layout as a description of the idea and not an API.
What make(chan T, n) builds
A channel value is a pointer to a runtime struct called hchan. Its fields tell you what a channel is:
qcount: how many elements are in the buffer now.dataqsiz: the buffer capacity, which isn.buf: a pointer to the ring buffer, an array ofdataqsizelements.elemsize: the size of one element.closed: a flag thatclosesets.sendxandrecvx: the next write and read positions in the ring.recvqandsendq: queues of goroutines waiting to receive or send, each entry asudogthat links a goroutine to the channel and to the element it wants to send or receive.lock: a mutex that protects all of the above.
For an unbuffered channel dataqsiz is zero and there is no buffer at all. For a buffered one the elements live in a contiguous ring, and sendx and recvx chase each other around it; that is the whole of FIFO order.
You can see the counters from user code with a mirror struct. The first five fields have stayed in this order for years, so a struct with the same layout lets us read them through unsafe. This is for inspection only and I would not ship it:
package main
import (
"fmt"
"unsafe"
)
// hchanHead mirrors the first fields of the runtime's hchan (Go 1.25, 64-bit).
type hchanHead struct {
qcount uint
dataqsiz uint
buf unsafe.Pointer
elemsize uint16
closed uint32
}
func peek(c chan int) hchanHead {
return *(*hchanHead)(*(*unsafe.Pointer)(unsafe.Pointer(&c)))
}
func main() {
c := make(chan int, 4)
c <- 1
c <- 2
c <- 3
h := peek(c)
fmt.Printf("qcount=%d dataqsiz=%d elemsize=%d closed=%d len=%d cap=%d\n",
h.qcount, h.dataqsiz, h.elemsize, h.closed, len(c), cap(c))
close(c)
fmt.Println("after close, closed =", peek(c).closed)
v, ok := <-c
fmt.Println("receive after close still drains the buffer:", v, ok)
u := make(chan string)
fmt.Printf("unbuffered: dataqsiz=%d elemsize=%d\n", peek2(u).dataqsiz, peek2(u).elemsize)
}
func peek2(c chan string) hchanHead {
return *(*hchanHead)(*(*unsafe.Pointer)(unsafe.Pointer(&c)))
}qcount=3 dataqsiz=4 elemsize=8 closed=0 len=3 cap=4
after close, closed = 1
receive after close still drains the buffer: 1 true
unbuffered: dataqsiz=0 elemsize=16len(c) is qcount and cap(c) is dataqsiz, which is all the built-in functions do. A closed channel just has its flag set, and buffered values stay readable, which is why the receive after close still returns the first buffered value with ok true. The unbuffered channel has zero capacity and carries the element size of the type it was made for (16 bytes for a string).
The send path
ch <- v compiles to a call to runtime.chansend1, which takes the channel lock and works through the cases in a fixed order:
- Nil channel. Block forever. This is why a nil channel in a
selectis never chosen, the trick in nil channels. - Closed channel. Panic with "send on closed channel". The check happens under the lock, so a close that races with a send either wins or loses cleanly.
- A receiver is waiting (
recvqnot empty). Dequeue it and copy the value directly into the receiver's stack slot, skipping the buffer. Mark the receiver runnable and return. This is the fast path for an unbuffered channel: if the receiver got there first, the sender never blocks, and the value moves exactly once. - Buffer has room (
qcount < dataqsiz). Copy the value into the ring atsendx, advancesendx, incrementqcount, return. - Otherwise block. Build a
sudogpointing at the value, enqueue it onsendq, and park the goroutine. A later receiver will find it, take the value, and wake the sender.
Receive is the mirror image with one twist. If the buffer is non-empty, the receiver takes the oldest element from the ring, and if a sender was parked on sendq it moves that sender's value into the slot just freed and wakes the sender. That keeps FIFO order and keeps the buffer full whenever there are waiting senders. On an empty buffer with a waiting sender (the unbuffered case) the receiver copies straight from the sender's stack.
The direct copy between stacks is why an unbuffered handoff is a few hundred nanoseconds with two goroutine switches, and not a pair of copies through a buffer. It is also the reason for the happens-before rules (happens-before): the channel lock is held across the transfer, which gives the send-to-receive ordering for free.
Close
close(ch) takes the lock, panics if the channel is nil or already closed, sets the flag, and then walks both wait queues. Every parked receiver is woken with a zero value and ok false; every parked sender is woken to panic. That is the code behind the rule that close is a broadcast, and it is why a for range ch loop in a hundred goroutines all end at once when one goroutine closes the channel (closing channels).
What the compiler emits
The language syntax maps to a small set of runtime entry points. Building a one-file program with -gcflags=-S prints the assembly, and filtering for runtime calls shows the mapping:
package main
func producer(c chan int) { c <- 1 }
func consumer(c chan int) int { return <-c }
func finish(c chan int) { close(c) }
func pick(a, b chan int) int {
select {
case v := <-a:
return v
case v := <-b:
return v
}
}
func try(c chan int) bool {
select {
case c <- 1:
return true
default:
return false
}
}
func main() {
c := make(chan int, 1)
producer(c)
println(consumer(c), try(c), pick(c, c))
finish(c)
}go build -gcflags=-S -o /dev/null . 2>&1 | grep -oE "CALL\s+runtime\.(makechan|chansend1|chanrecv1|chanrecv2|closechan|selectgo|selectnbsend|selectnbrecv)" | sort | uniq -c 2 CALL runtime.chanrecv1
2 CALL runtime.chansend1
2 CALL runtime.closechan
1 CALL runtime.makechan
2 CALL runtime.selectgo
2 CALL runtime.selectnbsendEach language feature is one runtime function (the counts are doubled because the small helpers were also inlined into main). Plain send, receive and close map to chansend1, chanrecv1 and closechan. A select with two or more cases and no default goes to selectgo. A select with a single case and a default is special-cased to selectnbsend or selectnbrecv, a non-blocking attempt that never parks; the compiler recognizes the common "try this send" shape and avoids the general machinery. The same applies to a one-case select without a default, which is rewritten into a plain send or receive.
Select
selectgo is the largest function in the file. It receives the list of cases, builds two orderings, and then does one of three things. First, it shuffles a poll order using a random permutation, so that when several cases are ready, the choice among them is uniform. Second, it computes a lock order, sorting the cases by channel address, and locks every channel in that order. A fixed global order for locks is how it avoids deadlock when two goroutines select on the same channels in opposite orders. Third, with everything locked it walks the poll order looking for a ready case; if one is found it performs it and unlocks. If none is ready, it enqueues a sudog on every channel's wait queue, parks, and when any one of them wakes it, removes the sudogs from the others. The cost of a blocking select is therefore proportional to the number of cases, with a lock acquisition per distinct channel, and that is what makes a select with a dozen cases measurably slower than one with two. Select fairness and what random means tests the uniformity claim.
What this explains
A few behaviours that read like folklore are consequences of the above.
An unbuffered channel is a rendezvous because with no buffer the sender can only complete by finding a waiting receiver or parking until one arrives. A buffered send to a channel with a waiting receiver does not touch the buffer at all. Receiving from a closed channel never blocks, since the check for closed precedes parking. A nil channel blocks because the code returns early and parks, with nothing to ever wake it. A goroutine blocked on a channel that nobody holds a reference to is not collected: the sudog links the channel to the goroutine and the goroutine's stack roots it, the mechanism behind every goroutine leak. And a channel is not free: there is a lock, a possible allocation and a context switch behind every hand-off, which the channels versus mutexes benchmark priced at around a hundred nanoseconds.
The source is readable in an afternoon. The chan.go file on cs.opensource.google is the primary source, the language specification on channel types states the behaviour the code implements.


