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, one mutex guarding it, and two intrusive linked lists of sudog waiters. A send copies the element with typedmemmove, a blocked send parks its goroutine on a sudog and 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:

go
// 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:

FieldWhat it is
bufpointer to dataqsiz element slots, allocated once at make time
qcounthow many elements are currently in the buffer
sendx / recvxthe ring buffer’s write and read cursors
elemsize / elemtypeelement width and type, used by the copy and the GC
recvq / sendqwait queues of goroutines blocked on this channel
lockone mutex guarding everything above
closednon-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:

go
// 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:

go
// 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:

go
// 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:

text
   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 ring

The 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”:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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.

go
// 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:

  1. 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.
  2. typedmemmove is the copy. It is a write-barrier-aware memmove of exactly elemsize bytes.
  3. c.sendx wraps manually rather than using %, because the compiler cannot know dataqsiz is 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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.

go
// 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:

go
// 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:

go
// 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:

go
// 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.

text
   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.

text
   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:

go
// 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:

go
// 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 — the hchan struct, chansend, chanrecv, send, recv, closechan
  • src/runtime/runtime2.go — the sudog struct and the runtime mutex
  • src/runtime/proc.go — gopark, goready, acquireSudog, releaseSudog
  • src/runtime/mbarrier.go — typedmemmove and typedmemclr
  • src/runtime/select.go — how select drives the same primitives
  • src/runtime/sema.go — the semaphore machinery that sync uses

GoInternalsConcurrencyChannels