Channel Internals

A Go channel is a heap-allocated runtime struct, hchan, that combines an optional ring buffer with two FIFO wait queuessendq for blocked senders and recvq for blocked receivers — all protected by a single runtime mutex (runtime chan.go). A channel value (chan T) is just a pointer to one hchan. Every send, receive, and close is a short critical section that either completes immediately against a waiting goroutine or the buffer, or parks the calling goroutine onto a wait queue and hands control to the scheduler. The channel is therefore not only a data structure but a scheduling primitive: it is the runtime’s mechanism for one goroutine to block until another makes progress, which is why “share memory by communicating” works (Go blog, Share Memory By Communicating).

This note dissects the hchan implementation. The language semantics of channels — direction, nil-channel behavior, the happens-before guarantees — are in Channels in Go and the Go Memory Model; select has its own note, The select Statement. This note explains the mechanism those notes rely on.

Mental Model

flowchart TD
  CH["chan T value"] -->|"pointer"| HCHAN
  subgraph HCHAN["hchan struct"]
    QC["qcount / dataqsiz"]
    BUF["buf → ring buffer (dataqsiz slots)"]
    IDX["sendx / recvx (ring indices)"]
    SQ["sendq: waitq of blocked senders"]
    RQ["recvq: waitq of blocked receivers"]
    LK["lock: runtime mutex"]
    CL["closed flag"]
  end
  SQ --> SG1["sudog (goroutine G3, elem→stack)"]
  RQ --> SG2["sudog (goroutine G7, elem→stack)"]
  BUF --> R["[ e0 | e1 | __ | __ ]  recvx→0  sendx→2"]

Diagram: an hchan with a 4-slot ring buffer holding 2 elements and one blocked sender and one blocked receiver. The insight: a channel is one mutex guarding three things — a ring buffer and two goroutine queues. An operation always touches exactly one of the three: a ready partner (queue), free buffer space/data (buffer), or, failing both, it adds itself to a queue and parks.

Mechanical Walk-through

The hchan struct

From runtime/runtime2.go/chan.go:

type hchan struct {
    qcount   uint           // # elements currently in the buffer
    dataqsiz uint           // ring buffer capacity (0 for unbuffered)
    buf      unsafe.Pointer // → array of dataqsiz elements
    elemsize uint16         // size of one element
    closed   uint32         // 0 = open, non-zero = closed
    elemtype *_type         // element type (for typed memmove + GC)
    sendx    uint           // ring index for the next send
    recvx    uint           // ring index for the next receive
    recvq    waitq          // queue of blocked receivers
    sendq    waitq          // queue of blocked senders
    lock     mutex          // guards everything above
}
 
type waitq struct { first, last *sudog } // intrusive FIFO linked list

make(chan T, n) calls makechan, which allocates the hchan. A clever detail: if the element type holds no pointers, the hchan header and its buffer are allocated in one mallocgc block (buf points just past the header); if elements do contain pointers, the buffer is a separate GC-scanned allocation. An unbuffered channel has dataqsiz == 0 and buf points at a zero-size location used only for the race detector.

The sudog — a goroutine parked on a channel

When a goroutine blocks on a channel it does not store itself on the queue — it stores a sudog (“pseudo-G”), a small reusable record acquired from a per-P free list (acquireSudog). The sudog records which goroutine (g), which channel (c), and a pointer to the element: for a blocked sender, elem points at the value to be sent (on the sender’s stack); for a blocked receiver, elem points at the destination slot to receive into. A goroutine selecting on multiple channels has one sudog per channel, which is why the sudog exists separately from the g.

Send

chansend (runtime/chan.go) follows a strict order:

  1. nil channelgopark forever (a send on a nil channel blocks permanently — by design, useful in select).
  2. Fast-path non-blocking check — a lock-free read of closed and full(); if the (select-style) non-blocking send cannot proceed, return false without locking.
  3. Acquire c.lock.
  4. Closed?unlock and panic("send on closed channel").
  5. Waiting receiver? c.recvq.dequeue() — if there is one, call send(): it copies the value directly into the receiver’s stack slot (sendDirect), bypassing the buffer entirely, sets the receiver’s sudog.success, and calls goready to make the receiver runnable. Unlock. Done.
  6. Buffer has space? (qcount < dataqsiz) — typedmemmove the value into buf[sendx], advance sendx (wrapping at dataqsiz), increment qcount. Unlock. Done.
  7. Non-blocking and got here? unlock, return false.
  8. Block. Acquire a sudog, point its elem at the value, enqueue it on c.sendq, and gopark — the goroutine is descheduled; the M running it goes off to run other goroutines. When a future receiver dequeues this sudog, that receiver copies the value out and goreadys this sender.

Step 5 is the key optimization: an unbuffered send to a ready receiver is a single direct stack-to-stack copy with no buffer round-trip. Step 8 is the scheduling integration — blocking on a channel is just gopark.

Receive

chanrecv mirrors it: nil channel → block forever; closed-and-empty channel → return the zero value with ok == false (never blocks, never panics); a waiting sender in sendq → take its value directly (recv/recvDirect) and goready the sender; otherwise dequeue from the buffer (advancing recvx) — and if the buffer was full with senders queued, also move one queued sender’s value into the buffer slot just vacated and wake that sender; otherwise enqueue a sudog on recvq and gopark.

