Channels in Go: a buffer, a mutex and two queues — and which of them is working when
A channel has three parts: a ring buffer, an ordinary mutex, and two queues of waiting goroutines. Nearly everything surprising about it is about which of the three is working at a given moment. When it is the queue: the value bypasses the buffer and len(ch) stays zero. When it is the buffer: bigger is not faster, and the best size measured is not the largest. When it is only the mutex: a channel costs ten times an atomic, because it is a mutex plus bookkeeping.
Full technical treatment
TL;DR
- A channel is 8 bytes: a pointer to a struct holding a buffer, two queues of waiters, and an ordinary mutex.
- If a receiver is already waiting, the value skips the buffer:
len(ch)after the send is still zero. - Cancellation has no priority: an already-cancelled context wins the
select14,992 times out of 30,000. - A bigger buffer is not faster: 64 slots — 57.91 ns, 1024 — 78.97.
- A channel costs more than a mutex even with no goroutine switch: 50.48 vs 32.36.
- A blocked goroutine is never collected.
The variable holds a pointer
unsafe.Sizeof(make(chan int)) // 8 — one wordInside, in the runtime, are a ring buffer, a queue of waiting senders, a queue
of waiting receivers, and lock mutex. That last one matters most: a channel
is not lock-free — every send and every receive takes that mutex.
The value does not always go through the buffer
If a receiver is already in the queue, the sender copies the value straight into its stack, skipping the buffer. An ordinary program can check it:
ch := make(chan int, 4)
// a receiver is already waiting
ch <- 1
fmt.Println(len(ch)) // 0 — the value never went through the bufferThat answers "what is a buffer for". Not for speed: when both sides keep up, the buffer takes no part. A buffer is permission for the receiver to fall behind by a given number of values. Once it is full, the sender blocks exactly as it would without one.
select does not read its branches top to bottom
A case <-ctx.Done() written first in the source has no priority. Measured:
the context was cancelled before the loop and work was ready on every pass —
cancellation won exactly half the passes.
To leave immediately, cancellation has to be checked in a separate select
with a default, before the main one:
select {
case <-ctx.Done():
return ctx.Err()
default:
}The polling order is shuffled Fisher–Yates on every entry into the select.
Nothing is carried over between entries.
What a channel costs
The tabs are not comparable with one another: the first two run two goroutines with a switch between them, the other three happen inside one.
Two of the findings contradict the usual telling. A 1024-slot buffer is slower than a 64-slot one — the relationship is not monotone, and the best of the five sizes measured is not the largest. And a channel loses to a mutex even where nobody has to be woken (50.48 vs 32.36 ns): it is a mutex plus a queue. A channel is chosen not for speed but because it hands over ownership of a value.
Closed and nil
| what | result |
|---|---|
| receive from a closed channel with a remainder | hands over the remainder, ok = true |
| receive from a closed empty channel | zero value, ok = false |
| send on a closed channel | panic send on closed channel |
close twice | panic close of closed channel |
close(nil) | panic close of nil channel |
| send/receive on nil | blocks forever, no panic |
Hence the rule "the sender closes": a receiver has no safe way to close a channel — the sender will not learn about it and will crash.
And blocking on a nil channel is a working technique: assigning nil disables
a select branch without breaking the select itself.
if !ok {
in = nil // the source ran out — the branch is never ready again
continue
}Goroutine leaks
ch := make(chan result) // UNBUFFERED
go func() { ch <- work() }()
select {
case r := <-ch: return r
case <-ctx.Done(): return ctx.Err() // and the goroutine stays forever
}Goroutines are not garbage collected — they must exit on their own. On timeout the function returned, nobody reads the channel, and the goroutine stands on its send until the process ends, together with everything its stack holds.
One character fixes it: make(chan result, 1). The sender has somewhere to put
the result and will finish even if nobody wants it any more.
TL;DR
The whole article rests on one thing: a channel has three parts — a ring buffer, an ordinary mutex, and two queues of waiting goroutines. Which of the three is working at a given moment explains all the rest.
The queue is working — a receiver is already parked in recvq. Then the
value goes past the buffer, copied straight into that goroutine's stack, and
it shows from the program: len(ch) stays zero after the send.
The buffer is working — there is no receiver. Then the buffer does not remove blocking, it defers it by exactly its capacity. And bigger is not faster: 64 values — 57.91 ns, 1024 — 78.97. The curve is not monotone.
Only the mutex is working — there is nobody to wake. Then it shows what a
channel really is: 67.19 ns against 15.80 for a mutex and 6.553 for an
atomic — ×10.25. A channel is a mutex plus queue bookkeeping.
select locks every channel at once and shuffles the branches on each
entry. Two things follow: cancellation has no priority — its branch wins
14,992 times out of 30,000, exactly half — and the price grows with the branch
count: the second adds 35.6 ns, eight branches cost 391.4 against 49.85.
A queue nobody will take you out of is a leak. A blocked goroutine is never
collected: a hundred goroutines on a nil channel survived two garbage
collections. And -race does not find them.
What all of it is for is in the penultimate section: a channel carries not values but the visibility of writes.
A channel is usually drawn as a pipe: the sender puts a value in the queue, the
receiver takes it out. A convenient picture, and wrong in the most common case —
and because of it, it later makes no sense why a bigger buffer does not speed
things up, why select does not read its branches top to bottom, and why a
goroutine nobody reads from lives to the end of the process.
All of those questions have one answer, and it is in the structure. So this
article goes: first the three parts, then one consequence per part, then two
states of the same structure — a closed and a nil channel — then leaks as the
direct continuation of that. And at the end, what a channel is for at all: the
guarantees about memory visibility. The numbers were taken on go1.24.7; how to
reproduce them is in the last section.
Step one: what lives in the variable
unsafe.Sizeof(make(chan int)) // 8 — one pointer
unsafe.Sizeof(map[int]int{}) // 8
unsafe.Sizeof([]int{}) // 24Eight bytes: a pointer to an hchan in the runtime. Two consequences follow
at once. A channel passed into a function is the same channel (the pointer is
what gets copied). And a channel's nil is a genuine null pointer, not an
"empty channel" — what comes of that is below, and it is not what you expect.
Inside hchan is what people usually draw, plus one line they usually do not:
type hchan struct {
qcount uint // how many are in the buffer right now
dataqsiz uint // buffer size
buf unsafe.Pointer // the buffer itself
// ...
recvq waitq // who is waiting to receive
sendq waitq // who is waiting to send
lock mutex
}lock mutex. A channel is not lock-free and not a "lightweight primitive":
every send and every receive takes that mutex. Everything below about cost
grows out of this line.
Those three parts are the whole article:
- the buffer (
buf,qcount,dataqsiz) — where a value is put when there is nobody to take it; - the two queues (
recvq,sendq) — goroutines standing and waiting; - the mutex (
lock) — taken on every operation.
The last one is worth spelling out, because it is the one most often skipped. Now, part by part — one consequence each.
Consequence one (the queue is working): the value bypasses the buffer
Start with the most common case: the receiver has already arrived and is
standing in recvq.
A channel is drawn as a pipe: the sender puts a value into a queue, the receiver takes it out. In the most common case that is wrong.
If a receiver is already parked in recvq, the sender does not put the value
into the buffer. It copies it straight into the waiting goroutine's stack and
wakes it. The runtime has a dedicated function for this, and its comment says
where from and where to:
src is on our stack, dst is a slot on another stack
This is not an implementation detail you can afford not to know: an ordinary program can check it, with no runtime introspection at all.
ch := make(chan int, 4)
// Scenario 1: the receiver got into the queue first.
ready := make(chan struct{})
go func() { close(ready); <-ch }()
<-ready
time.Sleep(10 * time.Millisecond) // let it park
ch <- 1
fmt.Println(len(ch)) // 0
// Scenario 2: no receiver.
ch2 := make(chan int, 4)
ch2 <- 1
fmt.Println(len(ch2)) // 1Zero. The value arrived, the send completed — and the buffer is empty, because the value never passed through it.
That answers the question usually settled by guesswork: what is a buffer for. Not to make the handoff faster — when both sides keep up, the buffer does not take part at all. It is there so the sender does not stop when the receiver falls behind. The buffer size is not "the bigger the better" but an answer to one question: how many values is the receiver allowed to fall behind by.
The specification states the same thing as the condition under which a send may proceed:
A send on an unbuffered channel can proceed if a receiver is ready. A send on
a buffered channel can proceed if there is room in the buffer.
And the memory model states it as an exact bound on the permitted lag:
The kth receive from a channel with capacity C is synchronized before the
completion of the k+Cth send on that channel.
So a buffer of C values lets the sender run ahead by exactly C and not one
more. That is not asynchrony; it is a delay with a known bound.
Consequence two (the buffer is working): bigger is not faster
Since a buffer exists for the case where the receiver falls behind, the obvious conclusion is "make it big so it never blocks". The measurement does not support that.
One rule matters more than the numbers here: the tabs are not comparable with one another. A channel costs fundamentally different amounts depending on whether a goroutine has to be parked and woken. In the first two tabs it does — two goroutines are at work there; in the other three everything happens inside one. The difference is an order of magnitude. Putting them on one scale would mean comparing different amounts of work.
The buffer: the curve is not monotone
| buffer | ns/op |
|---|---|
| 0 | 208.0 |
| 1 | 163.2 |
| 8 | 89.65 |
| 64 | 57.91 |
| 1024 | 78.97 |
A buffer saves goroutine switches: as long as it holds values, the receiver need not park and the sender need not wake anyone. Up to some size that is a win. Past it there is nothing left to win — there were hardly any switches anyway — and it gets worse. The three-run ranges for 64 and 1024 do not overlap: 57.51–59.00 against 77.68–79.84.
Why it gets worse the measurement does not show, and there is nothing to guess here: the plausible story about the ring's cache footprint (8 KB against 512 bytes) runs into the fact that 8 KB fits in L1d. That is a hypothesis, not a result.
The number 64 is not worth memorising: it depends on the element size, the machine, and how far behind the receiver runs. What is worth memorising is that the relationship is not monotone: of the five sizes measured, the best is not the largest. "I'll set a big buffer so it definitely never blocks" is not a free precaution, and it does not do what it is meant to do either: the buffer fills up for exactly as long as the sender is the faster of the two.
Consequence three (only the mutex is working): a channel is a mutex plus bookkeeping
Now the case where neither the queue nor the buffer is working: both sides in one goroutine, nobody to wake. Here it shows what a channel is at its base.
| means | ns/op | vs atomic |
|---|---|---|
atomic.AddInt64 | 6.553 | ×1.00 |
| mutex | 15.80 | ×2.41 |
| channel | 67.19 | ×10.25 |
All three rows increment the same counter and produce the same number. "Do not communicate by sharing memory" gets read as "a channel instead of a mutex, always" — and this is the price of that reading where a counter is what is actually needed.
This is not an argument against channels. A channel's job is a different one: hand over ownership of a value and wake whoever is waiting. A mutex does neither. There is exactly one argument here: handing over ownership costs, and when there is nothing to hand over, you pay for nothing.
Element size
| element | size | ns/op |
|---|---|---|
struct{} | 0 B | 46.66 |
int64 | 8 B | 49.79 |
[128]byte | 128 B | 59.96 |
*payload | 8 B | 49.97 |
The value is copied twice: into the buffer and out of it. A 128-byte struct costs 20 % more than eight bytes.
A pointer costs exactly what an int64 costs — and that is precisely the row
to stop at. Having sent a pointer, you have sent not a value but access to
shared memory. Both goroutines now look at one object, and everything the
channel was chosen for is over. Sometimes that is a deliberate choice: a large
struct the receiver only reads. But a choice, not an optimisation — the
compiler will not find a data race here, and go test -race will find one only
if it happens on that run.
Consequence four (select locks every channel at once)
select works not with one channel but with all of them at once — and hence
both of its surprises: the absence of priority, and a price that grows with the
branch count.
Cancellation has no priority
The second place where intuition fails systematically.
Three equal branches are chosen uniformly — exactly what the specification promises:
If one or more of the communications can proceed, a single one that can
proceed is chosen via a uniform pseudo-random selection.
What is interesting is not that, but what follows from it in the most ordinary cancellation loop:
for {
select {
case <-ctx.Done(): // "it's first, so it has priority"
return ctx.Err()
case v := <-work:
handle(v)
}
}Measured: the context is cancelled before the loop, so its branch is ready on every pass. Over 30,000 passes it won 14,992 times. Exactly half. The generator is not seeded, so your counts will differ — what repeats is the share, not the number.
If work keeps arriving, such a loop will do on average one more iteration after cancellation, and with probability 1/1024 another ten. A test with one message never reproduces this. Production under load reproduces it every time.
Priority in a select is expressed not by the order of the branches but by a
separate choice made earlier:
for {
select {
case <-ctx.Done():
return ctx.Err()
default: // not ready — move on, don't block
}
select {
case <-ctx.Done():
return ctx.Err()
case v := <-work:
handle(v)
}
}In the source the mechanism is three lines, and what matters about them is
that they run on every entry into the select:
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)A Fisher–Yates shuffle. Nothing is cached between entries.
This is worth putting next to maps. There, iteration order is also called random, but the number of distinct orders in a map of up to 896 entries turns out to equal the number of entries: only the shift is random. Here the shuffle is real. Two occurrences of "randomised" in one language mean different things, and the difference is settled with a counter.
And that is why the price grows with the branch count
| how the receive is written | ns/op | vs previous |
|---|---|---|
no select | 49.85 | — |
select, 1 branch | 51.57 | +1.7 |
select, 2 branches | 87.17 | +35.6 |
select, 4 branches | 158.7 | +71.5 |
select, 8 branches | 391.4 | +232.7 |
The first branch is nearly free — and not because select is cheap but
because there is no select there. A select with one branch and no default
is unfolded by the compiler into a plain operation; in
cmd/compile/internal/walk/select.go it is written out: "optimization:
one-case select: single op". selectgo is not called at all in that case.
A real select begins with the second branch, and its very first appearance
costs 35.6 ns — nearly three quarters of the price of the receive itself. From
there up to four branches each further one costs about the same 36 ns (+71.5
for two), and at eight the price per branch grows by more than half: +232.7 for four,
that is 58 apiece.
The reason is what selectgo does on every entry. It shuffles the branches.
Then it sorts them — by channel address, so that locks are always taken in the
same order and no deadlock occurs:
simple heap sort, to guarantee n log n time and constant stack footprint
And then it takes the mutex on all of the channels at once, not only the
one that ends up firing. Eight branches on eight distinct channels means eight
acquisitions and eight releases, plus the shuffle, plus the sort. (sellock
skips repeats: two branches on one channel take the lock once.) Nothing carries
over between entries, because a select's set of channels may change from one
entry to the next.
The practical conclusion is more modest than it sounds: a select over two or
three branches costs tens of nanoseconds and does not deserve attention next
to any real work. But a select over eight branches in a hot loop is a line
item, and it shows up in a profile as runtime.selectgo.
States of the same structure: closed and nil
The four consequences of the three parts are covered. Now two states in which those same three parts behave differently.
A closed channel
ch := make(chan int, 4)
ch <- 7
close(ch)
v, ok := <-ch // 7, true — the remainder is handed over
v, ok = <-ch // 0, false — and from here on, always
v, ok = <-ch // 0, false
for range ch {} // 0 iterations, the loop finishesClosing does not discard what is in the buffer: a receive first hands over the
remainder and only then starts returning zeros. That is exactly why
for range ch is a correct way to drain a channel to the end.
Three things panic, and the panic texts differ:
| what | panic |
|---|---|
ch <- 1 on a closed channel | send on closed channel |
close(ch) twice | close of closed channel |
close(nil) | close of nil channel |
Hence the rule "the sender closes", usually presented as a matter of taste. It
is not taste. A receiver has no safe way to close a channel: the sender
will not learn about it and will crash on its next send, and checking "is it
closed" before sending is impossible — the channel can close between the check
and the send. With several senders, none of them can close it alone either:
that needs either a separate done channel or a sync.WaitGroup and a close
after Wait.
A nil channel: not a mistake but a tool
var c chan int // nil
c == nil // true
len(c), cap(c) // 0, 0
c <- 1 // blocks FOREVER
<-c // blocks FOREVER
close(c) // panicsSending on and receiving from a nil channel neither panics nor returns an
error — it parks the goroutine forever. In the runtime that is a gopark with
the reason waitReasonChanSendNilChan for a send and
waitReasonChanReceiveNilChan for a receive, and nobody will ever wake that
goroutine.
It sounds like a trap, and in a for { <-c } loop that is what it is. But the
same property has a standard use that cannot be written without it: a nil
channel disables a select branch.
for in != nil || out != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // the source ran out — branch disabled
continue
}
pending = append(pending, v)
out = downstream
case out <- pending[0]:
pending = pending[1:]
if len(pending) == 0 {
out = nil // nothing to hand over — branch disabled
}
}
}Assigning nil removes a case from the choice without breaking the select
itself and without requiring flags or a second copy of the loop with a
different set of branches. The branch stays in the source and is simply never
ready.
A queue nobody will take you out of: goroutine leaks
The nil channel from the previous section is the extreme case of a general
rule: a goroutine that joined a queue nobody will take it out of stands there
until the process ends. And that is the ordinary leak in Go.
before := runtime.NumGoroutine() // 1
for i := 0; i < 100; i++ {
go func() { var c chan int; <-c }()
}
runtime.GC()
runtime.GC()
runtime.NumGoroutine() // 101The garbage collector collects objects, not goroutines:
Goroutines are not garbage collected; they must exit on their own.
A goroutine blocked forever stays alive together with its stack and everything that stack refers to:
This is a resource leak: goroutines consume memory and runtime resources, and
heap references in goroutine stacks keep data from being garbage collected.
The commonest way to arrange this by accident is to return before reading from a channel a goroutine you started is writing to:
func fetch(ctx context.Context) (result, error) {
ch := make(chan result) // UNBUFFERED
go func() { ch <- work() }() // the sender will stop here
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return result{}, ctx.Err() // and the goroutine stays behind
}
}On timeout the function returned, nobody reads ch any more, and the goroutine
inside stands on its send until the process ends — together with everything
work() holds on to. Under load with timeouts that is a leak growing linearly
with the number of requests.
One character fixes it:
ch := make(chan result, 1) // the sender has somewhere to put it and leaveThe sender puts the value in the buffer and finishes even if the result is no longer wanted; the buffer is collected along with the channel. This is the case where a buffer is added neither for speed nor for the receiver's lag, but so that the sender can end.
How this leak is seen
There is no ready-made "goroutine leak detector" in the standard distribution:
no flag like -race, no package. There is a counter and a profile — observation
rather than diagnosis. The difference between the two shows on the same hundred
leaked goroutines (bench/gochan/leakdetect.go).
runtime.NumGoroutine answers "how many", not "where". Its count is the one
given above — 1 before, 101 after, and two garbage collections change nothing.
One number, and it does not say which line is at fault.
The goroutine profile answers "where". It groups goroutines by stack and
puts a count in front of each group:
goroutine profile: total 101
100 @ 0x46ddae 0x40c5a5 0x40c152 0x4e94b9 0x474c61
# 0x4e94b8 main.leak.func1+0x18 bench/gochan/leakdetect.go:67
The addresses are each run's own, and the path to the file is shortened here to its path in the repository. A hundred on one stack is the address of the leak, down to the line. What that profile holds is stated in the package documentation:
goroutine — stack traces of all current goroutines.
You can take it from inside the program (pprof.Lookup("goroutine")), or in a
running service over HTTP via net/http/pprof, with no rebuild. Next to it sit
runtime.Stack(buf, true) for a dump into the program's own memory and
GOTRACEBACK=all for a dump of every goroutine on a crash.
-race does not catch the leak, and that is not a shortcoming. Checked: a
test with an outright leak passes under -race without a single complaint.
ok leaky 1.011s
The seconds are one run's wall clock; yours will differ. The word that matters is
ok.
The race detector looks for unsynchronised access to memory. A goroutine blocked forever touches nothing, so there is nothing for it to catch: a different job, not a missed one.
What does fail the test is a before-and-after check inside it. Two dozen lines, using nothing but the standard library:
--- FAIL: TestLeaks (0.51s)
FAIL
FAIL leaky 0.516s
The line elided above is the test's own message, in Russian, reporting 2
goroutines before and 12 after. A control run of the same program, with the
channel closed, passes. This is exactly the check the ecosystem libraries
automate; the best-known of them is go.uber.org/goleak. It was not run here, so
for what it does and how, see its own documentation: what this section measured
is only what the standard distribution gives you.
Order: preserved within a sender, absent between senders
A channel is a queue, and a queue has an order. But there is one queue and several goroutines joining it, and the scheduler decides that. What repeats and what does not is visible from a run:
received 20 values from 4 senders
first ten: [3.0 3.1 3.2 3.3 3.4 0.0 0.1 0.2 0.3 0.4]
sender 0: [0.0 0.1 0.2 0.3 0.4] — order preserved
sender 1: [1.0 1.1 1.2 1.3 1.4] — order preserved
sender 2: [2.0 2.1 2.2 2.3 2.4] — order preserved
sender 3: [3.0 3.1 3.2 3.3 3.4] — order preserved
Which sender comes out first is up to the scheduler, and on a re-run the line will be different. What repeats is not that line but the fact that each sender's own order is intact.
A channel is a queue, and within a single sender the order always holds. Between senders there is none and can be none: who joins the queue first is up to the scheduler. Sorting the output of a multi-sender channel is pointless — order has to be imposed on the way in, or carried as a sequence number inside the value.
What all of it is for: a channel carries the visibility of writes
And last — what the mutex and the queues in the structure are needed for at all.
A channel carries not only values but the visibility of writes, and that is a separate guarantee. The memory model states it in four rules.
The first is the one channels are most often taken for:
A send on a channel is synchronized before the completion of the corresponding
receive from that channel.
Everything the goroutine wrote before the send, the receiver is obliged to see after the receive. Which is also where the price of sending a pointer, from the section above, comes from: the guarantee covers writes made before the send and not one made after. A sender that keeps writing into the object it handed over is left with a race, and the channel does not protect against it.
The second is about closing. It gives the same guarantee without passing a single value:
The closing of a channel is synchronized before a receive that returns a zero
value because the channel is closed.
That is why the done := make(chan struct{}) + close(done) idiom is not merely
a convenient signal: it carries memory, not data.
The third reverses the direction, and only without a buffer:
A receive from an unbuffered channel is synchronized before the completion of
the corresponding send on that channel.
Here the guarantee runs from the receiver to the sender. The memory model
draws the boundary in the same breath: change the channel to make(chan int, 1)
and the same program guarantees nothing.
The fourth is the rule about the kth receive and the (k + C)th send that the cost of a buffer was measured against above. It has a second reading too: it is from this rule, not from practice, that the semaphore on a buffered channel grows — the number of values in the channel is the number of places taken, the capacity is the limit on concurrency.
One caveat about the source, which matters if you are going to quote it. The
wording of the fourth rule on go.dev/ref/mem and in the copy that ships with
go1.24.7 ($GOROOT/doc/go_mem.html) differs textually: the site has
The kth receive from a channel with capacity C … the k+Cth send on that channel,
the distribution has The kth receive on a channel with capacity C … the k+Cth send from that channel completes. The meaning is the same; quote verbatim from
whichever copy is open in front of you.
What should reproduce, and what should not
The numbers in this article were taken on go1.24.7 linux/amd64, Intel Xeon
2.10GHz, GOMAXPROCS = 2. The scripts and the records of the runs — with the
commands and the three-run spreads — open from here:
bench/gochan/cost_test.go and bench/gochan/behaviour.go. One more measures no time at
all: bench/gochan/leakdetect.go prints the goroutine count, the profile, and
the results of tests with and without a leak.
Do not expect your numbers to match these. Expect the ratios within a block to match, and above all expect the buffer to be non-monotone: the best of the sizes measured is not the largest.
One caveat about the first tab specifically: two goroutines on two cores. The
cost of a handoff depends on whether they landed on different processors and
whether the scheduler has to wake a thread; on a machine with a different core
count the ratios will differ. The blocks about select, the mutex and element
size run inside one goroutine and barely depend on it.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- The whole article rests on one thing: a channel has three parts — a ring buffer, an ordinary mutex, and two queues of waiting goroutines. Which of the three is working at a given moment explains all the rest.
- The queue is working — a receiver is already parked in
recvq. Then the value goes past the buffer, copied straight into that goroutine's stack, and it shows from the program:len(ch)stays zero after the send. - The buffer is working — there is no receiver. Then the buffer does not remove blocking, it defers it by exactly its capacity. And bigger is not faster: 64 values — 57.91 ns, 1024 — 78.97. The curve is not monotone.
- Only the mutex is working — there is nobody to wake. Then it shows what a channel really is: 67.19 ns against 15.80 for a mutex and 6.553 for an
atomic— ×10.25. A channel is a mutex plus queue bookkeeping. selectlocks every channel at once and shuffles the branches on each entry. Two things follow: cancellation has no priority — its branch wins 14,992 times out of 30,000, exactly half — and the price grows with the branch count: the second adds 35.6 ns, eight branches cost 391.4 against 49.85.- A queue nobody will take you out of is a leak. A blocked goroutine is never collected: a hundred goroutines on a
nilchannel survived two garbage collections. And-racedoes not find them. - What all of it is for is in the penultimate section: a channel carries not values but the visibility of writes.
In fact
- Only if there is no receiver yet. If one is already parked in
recvq, the value is copied from the sender's stack straight into the receiver's, skipping both the buffer and the heap. An ordinary program can see this: on a channel with room for four, after a successful send to a waiting receiverlen(ch)is still zero, whereas with no receiver it becomes one. The runtime has a dedicated function for this case,sendDirect, and its comment says where from and where to: src is on our stack, dst is a slot on another stack. - The order of the branches in the source means nothing. Measured: the context was cancelled BEFORE the loop, so its branch was ready on every pass and stood first; over 30,000 passes it won 14,992 times — exactly half. The specification says the same in words: If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection. Priority can only be expressed by a separate
selectwith adefault, run before the main one. - The relationship is not monotone: of the five sizes measured, the best is not the largest. Measured on handing a value from goroutine to goroutine: unbuffered 208.0 ns, buffer 1 — 163.2, buffer 8 — 89.65, buffer 64 — 57.91, buffer 1024 — 78.97. The three-run ranges for 64 and 1024 do not overlap: 57.51–59.00 against 77.68–79.84. A buffer saves goroutine switches; WHY it gets worse past 64 the measurement does not show — the guess about the ring's cache footprint stays a guess. Nor does it add safety: the memory model defines a buffer as a permitted lag of exactly C values — The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel. Once the buffer is full, the sender blocks just as it would without one.
- The first half is true, the second is not. On an unbuffered channel a send really does not complete until a receiver takes the value. But a buffer gives not asynchrony but a bounded delay: the sender is allowed to run ahead by exactly the buffer's capacity and not one value more. Go has no asynchronous send at all — it has
selectwith adefault, which is a refusal to send when there is nowhere to put the value. - A channel is a mutex — plus a ring buffer and two queues of waiters; the
hchanstruct contains an ordinarylock mutex. Measured where nobody has to be parked or woken: putting a value in and taking it back costs 50.48 ns against 32.36 for a pair of mutex acquisitions — 1.56× as much. And on a shared counter, where a goroutine switch does take part, a channel costs 67.19 ns against 15.80 for a mutex and 6.553 foratomic.AddInt64. This is not an argument against channels: a channel's job is a different one — hand over ownership of a value and wake whoever is waiting. It is an argument against reaching for a channel where a counter is what you need. - The first branch is nearly free (51.57 ns against 49.85 for a receive with no
select) — but not becauseselectis cheap: with one branch and nodefaultthe compiler unfolds it into a plain operation (optimization: one-case select: single op) andselectgois never called. A realselectbegins with the second branch and immediately adds 35.6 ns — nearly three quarters of the price of the receive itself. Up to four branches each further one costs about the same 36 ns (158.7 at four), and at eight the price per branch grows by more than half (58 against 36): 391.4. The reason is whatselectgodoes on EVERY entry: it shuffles the branches, sorts them by channel address (simple heap sort, to guarantee n log n time and constant stack footprint) and takes the mutex on all of the channels at once, not just the one that fires. Nothing is cached between entries. - Only
close(nil)panics. Sending on and receiving from a nil channel block forever, and that is standard behaviour — it is what the "disable aselectbranch" technique is built on: assigningnilto a channel removes a case from the choice without touching theselectitself. That is how you write a loop whose source and sink run out at different times. The danger is not the capability but tripping it by accident:var ch chan intwithout amakegives a nil channel, and a loop over it will stall in silence. - Never: Goroutines are not garbage collected; they must exit on their own. Measured: 100 goroutines waiting on a nil channel survived two garbage collections —
NumGoroutinewent from 1 to 101 and stayed at 101 after both. The goroutine's stack stays alive along with it, and so does everything it refers to. The commonest way to arrange this by accident is to return on a timeout before reading from an unbuffered channel a goroutine you started is writing to. A one-slot buffer fixes it. - Only within a single sender. Measured with four senders of five values each: every sender's own five arrived strictly in order, but between senders the order is arbitrary — the fourth sender's values came out first. A channel is indeed a queue, but who joins it first is up to the scheduler. If order between sources matters, it has to be imposed on the way in or carried as a number inside the value.
What is covered
- Step one: what lives in the variable
- Consequence one (the queue is working): the value bypasses the buffer
- Consequence two (the buffer is working): bigger is not faster
- Consequence three (only the mutex is working): a channel is a mutex plus bookkeeping
- Consequence four (select locks every channel at once)
- States of the same structure: closed and nil
- A queue nobody will take you out of: goroutine leaks
- Order: preserved within a sender, absent between senders
- What all of it is for: a channel carries the visibility of writes
- What should reproduce, and what should not
Common misconceptions
A sent value goes into the buffer first, and the receiver takes it from there
Only if there is no receiver yet. If one is already parked in recvq, the value is copied from the sender's stack straight into the receiver's, skipping both the buffer and the heap. An ordinary program can see this: on a channel with room for four, after a successful send to a waiting receiver len(ch) is still zero, whereas with no receiver it becomes one. The runtime has a dedicated function for this case, sendDirect, and its comment says where from and where to: src is on our stack, dst is a slot on another stack
.
A case <-ctx.Done() written first fires before the others
The order of the branches in the source means nothing. Measured: the context was cancelled BEFORE the loop, so its branch was ready on every pass and stood first; over 30,000 passes it won 14,992 times — exactly half. The specification says the same in words: If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection
. Priority can only be expressed by a separate select with a default, run before the main one.
A bigger buffer is faster and safer
The relationship is not monotone: of the five sizes measured, the best is not the largest. Measured on handing a value from goroutine to goroutine: unbuffered 208.0 ns, buffer 1 — 163.2, buffer 8 — 89.65, buffer 64 — 57.91, buffer 1024 — 78.97. The three-run ranges for 64 and 1024 do not overlap: 57.51–59.00 against 77.68–79.84. A buffer saves goroutine switches; WHY it gets worse past 64 the measurement does not show — the guess about the ring's cache footprint stays a guess. Nor does it add safety: the memory model defines a buffer as a permitted lag of exactly C values — The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel
. Once the buffer is full, the sender blocks just as it would without one.
An unbuffered channel is synchronous, a buffered one asynchronous
The first half is true, the second is not. On an unbuffered channel a send really does not complete until a receiver takes the value. But a buffer gives not asynchrony but a bounded delay: the sender is allowed to run ahead by exactly the buffer's capacity and not one value more. Go has no asynchronous send at all — it has select with a default, which is a refusal to send when there is nowhere to put the value.
A channel is a lightweight primitive, cheaper than a mutex
A channel is a mutex — plus a ring buffer and two queues of waiters; the hchan struct contains an ordinary lock mutex. Measured where nobody has to be parked or woken: putting a value in and taking it back costs 50.48 ns against 32.36 for a pair of mutex acquisitions — 1.56× as much. And on a shared counter, where a goroutine switch does take part, a channel costs 67.19 ns against 15.80 for a mutex and 6.553 for atomic.AddInt64. This is not an argument against channels: a channel's job is a different one — hand over ownership of a value and wake whoever is waiting. It is an argument against reaching for a channel where a counter is what you need.
select is syntactic sugar; an extra branch costs nothing
The first branch is nearly free (51.57 ns against 49.85 for a receive with no select) — but not because select is cheap: with one branch and no default the compiler unfolds it into a plain operation (optimization: one-case select: single op
) and selectgo is never called. A real select begins with the second branch and immediately adds 35.6 ns — nearly three quarters of the price of the receive itself. Up to four branches each further one costs about the same 36 ns (158.7 at four), and at eight the price per branch grows by more than half (58 against 36): 391.4. The reason is what selectgo does on EVERY entry: it shuffles the branches, sorts them by channel address (simple heap sort, to guarantee n log n time and constant stack footprint
) and takes the mutex on all of the channels at once, not just the one that fires. Nothing is cached between entries.
Sending on or receiving from a nil channel is an error
Only close(nil) panics. Sending on and receiving from a nil channel block forever, and that is standard behaviour — it is what the "disable a select branch" technique is built on: assigning nil to a channel removes a case from the choice without touching the select itself. That is how you write a loop whose source and sink run out at different times. The danger is not the capability but tripping it by accident: var ch chan int without a make gives a nil channel, and a loop over it will stall in silence.
A goroutine blocked on a channel will be garbage collected
Never: Goroutines are not garbage collected; they must exit on their own
. Measured: 100 goroutines waiting on a nil channel survived two garbage collections — NumGoroutine went from 1 to 101 and stayed at 101 after both. The goroutine's stack stays alive along with it, and so does everything it refers to. The commonest way to arrange this by accident is to return on a timeout before reading from an unbuffered channel a goroutine you started is writing to. A one-slot buffer fixes it.
A channel preserves the order of values
Only within a single sender. Measured with four senders of five values each: every sender's own five arrived strictly in order, but between senders the order is arbitrary — the fourth sender's values came out first. A channel is indeed a queue, but who joins it first is up to the scheduler. If order between sources matters, it has to be imposed on the way in or carried as a number inside the value.
Check yourself
A channel has room for 4. A receiver is already waiting on a receive. The sender runs ch <- 1. What is len(ch) immediately afterwards?
Sources & further reading
6 SOURCES
- The Go Specification — Channel types, Send statements, Receive operator, Select statementsOfficial documentation. The definition: «A channel provides a mechanism for concurrently executing functions to communicate by sending and receiving values of a specified element type». Capacity is defined as the buffer size: «The capacity, in number of elements, sets the size of the buffer in the channel», and this is the one place that states when a send does not block: «A send on an unbuffered channel can proceed if a receiver is ready. A send on a buffered channel can proceed if there is room in the buffer». On nil, plainly: «A send on a nil channel blocks forever». And the selection rule this article measures: «If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection».https://go.dev/ref/spec
- runtime/chan.go — hchan, sendDirect, nil-channel behaviourGo source code. The channel struct with all of its fields, including an ordinary mutex: `lock mutex` — a channel is not lock-free. A dedicated function for the case where a receiver is already waiting, whose comment says where the value travels from and to: «src is on our stack, dst is a slot on another stack». Also the three panics, each with its own text: `send on closed channel`, `close of closed channel`, `close of nil channel`.https://go.dev/src/runtime/chan.go
- runtime/select.go — pollorder, lockorder, sellockGo source code. The three lines all of select's behaviour follows from: `j := cheaprandn(uint32(norder + 1))` and the swap of `pollorder` entries after it — a Fisher-Yates shuffle performed on every entry into a select. Next to it, the comment covering the other half of the work: «sort the cases by Hchan address to get the locking order» and «simple heap sort, to guarantee n log n time and constant stack footprint». That is what explains the measured growth in cost with the number of branches.https://go.dev/src/runtime/select.go
- The Go Memory Model — Channel communicationOfficial documentation. The formal statement that a buffer is not asynchrony but a permitted lag of exactly C values: «The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel». And the two rules by which a channel replaces a mutex: «A send on a channel is synchronized before the completion of the corresponding receive from that channel» and «A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel».https://go.dev/ref/mem
- Go Concurrency Patterns: Pipelines and cancellationOfficial documentation. The source that names the goroutine leak outright and explains it: «Goroutines are not garbage collected; they must exit on their own». And why it is not harmless: «This is a resource leak: goroutines consume memory and runtime resources, and heap references in goroutine stacks keep data from being garbage collected».https://go.dev/blog/pipelines
- runtime/pprof — the goroutine profileGo source code. What the profile used in the article to locate a leak actually holds: "goroutine - stack traces of all current goroutines". Unlike `runtime.NumGoroutine`, the profile groups goroutines by stack and puts a count in front of each group, so it answers not "how many" but "how many and where".https://pkg.go.dev/runtime/pprof#Profile