Channels in Go: a value that bypasses the buffer, a select with no priorities, and a price paid in parking
A channels interview climbs a ladder: what is in the variable — where does the value actually go — what happens with a closed and a nil channel — how does select choose — what does a buffer cost — where do goroutines leak. This lesson climbs all of it: why a waiting receiver gets the value straight past the buffer, why the cancellation branch has no priority in select, and why an unbuffered channel costs three times more — though not because "channels are slow".
Full technical treatment
TL;DR
A channel is a link between two goroutines: one sends a value, the other receives it. While there is no receiver the sender waits; while there is no value the receiver waits. The whole topic grows out of that waiting: a channel in Go is less a way to move data than a way to agree on a moment.
Hence the main consequence: a buffer does not remove the waiting, it defers
it — by exactly its capacity. And when a receiver is already waiting the value
goes past the buffer, straight to it; that is observable from the program:
len(ch) stays zero. The cost of an unbuffered channel is not "a slow channel"
but stopping and waking goroutines: measured at 198,391 ns against 64,570 for a
thousand values — a factor of 3.1.
Beyond that is what separates knowing from having read. select has no
priority by branch order: one of the ready branches is chosen uniformly at
random, so putting case <-ctx.Done() first does nothing. A closed channel
can be read: the already-buffered values are drained first, and only then comes
the zero value with ok == false; sending to a closed channel and closing twice
both panic. A nil channel blocks forever — and that is a tool: a select
branch on it is never chosen. Underneath all of it lies a runtime structure — a
buffer, a mutex and two queues of waiting goroutines — and chan T is a
pointer to it: which is why a channel passed into a function is the same
channel, and why a nil channel is simply a pointer to nothing. And the thing
forgotten most often: a goroutine blocked on a channel forever is not
collected by the garbage collector. This is the ordinary leak in Go, and
go test -race does not find it.
- a goroutine is a separate piece of work that proceeds on its own while the rest of the program proceeds too;
- two goroutines sometimes need a value from each other: one has something to hand over, the other needs to get it;
- goroutines do not reach the relevant line at the same moment, so somebody will have to wait.
- how a channel is built inside: a ring buffer, a mutex, the queues of waiters and the parking of goroutines;
select,default,close, thenilchannel,lenandcapof a channel;- what happens-before is and what a channel guarantees about memory.
What is actually being asked
The ladder is almost always this:
- "What is a channel?" — testing whether you say "a pointer to a structure with a mutex" or stop at the word "pipe".
- "How does buffered differ from unbuffered?" — testing whether you understand it is about parking, not speed.
- "What happens sending to a closed channel? Receiving? From a nil one?" — the twelve-cell table.
- "How does select choose a branch?" — a trap: not top to bottom.
- "Where is the leak here?" (with code) — testing whether you know a goroutine on a blocked channel lives forever.
The lesson follows that ladder.
Base: one goroutine sent, another received
The simplest model of a channel — the one an answer is worth starting from — consists of two actions and nothing else.
There are two goroutines. One reaches the line ch <- v and sends a value.
The other reaches <-ch and receives it. The value moves from the first to
the second, and both carry on with their own business.
ch := make(chan int)
go func() { ch <- 42 }() // one sent
v := <-ch // the other receivedA channel here is not a variable that gets read and written but a meeting place: until somebody arrives from the other side, no value passes through it.
And here is the question this lesson is about: what happens when the send and
the receive do not coincide in time? Goroutines proceed at their own pace and
reach their lines whenever they do: the sender may arrive at ch <- v a second
before the receiver reaches <-ch — or a minute after.
The answer is simple: whoever arrives first waits for the other. A sender with nobody to hand the value to stops and stands there until a receiver turns up; a receiver with nothing to take stands until a value turns up.
Everything else in the topic rests on that waiting. A buffer is a way to postpone
the sender's wait. select is a way to wait on several channels at once.
Closing is a way to tell everyone waiting that there is nothing left to wait for.
That is already enough to answer the basic interview question, "what is a
channel". What follows is how a channel behaves in its other states (the buffer
is full, the channel is closed, there is no channel at all), why select has no
order, what the waiting costs and where goroutines leak on it.
Mechanism 1: the behaviour table — the whole topic on one screen
A channel is worth working through not from its construction but from what it does in each of its states. This is literally an interview question, and it does not stick as a list — but it does stick as a table, because the cells in it are connected.
Here is the same pair of actions from the Base, laid out across the channel's states:
| channel state | ch <- v — send | <-ch — receive |
|---|---|---|
| unbuffered | waits for a receiver; the handover happens at the meeting | waits for a sender |
| buffered, room to spare | puts the value in and moves on | takes a value; if the buffer is empty, waits |
| buffered, full | waits until room frees up | takes a value and frees room |
| closed | panic | does not wait: drains the buffered values first, then the zero value with ok == false |
nil (never created) | blocks forever | blocks forever |
It reads row by row, and every row is a separate interview question. A third
operation, close, adds one more dimension to this; the full twelve-cell table
is on the figure. Click the cells — the explanation underneath says why each one
is what it is:
Out of twelve cells, what is worth learning is not twelve facts but two rules:
- Only sending and closing panic, and only where the channel is absent or
already closed. Three panics:
send on closed,close of closed,close of nil. - Receiving never panics. Which is exactly what makes closing usable as a broadcast signal: every waiting reader wakes at once.
And one cell forgotten more than any other: closing does not discard what is
already in the buffer. The specification says so word for word — a receive
from a closed channel yields the zero value after any previously sent values
have been received
. The buffered values drain first with ok == true, and
only then does the zero value with false begin. The task at the end of this
lesson is built on that.
A nil channel is a tool, not a mistake. Since a select branch on a nil
channel is never chosen, nilling a channel out is the standard way to switch a
branch off:
for {
select {
case v, ok := <-in:
if !ok {
in = nil // branch off, the select lives on
continue
}
// …
case <-ctx.Done():
return
}
}Without that trick a closed channel would spin the select for nothing: a
receive from it is always ready.
Mechanism 2: an unbuffered channel is a rendezvous
The table shows the main property of an unbuffered channel: a send and a receive happen at the same moment or not at all. It is not "the sender put it in, the receiver took it out later" — it is a meeting:
Hence the practical reason an unbuffered channel is chosen at all: it provides
not the transfer of data but agreement on a moment. A sender that has
returned from ch <- v knows the receiver is already there.
And hence, too, what surprises people when the construction is examined.
A channel is nearly always drawn as a pipe: the sender puts a value in the buffer, the receiver takes it out. In the most common case that is wrong.
If a receiver is already waiting, the value bypasses the buffer — copied straight from the sender's stack into the receiver's. The buffer takes no part.
This is not an abstraction about implementation: it is observable from the
program. Had the value gone through the buffer, len(ch) would be one. It
stays zero.
Hence the right answer to "then why have a buffer at all". A buffer is not for transfer speed but so the sender does not have to wait for a receiver. It does not remove blocking — it defers it by exactly its capacity.
Mechanism 3: a buffered channel is a bounded queue
A buffered channel behaves differently, and the difference fits in one sentence: it is a queue with a fixed capacity, and blocking happens only at its edges.
Hence the right answer to "what is the buffer for, then". A buffer is not for the speed of the transfer, it is so the sender does not have to wait for the receiver. It does not remove the blocking — it defers it by exactly its capacity.
Hence, too, two practical rules worth naming together. A buffer of one is almost always pointless: it removes the wait for the first value only. A very large buffer is harmful in a different way: it hides the receiver failing to keep up and turns a fast failure into slow memory growth. Two sizes make sense — the whole batch, if it is finite and known, and the expected burst, if this is a queue.
Mechanism 4: select does not read branches top to bottom
The most common code-review edit: "put case <-ctx.Done() first so cancellation
fires sooner". It does nothing.
The specification: when several communications are ready, a single one that
can proceed is chosen via a uniform pseudo-random selection
. Branch order in
the source means nothing.
When a cancelled context and ready work compete, cancellation wins exactly half
the time — no more. If cancellation genuinely must take priority, it is checked
in a separate select before the main one:
select {
case <-ctx.Done():
return ctx.Err()
default:
}Two more things about select that get asked next:
defaultmakes aselectnon-blocking. Without it aselectwith no ready branch waits; with it, it leaves immediately.select {}with no branches blocks forever. That is not a compile error but the idiom for "occupy this goroutine permanently".
Mechanism 5: where goroutines leak
The classic "find the leak" question, and the code is usually this:
func find(ctx context.Context) int {
ch := make(chan int) // unbuffered
go func() { ch <- search() }()
select {
case v := <-ch:
return v
case <-ctx.Done():
return -1 // we left, the goroutine stayed
}
}On timeout the function returns — and the goroutine inside is stuck forever on
ch <- search(): there is no receiver any more and no buffer.
And such a goroutine is not collected. That is the key fact: it is alive, its stack is alive, and so is everything it references. The garbage collector cannot collect blocked goroutines — it has no way to know nobody will ever wake them.
The cure is a buffer of one: the sender drops the result and leaves, even if there is no longer anyone to read it.
ch := make(chan int, 1)And separately, on what catches this. go test -race does not find this
leak — there is no race here, everything is correctly synchronized. It is caught
by counting: runtime.NumGoroutine() before and after, or the goroutine
profile from runtime/pprof, or goleak in tests. "The race detector will catch
it" is the wrong answer, and that is exactly the difference the question tests.
Mechanism 6: what a channel guarantees about memory
This side gets forgotten and is asked at more senior levels. A channel is not only transport but a synchronization point.
- A send is synchronized before the completion of the receive: everything the
sender wrote to memory before
ch <- vis visible to the receiver after<-ch. - Closing is synchronized before a receive that returned the zero value. So
close(done)is a legitimate way to publish data written before the close. - An unbuffered channel adds the reverse guarantee: the receive is synchronized before the completion of the send. Hence its second role, a rendezvous: the sender knows the receiver reached the meeting point.
The practical consequence: close(done) as a readiness signal needs neither a
mutex nor atomics around the data it publishes.
Deeper: what is underneath in the runtime
Everything above described what a channel does, and that is enough to answer correctly. What follows is how it is made inside; here the claims change kind, and all of it belongs to one particular version of the runtime.
chan T is a pointer to a runtime structure. Inside it: a ring buffer (if
one was requested), a mutex, and two queues of waiting goroutines — those
waiting to send and those waiting to receive.
Several answers follow at once:
- A channel passed into a function is the same channel. The pointer is
copied, the structure behind it is single. No
*chan Tis needed. - A channel is protected by a mutex inside. "A channel is lock-free" is wrong: every operation takes the structure's mutex. That is not where its speed comes from.
- A
nilchannel is a pointer to nothing. No buffer, no queues: nowhere to put a value, nobody to wake. All of its behaviour follows from that.
It is worth saying out loud what a channel is not. It is not a message queue
and not a way to move data faster: two goroutines through a channel are always
slower than one without it. What a channel buys is synchronization, and the
memory model states it outright: A send on a channel is synchronized before
the completion of the corresponding receive from that channel
. Everything
the sender wrote before sending is guaranteed visible to the receiver after
receiving.
The cost is parking, not "the channel"
And before the numbers — one distinction, without which "the goroutine blocked" sounds worse than it is.
A blocked goroutine does not mean a blocked OS thread. Parking is a scheduler
operation inside the process: the goroutine comes off its P, another takes its
place, the thread keeps working. The kernel neither knows nor participates. Which
is why "a thousand goroutines waiting on channels" is a normal state for a
program, not a thousand idle threads.
The cause is what matters here. An unbuffered channel really is more expensive, but what costs is not "the channel" — it is parking a goroutine: the sender stops, the scheduler takes it off the processor and puts another on, then back.
Measured on a thousand values passed between two goroutines:
| time | |
|---|---|
make(chan int) | 198,391 ns |
make(chan int, 1000) | 64,570 ns |
| ratio | 3.1 |
Three times is not "channels are slow", it is a thousand parkings against zero. The cause is easy to check: make the buffer smaller than the batch and the parkings come back proportionally.
And the boundary of that number is named right here: the 3.1 came out on one machine, two cores and one version of Go. The ratio depends on how many goroutines run at once and on whether the sender and the receiver land on the same core — so what travels from here is the cause, not the number. The rule is written this way: the cost of a channel grows with the number of parkings, and it has to be measured on your own load.
How to answer in an interview
Short answer: a channel is a link between two goroutines where the send and
the receive meet: while there is no receiver the sender waits, and the other way
round. A buffer does not remove that waiting, it defers it by exactly its
capacity. A closed channel can be read — the buffered values are drained first;
sending to it or closing it twice is not allowed. And select branches are
chosen uniformly at random, not top to bottom.
That is enough for a correct answer. What follows is what you add when the interviewer digs.
If the interviewer digs deeper
Answer "what is a channel" with the structure. "A pointer to a structure
with a buffer, a mutex and two queues of waiters." Nearly everything else
derives from that, including the behaviour of a nil channel.
Say "defers" about buffers, not "speeds up". "A buffer does not remove blocking, it defers it by its capacity; the cost of unbuffered is parking — I measured threefold on a thousand values." The second sentence turns memorized into verified.
Give the table as two rules. "Only sending and closing panic; receiving never panics, which is why closing works as a signal." Few people can recite twelve cells, and two rules reconstruct all of them.
Say "uniformly at random" about select. And add that branch order means
nothing, while cancellation priority is done with a separate select and a
default. That reads as experience instantly.
On the leak, name both the cause and the tool. "A blocked goroutine is not
collected; -race will not find it; it is caught by counting goroutines or by
the goroutine profile." Half of candidates answer "the race detector".
Next they ask
Who should close a channel, and why that side?
The sender — because only the sender knows no more values are coming. A receiver
cannot know that in principle, and closing from a receiver leads to a
send on closed channel panic in a sender that is still writing.
With several senders the close moves outside: usually a sync.WaitGroup over
all senders and a separate goroutine that closes the channel after Wait(). An
"is it closed yet" check does not work — another goroutine slips in between the
check and the close.
How do you tell "a zero arrived" from "the channel is closed"?
Only with the two-result form: v, ok := <-ch. On a closed channel ok is
false; on a received zero value it is true. The single-result form cannot
tell them apart, and that is a source of quiet bugs in channels of numbers.
Inside for range ch there is nothing to tell apart: the loop ends on close.
But there is a trap there too — a range over a channel nobody closes never
ends, and the goroutine hangs.
What buffer size should you pick?
Two answers make sense, and both are about meaning rather than a number. If the batch is finite and known — a buffer for the whole batch: then there is no parking at all. If this is a queue — a buffer for the expected burst, so a short peak does not block senders.
Two answers are bad. A buffer of one is almost always pointless: it removes the parking for a single value. A very large buffer is harmful differently — it hides the receiver failing to keep up and turns a fast failure into quietly growing memory.
Channel or mutex — which do you take?
The guide: a channel when data is passed between goroutines, a mutex when
state is shared. A counter everyone touches is a mutex or an atomic; a
pipeline where a value moves stage to stage is a channel.
It helps to be able to argue with "share memory by communicating" too: a channel has its own cost — the internal mutex plus parking — and on a simple counter it loses to an atomic operation by an order of magnitude. "Channels are always better, this is Go" is not the expected answer.
What happens if every goroutine blocks on channels?
The runtime notices and prints fatal error: all goroutines are asleep - deadlock!. But it can only notice a total stop: if even one goroutine is running
— a timer, an HTTP server — the blocked ones hang silently.
Hence something important in practice: in a real service a deadlock almost never
looks like a fatal error. It looks like a growing goroutine count, and only a
profile shows it.
Why does a select with one ready branch and a default sometimes take the default?
Not sometimes but never: if any branch is ready, default is not taken — it is
chosen only when none is. The confusion usually comes from elsewhere: a branch is
ready at the moment of the check, and between the check and the choice another
goroutine changes the state.
The practical consequence: a select with default is a snapshot, not a
guarantee. Building "if the channel is empty, do X" on it is not reliable.
Common misconceptions
on a send the value goes into the buffer and the receiver takes it from there
Only when there is no receiver yet. When a receiver is already waiting the value is copied straight into its stack, bypassing the buffer — and that is observable: len(ch) stays zero. Hence the right answer to "why have a buffer": not for transfer speed but so the sender does not have to wait.
a buffer removes blocking
It defers it by exactly its capacity. Once the buffer is full the sender parks just the same. That is why a buffer of one is almost always pointless, and a very large one is harmful: it hides the receiver failing to keep up, turning a fast failure into growing memory.
an unbuffered channel is slow because channels are slow
What costs is not the channel but parking a goroutine: stopping the sender and waking the receiver through the scheduler. Measured on a thousand values: 198,391 ns unbuffered against 64,570 with a buffer for the whole batch — a factor of 3.1. Shrink the buffer below the batch size and the parkings come back proportionally.
select checks branches top to bottom, so cancellation goes first
Branch order means nothing: the specification requires choosing among ready branches via a uniform pseudo-random selection
. A cancelled context against ready work wins exactly half the time. Cancellation priority is done with a separate select and a default before the main one.
closing a channel discards what was in it
It does not. The specification: a receive from a closed channel yields the zero value after any previously sent values have been received
. Every buffered value is drained first with ok == true, and only then does the zero value with false begin.
a nil channel is a bug and should be guarded against
It is a tool. A select branch on a nil channel is never chosen, so nilling a channel out is the standard way to switch a branch off without touching the select. Without that trick a closed channel would spin the loop for nothing: a receive from it is always ready.
a goroutine stuck on a channel will be garbage collected
It will not. A blocked goroutine is alive, its stack is alive, and so is everything it references: the runtime cannot know nobody will wake it. This is the ordinary leak in Go, and go test -race does not find it — there is no race. It is caught by counting goroutines or by the goroutine profile.
channels are always better than mutexes — this is Go
A channel has its own cost: the mutex inside the structure plus parking. On a shared counter it loses to an atomic operation by an order of magnitude. The guide is simple: a channel when data is passed between goroutines, a mutex or atomic when state is shared.
Practice
Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from a script run rather than assigned.
Practice · predict the output
ch := make(chan int, 2) ch <- 1 ch <- 2 close(ch) a, ok1 := <-ch b, ok2 := <-ch c, ok3 := <-ch fmt.Println(a, ok1) fmt.Println(b, ok2) fmt.Println(c, ok3) fmt.Println(len(ch), cap(ch))
Practice · estimate
Knowledge check
A goroutine sends into an unbuffered channel while a receiver is already waiting. What is len(ch) right after the handoff?
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
- A channel is a link between two goroutines: one sends a value, the other receives it. While there is no receiver the sender waits; while there is no value the receiver waits. The whole topic grows out of that waiting: a channel in Go is less a way to move data than a way to agree on a moment.
- Hence the main consequence: a buffer does not remove the waiting, it defers it — by exactly its capacity. And when a receiver is already waiting the value goes past the buffer, straight to it; that is observable from the program:
len(ch)stays zero. The cost of an unbuffered channel is not "a slow channel" but stopping and waking goroutines: measured at 198,391 ns against 64,570 for a thousand values — a factor of 3.1. - Beyond that is what separates knowing from having read.
selecthas no priority by branch order: one of the ready branches is chosen uniformly at random, so puttingcase <-ctx.Done()first does nothing. A closed channel can be read: the already-buffered values are drained first, and only then comes the zero value withok == false; sending to a closed channel and closing twice both panic. Anilchannel blocks forever — and that is a tool: aselectbranch on it is never chosen. Underneath all of it lies a runtime structure — a buffer, a mutex and two queues of waiting goroutines — andchan Tis a pointer to it: which is why a channel passed into a function is the same channel, and why anilchannel is simply a pointer to nothing. And the thing forgotten most often: a goroutine blocked on a channel forever is not collected by the garbage collector. This is the ordinary leak in Go, andgo test -racedoes not find it.
In fact
- Only when there is no receiver yet. When a receiver is already waiting the value is copied straight into its stack, bypassing the buffer — and that is observable:
len(ch)stays zero. Hence the right answer to "why have a buffer": not for transfer speed but so the sender does not have to wait. - It defers it by exactly its capacity. Once the buffer is full the sender parks just the same. That is why a buffer of one is almost always pointless, and a very large one is harmful: it hides the receiver failing to keep up, turning a fast failure into growing memory.
- What costs is not the channel but parking a goroutine: stopping the sender and waking the receiver through the scheduler. Measured on a thousand values: 198,391 ns unbuffered against 64,570 with a buffer for the whole batch — a factor of 3.1. Shrink the buffer below the batch size and the parkings come back proportionally.
- Branch order means nothing: the specification requires choosing among ready branches via a uniform pseudo-random selection. A cancelled context against ready work wins exactly half the time. Cancellation priority is done with a separate
selectand adefaultbefore the main one. - It does not. The specification: a receive from a closed channel yields the zero value after any previously sent values have been received. Every buffered value is drained first with
ok == true, and only then does the zero value withfalsebegin. - It is a tool. A
selectbranch on anilchannel is never chosen, so nilling a channel out is the standard way to switch a branch off without touching theselect. Without that trick a closed channel would spin the loop for nothing: a receive from it is always ready. - It will not. A blocked goroutine is alive, its stack is alive, and so is everything it references: the runtime cannot know nobody will wake it. This is the ordinary leak in Go, and
go test -racedoes not find it — there is no race. It is caught by counting goroutines or by thegoroutineprofile. - A channel has its own cost: the mutex inside the structure plus parking. On a shared counter it loses to an atomic operation by an order of magnitude. The guide is simple: a channel when data is passed between goroutines, a mutex or
atomicwhen state is shared.
What is covered
- What is actually being asked
- Base: one goroutine sent, another received
- Mechanism 1: the behaviour table — the whole topic on one screen
- Mechanism 2: an unbuffered channel is a rendezvous
- Mechanism 3: a buffered channel is a bounded queue
- Mechanism 4: select does not read branches top to bottom
- Mechanism 5: where goroutines leak
- Mechanism 6: what a channel guarantees about memory
- Deeper: what is underneath in the runtime
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
3 SOURCES
- The Go Programming Language Specification — Channel types, Send statements, Receive operator, CloseOfficial documentation. The behaviour the state × operation table is assembled from. On sending: “A send on a nil channel blocks forever” and “A send on a closed channel proceeds by causing a run-time panic”. On receiving: “Receiving from a nil channel blocks forever” and “A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value after any previously sent values have been received”. That last sentence is the one most often forgotten: the buffered values are drained first. On closing: “Closing a nil channel or closing a channel that has already been closed causes a run-time panic”. And on select: “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#Channel_types
- The Go Memory Model — Channel communicationOfficial documentation. The guarantees channels exist for, beyond moving values. “A send on a channel is synchronized before the completion of the corresponding receive from that channel”, and on closing: “The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed”. Separately for unbuffered channels: “A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel”.https://go.dev/ref/mem#chan
- Effective Go — ConcurrencyOfficial documentation. The line usually quoted by half: “Do not communicate by sharing memory; instead, share memory by communicating”. The same page notes that a channel has roles beyond carrying data: “A buffered channel can be used like a semaphore”.https://go.dev/doc/effective_go#concurrency