Go Channels Are a Ring Buffer, a Lock and a Wait Queue
A Go channel is a runtime.hchan: a fixed-size ring buffer of elements guarded by one mutex, plus two intrusive linked lists of sudog waiters that park and wake each other.
TL;DR: A Go channel is not a queue of goroutines or a magic pipe. It is a
runtime.hchan: a fixed-size ring buffer of elements, onemutexguarding it, and two intrusive linked lists ofsudogwaiters. A send copies the element withtypedmemmove, a blocked send parks its goroutine on asudogand hands ownership of the goroutine to whoever completes the operation.
Every Go programmer learns channels on day one and then spends years half-guessing them. The mental model most people carry — “a channel is a queue of values that goroutines read from” — is roughly right for a buffered channel and completely wrong for an unbuffered one. The unbuffered channel is not a queue at all: it is a rendezvous point, and the value is copied straight from the sender’s stack to the receiver’s stack.
Here is what is actually in the runtime, read from the Go 1.28 development
tree (commit c0be4674865b).
The whole object: hchan
Everything about a channel lives in one struct:
// src/runtime/chan.go:34
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
timer *timer // timer feeding this chan
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
bubble *synctestBubble
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}That is the entire channel. A few things stand out:
| Field | What it is |
|---|---|
buf | pointer to dataqsiz element slots, allocated once at make time |
qcount | how many elements are currently in the buffer |
sendx / recvx | the ring buffer’s write and read cursors |
elemsize / elemtype | element width and type, used by the copy and the GC |
recvq / sendq | wait queues of goroutines blocked on this channel |
lock | one mutex guarding everything above |
closed | non-zero once close() has run |
There is no separate “channel closed” flag with clever semantics, no per-element bookkeeping, and no reference counting. One mutex, one buffer, two queues.
The lock itself is the runtime’s own mutex, which is deliberately tiny:
// src/runtime/runtime2.go:165
// Mutual exclusion locks. In the uncontended case,
// as fast as spin locks (just a few user-level instructions),
// but on the contention path they sleep in the kernel.
// A zeroed Mutex is unlocked (no need to initialize each lock).
// Initialization is helpful for static lock ranking, but not required.
type mutex struct {
// Empty struct if lock ranking is disabled, otherwise includes the lock rank
lockRankStruct
// Futex-based impl treats it as uint32 key,
// while sema-based impl as M* waitm.
// Used to be a union, but unions break precise GC.
key uintptr
}Note the comment on the hchan.lock: “Do not change another G’s status
while holding this lock (in particular, do not ready a G)”. The runtime
takes this seriously — when it wakes a waiter, it collects the goroutines
into a list, drops the lock, and only then calls goready on them.
The buffer is a fixed ring, allocated once
The buffer is a plain array of dataqsiz elements. chanbuf computes the
address of slot i:
// src/runtime/chan.go:127
// chanbuf(c, i) is pointer to the i'th slot in the buffer.
//
// chanbuf should be an internal detail,
// but widely used packages access it using linkname.
// Notable members of the hall of shame include:
// - github.com/fjl/memsize
//
// Do not remove or change the type signature.
// See go.dev/issue/67401.
//
//go:linkname chanbuf
func chanbuf(c *hchan, i uint) unsafe.Pointer {
return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}No modulo, no bounds check — the callers keep sendx and recvx inside
[0, dataqsiz) by hand, wrapping them explicitly.
The allocation happens once, in makechan, and the runtime is careful to
avoid a separate allocation when it can help:
// src/runtime/chan.go:96
switch {
case mem == 0:
// Queue or element size is zero.
c = (*hchan)(mallocgc(hchanSize, nil, true))
// Race detector uses this location for synchronization.
c.buf = c.raceaddr()
case !elem.Pointers():
// Elements do not contain pointers.
// Allocate hchan and buf in one call.
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// Elements contain pointers.
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}So a channel of pointer-free elements is a single heap allocation: the
hchan header and the element array are one contiguous block, with buf
pointing just past the header. A channel of pointer-carrying elements needs
two, because the GC has to scan the buffer.
A buffered channel of size 8 holding int64 looks like this in memory:
hchan (one allocation)
+--------------------------------------------------+
| qcount | dataqsiz=8 | buf -> | elemsize | closed |
| sendx | recvx | recvq | sendq | lock |
+--------------------------------------------------+
| buf points here
v
+--------------------------------------------------+
| slot 0 | slot 1 | slot 2 | ... | slot 7 |
+--------------------------------------------------+
^sendx, ^recvx move around this ringThe wait queue: sudog
When a goroutine blocks on a channel, it does not get its own goroutine
object. It gets a sudog — a small descriptor that says “this goroutine is
waiting on this channel, and here is where my value goes”:
// src/runtime/runtime2.go:404
type sudog struct {
// The following fields are protected by the hchan.lock of the
// channel this sudog is blocking on. shrinkstack depends on
// this for sudogs involved in channel ops.
g *g
next *sudog
prev *sudog
elem maybeTraceablePtr // data element (may point to stack)
// The following fields are never accessed concurrently.
// For channels, waitlink is only accessed by g.
// For semaphores, all fields (including the ones above)
// are only accessed when holding a semaRoot lock.
acquiretime int64
releasetime int64
ticket uint32
// isSelect indicates g is participating in a select, so
// g.selectDone must be CAS'd to win the wake-up race.
isSelect bool
// success indicates whether communication over channel c
// succeeded. It is true if the goroutine was awoken because a
// value was delivered over channel c, and false if awoken
// because c was closed.
success bool
// waiters is a count of semaRoot waiting list other than head of list,
// clamped to a uint16 to fit in unused space.
// Only meaningful at the head of the list.
// (If we wanted to be overly clever, we could store a high 16 bits
// in the second entry in the list.)
waiters uint16
parent *sudog // semaRoot binary tree
waitlink *sudog // g.waiting list or semaRoot
waittail *sudog // semaRoot
c maybeTraceableChan // channel
}The sudog exists because the goroutine-to-object relation is many-to-many.
One goroutine can wait on many channels; one channel can have many waiters.
A sudog is the join record between the two.
The two wait queues are doubly linked lists of sudog, with a head and tail
pointer:
// src/runtime/chan.go:57
type waitq struct {
first *sudog
last *sudog
}enqueue appends at the tail and dequeue takes from the head, so the
channel’s wait queues are strictly FIFO — a fact that surprises people who
assume Go channels are “fair” in the sense of a fair scheduler. They are
fair in a different way: waiters are served in arrival order, but a newly
arrived sender can still jump a buffered receiver, because buffered
receives never touch the wait queue at all.
Sudogs are pooled rather than allocated fresh every time, and the pool is per-P with a central overflow:
// src/runtime/proc.go:499
func acquireSudog() *sudog {
// Delicate dance: the semaphore implementation calls
// acquireSudog, acquireSudog calls new(sudog),
// new calls malloc, malloc can call the garbage collector,
// and the garbage collector calls the semaphore implementation
// in stopTheWorld.
// Break the cycle by doing acquirem/releasem around new(sudog).
// The acquirem/releasem increments m.locks during new(sudog),
// which keeps the garbage collector from being invoked.
mp := acquirem()
pp := mp.p.ptr()
if len(pp.sudogcache) == 0 {
lock(&sched.sudoglock)
// First, try to grab a batch from central cache.
for len(pp.sudogcache) < cap(pp.sudogcache)/2 && sched.sudogcache != nil {
s := sched.sudogcache
sched.sudogcache = s.next
s.next = nil
pp.sudogcache = append(pp.sudogcache, s)
}
unlock(&sched.sudoglock)
// If the central cache is empty, allocate a new one.
if len(pp.sudogcache) == 0 {
pp.sudogcache = append(pp.sudogcache, new(sudog))
}
}
n := len(pp.sudogcache)
s := pp.sudogcache[n-1]
pp.sudogcache[n-1] = nil
pp.sudogcache = pp.sudogcache[:n-1]
if s.elem.get() != nil {
throw("acquireSudog: found s.elem != nil in cache")
}
releasem(mp)
return s
}Sending: three fast paths, then the slow path
chansend is where all the interesting decisions live. The first thing it
does is handle the nil channel:
// src/runtime/chan.go:168
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
if c == nil {
if !block {
return false
}
gopark(nil, nil, waitReasonChanSendNilChan, traceBlockForever, 2)
throw("unreachable")
}A send on a nil channel parks the goroutine forever. Not “panics”, not
“returns an error” — parks it, with a stack trace that says
waitReasonChanSendNilChan. This is why var c chan int is a legal
“never ready” channel and why select with a nil case is the idiomatic way
to disable a branch.
Then there is a lock-free fast path for the non-blocking case, which is what
select with a default compiles down to:
// src/runtime/chan.go:205
if !block && c.closed == 0 && full(c) {
return false
}Only one word-sized read of closed and one of qcount. No lock at all.
Under the lock, the order of checks is the whole algorithm: is there a waiting receiver (hand off directly), else is there buffer space (copy into the ring), else block.
// src/runtime/chan.go:214
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
if sg := c.recvq.dequeue(); sg != nil {
// Found a waiting receiver. We pass the value we want to send
// directly to the receiver, bypassing the channel buffer (if any).
send(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true
}
if c.qcount < c.dataqsiz {
// Space is available in the channel buffer. Enqueue the element to send.
qp := chanbuf(c, c.sendx)
if raceenabled {
racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
c.qcount++
unlock(&c.lock)
return true
}Three things to notice:
- A send never blocks if a receiver is already waiting. The value is copied straight into the receiver’s slot, bypassing the buffer entirely. This is why an unbuffered channel is a rendezvous and not a zero-length queue.
typedmemmoveis the copy. It is a write-barrier-awarememmoveof exactlyelemsizebytes.c.sendxwraps manually rather than using%, because the compiler cannot knowdataqsizis a power of two and a modulo is a division.
The copy itself
typedmemmove is the ordinary runtime memory copier with GC write barriers
attached:
// src/runtime/mbarrier.go:150
func typedmemmove(typ *abi.Type, dst, src unsafe.Pointer) {
if dst == src {
return
}
if writeBarrier.enabled && typ.Pointers() {
// This always copies a full value of type typ so it's safe
// to pass typ along as an optimization. See the comment on
// bulkBarrierPreWrite.
bulkBarrierPreWrite(uintptr(dst), uintptr(src), typ.PtrBytes, typ)
}and it ends in memmove(dst, src, typ.Size_). For pointer-free element
types the whole thing collapses to a plain copy of a handful of bytes.
For the direct handoff case — unbuffered channel, or buffered channel with a waiting receiver — the source and destination are on two different goroutine stacks, which the GC is not supposed to like. The runtime handles it with a hand-written barrier:
// src/runtime/chan.go:384
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
// src is on our stack, dst is a slot on another stack.
// Once we read sg.elem out of sg, it will no longer
// be updated if the destination's stack gets copied (shrunk).
// So make sure that no preemption points can happen between read & use.
dst := sg.elem.get()
typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.Size_)
// No need for cgo write barrier checks because dst is always
// Go memory.
memmove(dst, src, t.Size_)
}The comment block above it explains why this is special:
// src/runtime/chan.go:374
// Sends and receives on unbuffered or empty-buffered channels are the
// only operations where one running goroutine writes to the stack of
// another running goroutine. The GC assumes that stack writes only
// happen when the goroutine is running and are only done by that
// goroutine. Using a write barrier is sufficient to make up for
// violating that assumption, but the write barrier has to work.
// typedmemmove will call bulkBarrierPreWrite, but the target bytes
// are not in the heap, so that will not help. We arrange to call
// memmove and typeBitsBulkBarrier instead.This is the single most surprising fact about Go channels: the runtime occasionally writes into another goroutine’s stack. It is safe only because the writer holds the channel lock and the reader is parked.
Blocking, and the handoff
If there is no waiting receiver and no buffer space, the sender has to
block. It allocates a sudog, wires itself into the channel’s send queue,
and parks:
// src/runtime/chan.go:249
// Block on the channel. Some receiver will complete our operation for us.
gp := getg()
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
// No stack splits between assigning elem and enqueuing mysg
// on gp.waiting where copystack can find it.
mysg.elem.set(ep)
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c.set(c)
gp.waiting = mysg
gp.param = nil
c.sendq.enqueue(mysg)
// Signal to anyone trying to shrink our stack that we're about
// to park on a channel. The window between when this G's status
// changes and when we set gp.activeStackChans is not safe for
// stack shrinking.
gp.parkingOnChan.Store(true)
reason := waitReasonChanSend
if c.bubble != nil {
reason = waitReasonSynctestChanSend
}
gopark(chanparkcommit, unsafe.Pointer(&c.lock), reason, traceBlockChanSend, 2)The key line is mysg.elem.set(ep): the sender’s stack address of the
value is recorded in the sudog. Whoever eventually receives will copy from
that address. The sender’s stack stays alive because the sudog keeps a
reference, and the sender cannot resume (and cannot return from the
function) until the value has been taken.
gopark is the scheduler hand-off:
// src/runtime/proc.go:456
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
if reason != waitReasonSleep {
checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
}
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = unlockf
gp.waitreason = reason
mp.waitTraceBlockReason = traceReason
mp.waitTraceSkip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)
}Note the unlockf: the channel lock is not released by the caller, it is
released by the park machinery at exactly the right moment. For a channel,
unlockf is chanparkcommit:
// src/runtime/chan.go:741
func chanparkcommit(gp *g, chanLock unsafe.Pointer) bool {
// There are unlocked sudogs that point into gp's stack. Stack
// copying must lock the channels of those sudogs.
// Set activeStackChans here instead of before we try parking
// because we could self-deadlock in stack growth on the
// channel lock.
gp.activeStackChans = true
// Mark that it's safe for stack shrinking to occur now,
// because any thread acquiring this G's stack for shrinking
// is guaranteed to observe activeStackChans after this store.
gp.parkingOnChan.Store(false)
// Make sure we unlock after setting activeStackChans and
// unsetting parkingOnChan. The moment we unlock chanLock
// we risk gp getting readied by a channel operation and
// so gp could continue running before everything before
// the unlock is visible (even to gp itself).
unlock((*mutex)(chanLock))
return true
}The unlock happens inside the park commit, after the goroutine is fully parked. That is what makes the handoff safe: the sender is not runnable until the channel lock is released, so a receiver that is waking up cannot observe a half-published state.
Now the other half: waking a parked sender. send copies the value and
makes the receiver runnable:
// src/runtime/chan.go:310
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if c.bubble != nil && getg().bubble != c.bubble {
unlockf()
fatal("send on synctest channel from outside bubble")
}
if raceenabled {
if c.dataqsiz == 0 {
racesync(c, sg)
} else {
// Pretend we go through the buffer, even though
// we copy directly. Note that we need to increment
// the head/tail locations only when raceenabled.
racenotify(c, c.recvx, nil)
racenotify(c, c.recvx, sg)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
}
}
if sg.elem.get() != nil {
sendDirect(c.elemtype, sg, ep)
sg.elem.set(nil)
}
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
sg.success = true
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
goready(gp, skip+1)
}Read the ordering: copy the value, release the lock, then goready.
The goready is deliberately after the unlock, both to avoid the
deadlock the hchan.lock comment warns about and to avoid the woken
goroutine racing back into the channel before the unlock has happened.
goready itself is thin:
// src/runtime/proc.go:492
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}Receiving is the mirror image
chanrecv checks the nil channel, then does the same lock-free fast path
for the non-blocking case, then locks and applies the mirror-image
priority: closed-and-empty, then a waiting sender, then the buffer, then
block.
// src/runtime/chan.go:579
lock(&c.lock)
if c.closed != 0 {
if c.qcount == 0 {
if raceenabled {
raceacquire(c.raceaddr())
}
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
// The channel has been closed, but the channel's buffer have data.
} else {
// Just found waiting sender with not closed.
if sg := c.sendq.dequeue(); sg != nil {
// Found a waiting sender. If buffer is size 0, receive value
// directly from sender. Otherwise, receive from head of queue
// and add sender's value to the tail of the queue (both map to
// the same buffer slot because the queue is full).
recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
return true, true
}
}Note the subtlety in the closed case: a closed channel still delivers the
values that are sitting in its buffer. close() does not discard buffered
values; it just stops accepting new ones and wakes everybody up.
The buffered receive path moves the element out and then clears the slot, so the buffer does not keep the value alive:
// src/runtime/chan.go:605
if c.qcount > 0 {
// Receive directly from queue
qp := chanbuf(c, c.recvx)
if raceenabled {
racenotify(c, c.recvx, nil)
}
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
unlock(&c.lock)
return true, true
}typedmemclr on the slot is what allows the GC to collect a large value
that was sitting in the buffer. Without it the buffer would pin every
value ever sent.
Closing wakes everybody
closechan sets the flag, drains both wait queues, and then wakes the
collected goroutines:
// src/runtime/chan.go:406
func closechan(c *hchan) {
if c == nil {
panic(plainError("close of nil channel"))
}
if c.bubble != nil && getg().bubble != c.bubble {
fatal("close of synctest channel from outside bubble")
}
lock(&c.lock)
if c.closed != 0 {
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
if raceenabled {
callerpc := sys.GetCallerPC()
racewritepc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(closechan))
racerelease(c.raceaddr())
}
c.closed = 1
var glist gList
// release all readers
for {
sg := c.recvq.dequeue()
if sg == nil {
break
}
if sg.elem.get() != nil {
typedmemclr(c.elemtype, sg.elem.get())
sg.elem.set(nil)
}
if sg.releasetime != 0 {
sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {
raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}and the wake-up loop at the end:
// src/runtime/chan.go:470
unlock(&c.lock)
// Ready all Gs now that we've dropped the channel lock.
for !glist.empty() {
gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}Blocked receivers are woken with success = false — they get the zero
value and ok == false. Blocked senders are also woken with
success = false, and they panic with “send on closed channel” when they
look at the flag on the way out.
This is where the “close wakes everyone” behaviour comes from, and it is
why close on a channel with a blocked sender is a panic source rather
than an error return.
Unbuffered versus buffered
Put the pieces together and the difference is stark.
Unbuffered (dataqsiz == 0): there is no buf at all. Every send
either finds a parked receiver or parks itself. The value is copied from
sender’s stack to receiver’s stack (or into a temporary when a select is
involved) and never sits in the channel.
sender G receiver G
--------- -----------
chansend(ep)
lock(c)
recvq.dequeue() -> sg
sendDirect: memmove(sg.elem, ep) <-- straight across
unlock(c)
goready(sg.g)Buffered (dataqsiz = N): the value goes into the ring. A sender only
blocks when the ring is full and no receiver is waiting. A receiver only
blocks when the ring is empty.
sender G receiver G
--------- -----------
chansend(ep)
lock(c)
recvq empty
qcount(0) < dataqsiz(8)?
typedmemmove(chanbuf(sendx), ep)
sendx = (sendx+1) % 8
qcount++
unlock(c)
chanrecv(ep)
lock(c)
qcount(1) > 0
typedmemmove(ep, chanbuf(recvx))
typedmemclr(chanbuf(recvx))
recvx = (recvx+1) % 8
qcount--
unlock(c)So a buffered channel of size 1 is not a synchronous channel with one slot of slack. It behaves like a mailbox: the sender does not wait for the receiver, and it does not wait for the receiver to be scheduled. The difference is visible in every latency-sensitive program.
And a buffered channel’s buffer is not touched by a receive when a sender
is parked. In that case recv does something subtle: it takes the value
out of the ring for the current receiver, and puts the parked sender’s
value into the ring in its place, because the ring was full:
// src/runtime/chan.go:708
} else {
// Queue is full. Take the item at the
// head of the queue. Make the sender enqueue
// its item at the tail of the queue. Since the
// queue is full, those are both the same slot.
qp := chanbuf(c, c.recvx)
if raceenabled {
racenotify(c, c.recvx, nil)
racenotify(c, c.recvx, sg)
}
// copy data from queue to receiver
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
// copy data from sender to queue
typedmemmove(c.elemtype, qp, sg.elem.get())
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
}Two copies, but only one slot moves: the head slot is handed to the receiver and immediately refilled with the parked sender’s value. The channel’s ordering invariant (“FIFO of sends”) is preserved without ever allocating.
Trade-offs
What a channel buys you. A mutex plus a condition variable plus a
queue, with one lock and no copying of the payload twice. A send into an
empty buffered channel is a lock, a memmove, an unlock. A send into an
empty unbuffered channel with a waiting receiver is the same plus a
goready.
What it costs you. Every operation takes a lock, even the uncontended
one. Two goroutines contending on the same channel serialise completely.
There is no try-lock, no barging, no fairness knob. And a select over N
channels locks all N of them, which is the subject of the next article.
Buffer size is a latency knob, not a throughput knob. A larger buffer does not make the channel faster; it makes the handoff less synchronised. A buffer of 1 decouples the producer from the consumer by one item. A buffer of 1000 decouples them by 1000 items, and adds 1000 items of latency when the consumer finally gets to them.
Unbuffered channels are not slower by accident. They are the correct choice when the value’s lifetime should be tied to the rendezvous: the sender’s stack slot is only read while the sender is parked, so the value cannot be mutated after the send.
Caveats
len(ch) and cap(ch) are not exact. qcount is read without the
lock, so the answer is a snapshot that may already be stale. The runtime
even hides the buffer of timer channels:
// src/runtime/chan.go:811
func chanlen(c *hchan) int {
if c == nil || c.timer != nil {
// timer channels have a buffered implementation
// but present to users as unbuffered, so that we can
// undo sends without users noticing.
return 0
}
return int(c.qcount)
}A closed channel is not a queue you can drain. Receives from a closed
channel succeed forever, returning the zero value. Code that loops on
v, ok := <-ch is correct; code that loops on v := <-ch spins forever
at full speed.
Sending on a closed channel panics, but the panic may come from a different goroutine. If a sender is parked when the channel is closed, the sender is woken and panics in its own goroutine, potentially minutes later. This is why “close the channel to signal” and “close the channel to stop producers” are different designs.
The hchan layout is not stable. The struct has changed shape many
times; timer and bubble are recent additions. The only supported way to
poke at a channel is through the reflect package.
chanbuf is technically a hall-of-shame linkname. The comment in
chan.go says so. Packages that reach into a channel’s buffer with
go:linkname will break.
Wrapping up
The one thing to remember: a channel is a ring buffer plus one mutex
plus two intrusive wait queues of sudog. A send that finds a waiting
receiver copies the value directly to the receiver and never touches the
buffer; a send that finds no receiver either parks or copies into the ring.
The part people get wrong: unbuffered channels are not “channels with no space”. They have no buffer at all, and the value is copied straight from the sender’s stack into the receiver’s — which is why the runtime has to write into another goroutine’s stack and hand-roll a write barrier for it.
What to go do about it: read chansend and chanrecv in
src/runtime/chan.go end to end; they are about 150 lines each and every
question you have about channel semantics is answered in them. Then
benchmark a buffered channel of size 1 against an unbuffered one in your own
pipeline — the difference is often much larger than people expect.
Further reading
src/runtime/chan.go— thehchanstruct,chansend,chanrecv,send,recv,closechansrc/runtime/runtime2.go— thesudogstruct and the runtimemutexsrc/runtime/proc.go—gopark,goready,acquireSudog,releaseSudogsrc/runtime/mbarrier.go—typedmemmoveandtypedmemclrsrc/runtime/select.go— howselectdrives the same primitivessrc/runtime/sema.go— the semaphore machinery thatsyncuses