Deep Engineering
Advanced·Published·45 MIN

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

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

GO
unsafe.Sizeof(make(chan int))   // 8 — one pointer
unsafe.Sizeof(map[int]int{})    // 8
unsafe.Sizeof([]int{})          // 24

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

GO
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

runtime/chan.go, the sendDirect function

This is not an implementation detail you can afford not to know: an ordinary program can check it, with no runtime introspection at all.

GO
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))  // 1

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

The Go Specification, Send statements

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.

The Go Memory Model, Channel communication

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

bufferns/op
0208.0
1163.2
889.65
6457.91
102478.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.

meansns/opvs atomic
atomic.AddInt646.553×1.00
mutex15.80×2.41
channel67.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

elementsizens/op
struct{}0 B46.66
int648 B49.79
[128]byte128 B59.96
*payload8 B49.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.

The Go Specification, Select statements

What is interesting is not that, but what follows from it in the most ordinary cancellation loop:

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

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

GO
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 writtenns/opvs previous
no select49.85
select, 1 branch51.57+1.7
select, 2 branches87.17+35.6
select, 4 branches158.7+71.5
select, 8 branches391.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

runtime/select.go, the comment on building lockorder

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

GO
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 finishes

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

whatpanic
ch <- 1 on a closed channelsend on closed channel
close(ch) twiceclose 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

GO
var c chan int    // nil
c == nil          // true
len(c), cap(c)    // 0, 0
c <- 1            // blocks FOREVER
<-c               // blocks FOREVER
close(c)          // panics

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

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

GO
before := runtime.NumGoroutine()   // 1
for i := 0; i < 100; i++ {
    go func() { var c chan int; <-c }()
}
runtime.GC()
runtime.GC()
runtime.NumGoroutine()             // 101

The garbage collector collects objects, not goroutines:

Goroutines are not garbage collected; they must exit on their own.

Go Concurrency Patterns: Pipelines and cancellation

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.

Go Concurrency Patterns: Pipelines and cancellation

The commonest way to arrange this by accident is to return before reading from a channel a goroutine you started is writing to:

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

GO
ch := make(chan result, 1)   // the sender has somewhere to put it and leave

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

runtime/pprof — Profile

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.

The Go Memory Model — Channel communication

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.

Ibid.

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.

Ibid.

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.

Common misconceptions

Claim

A sent value goes into the buffer first, and the receiver takes it from there

Actually

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.

Claim

A case <-ctx.Done() written first fires before the others

Actually

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.

Claim

A bigger buffer is faster and safer

Actually

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.

Claim

An unbuffered channel is synchronous, a buffered one asynchronous

Actually

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.

Claim

A channel is a lightweight primitive, cheaper than a mutex

Actually

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.

Claim

select is syntactic sugar; an extra branch costs nothing

Actually

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.

Claim

Sending on or receiving from a nil channel is an error

Actually

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.

Claim

A goroutine blocked on a channel will be garbage collected

Actually

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.

Claim

A channel preserves the order of values

Actually

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

Question 1 of 5

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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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