10.2 hchan: The Internal Structure of a Channel
In the runtime, a channel is an hchan structure, and the channel value returned by
make(chan T, n) is just a pointer to it. This is consistent with make in general: the values
make returns carry an implicit pointer inside [Cox, 2009]. Channel values can
therefore be copied and passed around freely, and all copies point to the same hchan. This section
starts with the invariants stated at the top of runtime/chan.go, then covers the fields of
hchan, the ring buffer, the wait queues, the three allocation strategies of makechan, and the
lock that protects all of it. The structs shown below omit fields irrelevant to the discussion; for
the complete definitions see runtime/chan.go and runtime/runtime2.go
[Go Authors, 2026a].
10.2.1 Three Invariants
The top of chan.go states the invariants that the channel implementation maintains:
- At least one of
c.sendqandc.recvqis empty. The only exception is a goroutine that blocks in aselectstatement sending and receiving on the same unbuffered channel at once; then the length of both queues is bounded only by the size of thatselectstatement. - For a buffered channel,
c.qcount > 0implies thatc.recvqis empty. - For a buffered channel,
c.qcount < c.dataqsizimplies thatc.sendqis empty.
Their meaning is direct. When the buffer holds a value, no receiver should be waiting, because it
could have taken the value; when the buffer has a free slot, no sender should be waiting, because it
could have put its value there; and when a receiver is waiting, a new sender pairs with it directly,
so the sender does not queue as well. The exception comes from select: a goroutine cannot pair
with itself, so when its select contains both case c <- v and case <-c, it appears in both
queues of c. The implementation of send and receive
(10.3 Send, Receive, and Direct Handoff) maintains these invariants throughout:
pair with a waiter if possible, use the buffer if possible, and enter a wait queue only when neither
works.
10.2.2 The Structure of hchan
| |
The fields fall into groups. qcount, dataqsiz, buf, sendx and recvx describe the ring
buffer (10.2.3); recvq and sendq are the two wait queues (10.2.4); closed records the closed
state (10.4 The Semantics of Closing); elemsize and elemtype are used to copy
elements, and elemtype also decides whether the copy needs a write barrier. dataqsiz does not
change after creation, so the runtime can read it without the lock; full, which decides without
locking whether a send would block, and empty, which does the same for a receive, depend on this
(10.3 Send, Receive, and Direct Handoff).
Two other fields serve other components. timer is non-nil only on channels created by functions
such as time.NewTimer: on receive, the runtime uses it to check whether the timer has expired, and
if it has expired but has not yet sent, performs the send on the spot
(9.10 Timers). bubble records the testing/synctest bubble that created
the channel (Go 1.25): a goroutine outside the bubble that sends to, receives from, or closes it
terminates the program, and a goroutine inside the bubble that blocks on it is considered wakeable
only by other goroutines in the bubble, from which synctest decides when to advance the virtual
clock.
10.2.3 The Ring Buffer
The elements of a buffered channel live in a ring buffer. buf points to a contiguous block of
memory that holds dataqsiz elements, and sendx and recvx are two indices: a send writes the
element at sendx and then advances it by one; a receive reads from recvx and then advances it by
one; both wrap to 0 on reaching the end. qcount records the number of elements present: the buffer
is full when qcount == dataqsiz and empty when qcount == 0. The address of slot i is computed
by chanbuf:
| |
The figure below shows a channel of capacity 6 that holds 3 elements. recvx points to the earliest
written element, which is read next, and sendx points to the next free slot; slot 0 follows
slot 5.
block-beta columns 6 s0["slot 0: empty"] s1["slot 1: empty"] s2["slot 2: empty"] s3["slot 3: v0"] s4["slot 4: v1"] s5["slot 5: v2"] a0<["sendx = 0"]>(up) space:2 a3<["recvx = 3"]>(up) space:2
When an index advances, the runtime does not take a modulus but compares and resets to zero:
| |
The capacity is generally not a power of two, so a modulus needs a division while a comparison does not. A channel is first-in-first-out, and the ring buffer makes both enqueue and dequeue a matter of moving one index, with no elements shifted.
Early implementations were different. Before April 2011 the buffer was a circular linked list: the elements were allocated in one block of memory but connected by pointers. Russ Cox changed it into an array accessed by index, saving the following and storing of pointers; the change also removed a pointer from the channel into its own memory block. That self-reference formed a cycle, and memory blocks in a cycle are not guaranteed to run their finalizers correctly. At the time, on some systems a channel relied on a finalizer to release an operating-system-level lock, so such a channel could never be reclaimed [Cox, 2011].
An unbuffered channel has a dataqsiz of 0 and no buffer; values are always passed directly between
two goroutines (10.3 Send, Receive, and Direct Handoff).
10.2.4 Wait Queues and sudog
When the buffer cannot complete an operation, for example a send to a full channel, a receive from
an empty channel, or a channel with no buffer at all, the current goroutine must wait for the other
side to arrive. Send and receive on an unbuffered channel are exactly a rendezvous in Hoare’s sense:
the side that arrives first waits for the side that arrives later [Hoare, 1978].
Waiters line up in recvq or sendq, both doubly linked lists of type waitq:
| |
The nodes of the list are sudogs. A sudog represents a goroutine blocked on a channel, together
with the element it wants to send or receive [Go Authors, 2026b]:
| |
The nodes of the queue are not gs directly because goroutines and channels are not in a one-to-one
relationship: a select statement can block one goroutine on several channels at once, and then it
prepares one sudog for each channel. The memory elem points to is often on the waiter’s own
stack, which lets a sender write the value directly into the stack of a waiting receiver without
going through the buffer (10.3 Send, Receive, and Direct Handoff). In Go 1.26 the
types of elem and c are pointers that can be hidden from the GC, for use by GC-based goroutine
leak detection (9.3 The MPG Model and the Units of Concurrent Scheduling).
sudog is not specific to channels. The runtime’s semaphores also queue with sudog, and they
underpin synchronization primitives such as sync.Mutex (11.2 Mutex); most
of the omitted fields exist for semaphores. sudogs are reused through a per-P cache and a global
cache rather than allocated each time.
Enqueue appends at the tail and dequeue takes from the head. Dequeue has one special case: a
goroutine executing a select may already have been woken by another case without yet having
removed itself from the other queues. When dequeue meets such a node, it uses a CAS on
g.selectDone to decide who wins the right to wake the goroutine, and skips the node if it loses:
| |
The queue was originally a singly linked list. A woken select must remove its sudogs from the
queues of the remaining channels, which in a singly linked list requires a search from the head.
When a channel has a great many waiters, the total cost of removing them one by one can become
quadratic. In Go 1.5 Keith Randall changed it to a doubly linked list, so that removing an arbitrary
node takes constant time; in the corresponding benchmark the running time dropped by 75.54%
[Randall, 2014]. The significance of waking the earliest waiter first is
discussed in 10.3.6.
10.2.5 The Three Allocation Strategies of makechan
The compiler translates make(chan T, n) into a call to makechan. It computes the size of the
buffer, requests memory from the heap, and fills in the element information. Because it always calls
mallocgc, a channel is always allocated on the heap. With the debugging and synctest code
removed:
| |
The three branches differ in whether the GC needs to scan the memory.
mem == 0covers two cases: an unbuffered channel, and a channel whose element size is 0, such as thechan struct{}used as a signal. Neither needs a buffer, so only anhchanis allocated;bufpoints to an address inside thehchan, for the race detector to use as a synchronization address.- When the elements contain no pointers, the
hchanand the buffer are allocated by a singlemallocgc, with the buffer immediately after thehchan. - When the elements contain pointers, the buffer must be allocated separately, and the element type is handed to the allocator so that the GC can find the objects referenced by the elements still in the buffer.
The first two branches pass the type nil to mallocgc, so the whole block is not scanned.
Notably, hchan itself contains pointer fields; why can it go unscanned? The comment in the source
gives three reasons: buf points into the same block; the type information elemtype points to is
permanent; and the sudogs in the wait queues are referenced by the goroutines they belong to, so
they are not reclaimed because of this. Only the third branch allocates a scannable hchan with
new(hchan), and a separate scannable buffer.
The number of allocations can be observed directly:
| |
make(chan int) 1
make(chan struct{}, 8) 1
make(chan int, 8) 1
make(chan *int, 8) 2
elemsize is a 16-bit integer, so elements must be smaller than 64 KiB. The compiler checks this at
compile time: make(chan [1 << 16]byte) gets the error “channel element type too large (>64kB)”,
and the check in makechan is only a safeguard. In practice this limit is almost never hit: large
values should travel through a channel as pointers.
10.2.6 The Lock
The final field of hchan, lock, protects all fields of hchan as well as the g, next,
prev and elem fields of the sudogs blocked on the channel. Send, receive, close and select
all acquire it before reading or writing this state. The guarantees the memory model gives channels
(10.1 Channels and the Engineering of CSP) are realized in the critical sections of
this lock; see 10.6 The Memory Model and the Lock-Free Evolution for details.
The lock comes with a discipline, written in its comment: while holding it, do not change the state
of another goroutine, and in particular do not wake a goroutine, or a deadlock with stack shrinking
may result. The reason lies in the order of lock acquisition. When the runtime shrinks the stack of
a goroutine blocked on a channel, it first locks the goroutine through the scan bit in its status,
and then acquires the locks of the channels it waits on, so as to fix the pointers into the old
stack in sudog.elem. If another goroutine holds a channel’s lock and then tries to change this
goroutine’s state, it must wait for the scan bit to clear, and each side holds one lock while
waiting for the other. In 2016 Austin Clements established this order for the implementation of
concurrent stack shrinking, and fixed closechan, then the only code that violated it
[Clements, 2016]. The work originated in issue #12967 reported by Rhys
Hiltner: shrinking stacks during mark termination noticeably lengthened pauses, and the program
involved is the one with a great many goroutines discussed in
9.11 NUMA Awareness and the Future of the Scheduler. That is why send,
receive and close take waiters off the queue while holding the lock and call goready to wake them
only after releasing it (10.3 Send, Receive, and Direct Handoff,
10.4 The Semantics of Closing).
Protecting the whole channel with one lock keeps the implementation simple and its correctness easy
to argue, at the price that concurrent operations on the same channel serialize on this lock. In a
design document of January 2014, Dmitry Vyukov proposed a series of ways to speed up channels, with
goals including faster uncontended operations, contended producer-consumer scenarios, and failing
non-blocking operations and select; the document also lists making channels entirely lock-free as
a non-goal, on the grounds that it would make the implementation much more complex and slow down the
common cases [Vyukov, 2014a]. In August of the same year Vyukov added a
lock-free fast path for failing non-blocking operations, which cut the running time by 97% at
GOMAXPROCS of 4 [Vyukov, 2014b]; today’s full and empty are part of that
fast path. The channel is still a lock-based structure; later attempts at lock-free channels are
covered in 10.6 The Memory Model and the Lock-Free Evolution.
Further Reading
- [Clements, 2016] Austin Clements. runtime: define lock order between G status and channel lock. Go commit d45bf7228f, 2016. https://go.dev/cl/20041 Related issue #12967; released with Go 1.7.
- [Cox, 2009] Russ Cox. Go Data Structures. 2009. https://research.swtch.com/godata
- [Cox, 2011] Russ Cox. runtime: drop chan circular linked list in favor of circular buffer. Go commit 507df959e4, 2011. https://github.com/golang/go/commit/507df959e4 Fixes issue #1676.
- [Go Authors, 2026a] The Go Authors. runtime/chan.go (
hchan,waitq,makechan,chanbuf, including the invariants comment at the top). https://github.com/golang/go/blob/master/src/runtime/chan.go This section follows Go 1.26. - [Go Authors, 2026b] The Go Authors. runtime/runtime2.go (the
definition and field documentation of
sudog). https://github.com/golang/go/blob/master/src/runtime/runtime2.go - [Hoare, 1978] C. A. R. Hoare. Communicating Sequential Processes. Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585
- [Randall, 2014] Keith Randall. runtime: use doubly-linked lists for channel send/recv queues. Go commit 8eb8b40a49, 2014. https://github.com/golang/go/commit/8eb8b40a49 Released with Go 1.5.
- [Vyukov, 2014a] Dmitry Vyukov. Go channels on steroids (a design for speeding up channels; going entirely lock-free is listed as a non-goal). 2014. https://docs.google.com/document/d/1yIAYmbvL3JxOKOjuCyon7JhW4cSv1wy5hC0ApeGMV9s
- [Vyukov, 2014b] Dmitry Vyukov. runtime: add fast paths to non-blocking channel operations. Go commit f6ceefa2bf, 2014. https://github.com/golang/go/commit/f6ceefa2bf Released with Go 1.4.
- This book: 10.1 Channels and the Engineering of CSP, 10.3 Send, Receive, and Direct Handoff, 11.9 Memory Consistency Models, 12 Memory Allocator.