The buffer is a true ring: recvx chases sendx modulo dataqsiz. The invariants chan.go documents: at least one of sendq/recvq is always empty (you cannot simultaneously have blocked senders and blocked receivers, except for one goroutine selecting on both ends); qcount > 0 implies recvq empty; qcount < dataqsiz implies sendq empty.

Close

closechan locks the channel, panics if it is nil or already closed, sets closed = 1, then drains both queues: every receiver in recvq is dequeued, given the element type’s zero value, marked success = false, and readied — this is why receives on a closed channel return (zero, false). Every sender in sendq is dequeued, marked success = false, and readied — and each will, on waking, see closed != 0 and panic (“send on closed channel”). All woken goroutines are collected into a glist and readied after the lock is released. Close is therefore a broadcast: it is the canonical way to signal “no more values” to an unknown number of receivers at once.

Why channels are a scheduler primitive

gopark removes the goroutine from a run queue and marks it waiting; goready puts a goroutine back on a run queue. A blocked channel operation is exactly one gopark; a completing operation that had a waiting partner is exactly one goready. The runtime also attributes blocked time to the block profile (blockprofilerate). One sharp constraint the comments call out: a send/recv on an unbuffered channel is the only situation where one running goroutine writes directly into another running goroutine’s stack — so the GC’s assumption “stacks are written only by their own goroutine” is relaxed here, and sendDirect/recvDirect must invoke the write barrier (typeBitsBulkBarrier) explicitly so the GC stays correct.

Code: Tracing a Buffered Channel

ch := make(chan int, 2)   // hchan: dataqsiz=2, qcount=0, sendx=recvx=0, buf=[_,_]
 
ch <- 10                  // buffer has space → buf[0]=10, sendx=1, qcount=1
ch <- 20                  // buffer has space → buf[1]=20, sendx=0 (wrap), qcount=2
 
go func() { ch <- 30 }()  // buffer FULL → sender parks: sudog{elem→30} on sendq, gopark
 
x := <-ch                 // recvq empty; buffer non-empty → x=buf[0]=10, recvx=1, qcount=1
                          // sendq non-empty → move parked 30 into buf[1] vacated... 
                          // actually into the freed slot, wake that sender, qcount back to 2

Walk-through: the first two sends hit step 6 (buffer space) — no goroutine ever blocks. The third send finds a full buffer and no waiting receiver, so step 8 parks it with a sudog. The receive (<-ch) finds recvq empty but the buffer non-empty, takes buf[recvx] (the 10), and — because sendq is non-empty — also dequeues the parked sender’s sudog, copies its 30 into the slot that just opened, advances sendx, and goreadys the sender. Net effect: one receive both delivered a value and unblocked a sender, keeping the buffer full.

done := make(chan struct{})        // unbuffered — pure signal
go func() { /* work */ ; close(done) }()
<-done                             // blocks until close() broadcasts

Here <-done finds a nil-ish empty unbuffered channel: no sender, so it parks on recvq. close(done) drains recvq, hands the receiver the zero struct{}{}, and readies it. struct{} carries no data — the channel is used purely for its scheduling effect.

Failure Modes and Common Misunderstandings

Send on a closed channel panics; receive does not. Receiving from a closed channel returns (zero, false) forever — non-panicking, which is what makes the v, ok := <-ch idiom and for v := range ch work. Sending panics. So the receiver must never close a channel; by convention the sender owns and closes it. See Channel Direction and Ownership.

Closing a nil channel, or double-closing, panics. close(nil) → “close of nil channel”; closing an already-closed channel → “close of closed channel”.

nil-channel operations block forever. Send or receive on a nil channel never proceeds. This is occasionally useful — setting a select case’s channel to nil disables that case — but an accidental nil channel is a silent deadlock. See Buffered vs Unbuffered Channel Mistakes.

Leaked goroutines via blocked channels. A goroutine parked on a channel that no one will ever signal is leaked — its sudog, stack, and captured memory are pinned. This is the dominant goroutine-leak shape: see Goroutine Leak via Blocked Channel and Goroutine Leaks.

Unbuffered ≠ “buffer of size 1”. An unbuffered send does not complete until a receiver is executing the receive — it is a rendezvous. A size-1 buffered send completes as soon as the slot is free, with no receiver present. Conflating them causes ordering bugs.

A for range ch never terminates unless the channel is closed. Ranging a channel that is never closed leaks the ranging goroutine.

Alternatives and When to Choose Them

Channels are for communicating ownership and signaling between goroutines. When the job is plain mutual exclusion over shared state, a sync.Mutex is simpler and faster — the Go team’s own advice is “don’t communicate by sharing memory; share memory by communicating, but use a mutex when a mutex is what you mean.” For a one-shot “done” signal, a closed chan struct{} or the context cancellation channel. For counting completions, sync.WaitGroup. For one-time initialization, sync.Once. Reach for channels when the value or the handoff is the point; reach for sync primitives when only the synchronization is.

Production Notes

Channels appear in the execution tracer and block profile: a goroutine parked in chan send/chan receive is visible, and runtime/pprof’s block profile (enabled via runtime.SetBlockProfileRate) attributes blocked time to channel operations — the standard tool for spotting contended or starved channels (see The Go Execution Tracer, pprof and Profiling). The single per-channel lock means a heavily-fanned-in channel can become a contention point; the usual fix is sharding or a buffer sized to absorb bursts. The buffer-fused-with-header allocation in makechan keeps pointer-free channels cheap. Go 1.26’s experimental goroutine-leak profiler specifically targets the most common channel failure — goroutines permanently parked on sendq/recvq (see Goroutine Leaks).

See Also