Channels
Channel mechanics, sending on closed channels, select, and the context package.
21 questions
JuniorTheoryVery commonWhat is a channel, and how do buffered and unbuffered channels differ?
What is a channel, and how do buffered and unbuffered channels differ?
A channel is a typed conduit that passes values between goroutines. An unbuffered channel synchronizes — a send blocks until a receiver is ready. A buffered channel (make(chan T, n)) holds up to n values; send blocks only when the buffer is full.
Common mistakes
- ✗Believing a buffered channel never blocks the sender — it blocks once the buffer is full
- ✗Thinking an unbuffered channel has a buffer of size one rather than zero
- ✗Assuming channels share memory instead of passing copies of values between goroutines
Follow-up questions
- →What value and
okflag does a receive return after the channel is closed? - →Why does an unbuffered send establish a happens-before relationship with the receive?
JuniorTheoryVery commonHow do you send and receive on a Go channel?
How do you send and receive on a Go channel?
A channel made with make(chan T) is a typed conduit between goroutines. You send with ch <- v and receive with v := <-ch; the arrow always points in the direction the value moves. On an unbuffered channel the send blocks until some other goroutine is ready to receive, and the receive blocks until a value arrives, so the two goroutines rendezvous and synchronize at that point. The element type T is fixed at creation.
Common mistakes
- ✗Pointing the
<-arrow the wrong way for send vs receive - ✗Believing an unbuffered send returns before a receiver is ready
- ✗Forgetting the channel's element type is fixed at
make
Follow-up questions
- →What does the comma-ok form
v, ok := <-chtell you? - →How does a receive establish a happens-before relationship with the send?
MiddleTheoryVery commonHow does a buffered channel change when a send blocks versus an unbuffered one?
How does a buffered channel change when a send blocks versus an unbuffered one?
An unbuffered channel has capacity 0, so every send blocks until a receiver is ready to take the value — the send and receive must rendezvous. A buffered channel from make(chan T, n) holds up to n values, so the first n sends complete without any waiting receiver and only block once the buffer is full. This decouples sender and receiver up to n items: the producer can run ahead, smoothing bursts, but does not give unlimited slack.
Common mistakes
- ✗Thinking an unbuffered channel has a one-slot buffer
- ✗Believing a buffered send never blocks the sender
- ✗Assuming a full buffer drops sends instead of blocking
Follow-up questions
- →When would you deliberately pick an unbuffered channel over a buffered one?
- →What does
cap(ch)versuslen(ch)report on a buffered channel?
MiddleTheoryVery commonWhat are the semantics of select, including the default case?
What are the semantics of select, including the default case?
select waits until one of its channel cases is ready; if several are ready it picks one uniformly at random. With a default case it never blocks — if no case is ready, default runs at once. An empty select{} blocks forever.
Common mistakes
- ✗Believing
selectcases have priority by source order — the choice is uniformly random - ✗Thinking a
defaultcase makesselectbusy-poll instead of returning immediately - ✗Forgetting that an empty
select{}blocks the goroutine forever
Follow-up questions
- →How do you implement a receive with timeout using
selectandtime.After? - →How does a
selecton actx.Done()channel let a goroutine react to cancellation?
JuniorTheoryCommonHow do you detect that a channel has been closed when receiving?
How do you detect that a channel has been closed when receiving?
Use the comma-ok form v, ok := <-ch: ok is false once the channel is closed and drained, and v is the element type's zero value. A for range ch loop is the idiomatic alternative — it exits automatically when the channel closes.
Common mistakes
- ✗Inventing a non-existent
closed(ch)built-in — Go has no such predicate - ✗Thinking a receive from a closed channel panics — only a send panics
- ✗Checking the value against the zero value to detect closure instead of using
ok
Follow-up questions
- →Why does a closed channel still let buffered values drain before
okbecomes false? - →How does a
selectcase interact with a closed channel when receiving?
JuniorTheoryCommonWhat happens when you send to or receive from a nil channel?
What happens when you send to or receive from a nil channel?
Both a send and a receive on a nil channel block forever — the operation never proceeds and never panics. A zero-value channel variable is nil. This is sometimes used deliberately in select to disable a case by setting its channel to nil.
Common mistakes
- ✗Thinking a
nilchannel operation panics rather than blocking forever - ✗Confusing a
nilchannel with a closed channel — the closed one does not block - ✗Forgetting that a declared-but-not-
maked channel variable isnil
Follow-up questions
- →How can setting a channel to
nilinside a loop usefully disable aselectcase? - →What is the difference in behaviour between a
nilchannel and a closed one on receive?
JuniorTheoryCommonWhat happens when you range over a channel and when it is closed?
What happens when you range over a channel and when it is closed?
for v := range ch keeps receiving values one by one and only ends when the channel is closed and drained. close(ch) is the sender's signal that no more values will come; ranging over an unclosed channel blocks forever once it is empty. A receive from a closed channel returns the element's zero value with ok == false, which is exactly what stops the loop. Only the sender should close, and closing twice or sending after close panics.
Common mistakes
- ✗Believing a
rangeloop ends when the channel is merely empty - ✗Thinking a receive from a closed channel returns
ok == true - ✗Letting the receiver close the channel instead of the sender
Follow-up questions
- →How do you detect a closed channel with the comma-ok form?
- →Why does sending on a closed channel panic but receiving does not?
MiddleCodeCommonReceive from, send to, and re-close a closed channel — predict each
Receive from, send to, and re-close a closed channel — predict each
Receiving from a closed channel returns the zero value with ok == false, so (1) gives v == 0, ok == false. Sending on a closed channel panics with send on closed channel, so (2) panics. Closing an already-closed channel also panics, so (3) panics. Rule: the sender closes, never the receiver, and only once.
Common mistakes
- ✗Thinking a receive from a closed channel blocks instead of returning the zero value with
ok == false - ✗Believing a closed channel still accepts sends — it panics
- ✗Assuming
closeis idempotent — re-closing panics
Follow-up questions
- →How do you safely coordinate closing when there are multiple senders?
- →Why does ranging over a closed channel terminate cleanly while a bare receive returns zeros forever?
MiddleTheoryCommonWhat is context.Context for and how does cancellation propagate?
What is context.Context for and how does cancellation propagate?
context.Context carries cancellation signals, deadlines, and request-scoped values across API boundaries. Calling a cancel func or hitting a deadline closes the context's Done() channel; that close propagates to every derived child context, so all of them observe cancellation.
Common mistakes
- ✗Thinking cancellation flows from child to parent rather than parent to all children
- ✗Believing cancelling a context forcibly kills the goroutines using it
- ✗Forgetting to call the
cancelfunc, which leaks the context's resources
Follow-up questions
- →Why must you always call the
cancelfunc even when the context already finished? - →How does a
WithDeadlinecontext turn a time limit into aDone()channel close?
MiddleTheoryCommonHow is a channel implemented internally — what does the runtime hchan struct hold?
How is a channel implemented internally — what does the runtime hchan struct hold?
A channel is a pointer to a runtime hchan struct: a ring buffer (buf with sendx/recvx indices and element counts), two wait queues of blocked goroutines (sendq, recvq), and a mutex. Every send/receive takes the lock; if the operation cannot proceed the goroutine parks on a queue and is later woken by its peer. Closing sets a flag and wakes both queues.
Common mistakes
- ✗Believing channels are lock-free — each operation takes the
hchanmutex - ✗Thinking a blocked send/receive busy-waits instead of parking the goroutine on a wait queue
- ✗Imagining the buffer is unbounded rather than a fixed ring sized at
maketime
Follow-up questions
- →How does an unbuffered send hand the value directly to a waiting receiver without touching
buf? - →What happens to goroutines parked in
recvqwhen the channel is closed?
JuniorCodeOccasionalWhat happens sending on an unbuffered channel before any receive?
What happens sending on an unbuffered channel before any receive?
It deadlocks: fatal error: all goroutines are asleep - deadlock!. An unbuffered send blocks until a receiver is ready, but the only goroutine is blocked on the send, so the receive never runs. Fix: send from a separate goroutine, or use make(chan int, 1).
Common mistakes
- ✗Thinking an unbuffered channel buffers one value — its capacity is zero
- ✗Expecting the runtime to hang silently rather than report
deadlock! - ✗Believing one goroutine can both send and then receive on an unbuffered channel in sequence
Follow-up questions
- →Why can the Go runtime detect this deadlock but not one involving a blocked syscall?
- →How does moving the send into
go func(){ ch <- 1 }()fix it?
MiddleTheoryOccasionalWhat happens when you send on a closed channel, and how do you avoid it?
What happens when you send on a closed channel, and how do you avoid it?
Sending on a closed channel panics immediately with send on closed channel. Avoid it by making one owner responsible for closing, and never closing from a receiver or from multiple senders. The sender side decides closure; the receiver only detects it via comma-ok.
Common mistakes
- ✗Closing a channel from the receiver side instead of from the sender that owns it
- ✗Calling
closefrom multiple senders, which can itself panic on a double close - ✗Believing a send on a closed channel is silently dropped rather than panicking
Follow-up questions
- →How does a
sync.WaitGrouphelp coordinate when a fan-in channel may be closed? - →Why does calling
closetwice on the same channel also panic?
MiddleDebuggingOccasionalWhy does this program deadlock when a goroutine ranges over a channel that is never closed?
Why does this program deadlock when a goroutine ranges over a channel that is never closed?
for v := range ch keeps receiving until the channel is closed. The producer sends 1 and 2 but never calls close(ch), so the consumer blocks forever after draining them, wg.Done() never runs, and wg.Wait() blocks too — deadlock. Fix: close(ch) after the last send.
Common mistakes
- ✗Thinking
range chstops when the channel is empty rather than when it is closed - ✗Closing the channel from the receiver instead of the sender
- ✗Forgetting an unclosed channel keeps the ranging goroutine alive, so
wg.Wait()never returns
Follow-up questions
- →Who is responsible for closing a channel, and why never the receiver?
- →How would a
selectwith adonechannel let the consumer exit without a close?
MiddleCodeOccasionalWhat does this select loop over a cap-1 channel print?
What does this select loop over a cap-1 channel print?
It prints 02468. With capacity 1 on a single goroutine the send and receive cases alternate: on an empty buffer only the send is ready (buffers i, prints nothing); next iteration the buffer is full so only the receive is ready (prints the stored even value). With an unbuffered channel neither case is ever ready on one goroutine, so the program is a fatal error: all goroutines are asleep - deadlock.
Common mistakes
- ✗Thinking one
selectpass can both send and receive on the same channel - ✗Forgetting that an unbuffered channel makes both cases block, causing a deadlock
- ✗Calling the deadlock a recoverable panic rather than a fatal runtime error
Follow-up questions
- →Why does the very first iteration print nothing rather than
0? - →Why is
all goroutines are asleep - deadlocka fatal error and not apanicyou canrecover?
MiddleCodeOccasionalWhy does this two-worker program take ~6s instead of ~3s?
Why does this two-worker program take ~6s instead of ~3s?
It prints 6. Go evaluates the two-operand expression left to right: the first <-worker() calls worker() (starting goroutine #1) and then blocks 3s receiving from it. Only after that completes does the second worker() even start, then blocks another 3s — so they run sequentially, ~6s total. To overlap them, start both first: c1, c2 := worker(), worker(); <-c1; <-c2 → ~3s.
Common mistakes
- ✗Assuming Go starts both
worker()calls before doing either receive - ✗Thinking discarding with
_skips the blocking receive - ✗Believing only buffering, not reordering the starts, can overlap the work
Follow-up questions
- →What is Go's evaluation order for the operands of a multi-value expression?
- →Would buffering the channel change the ~6s result, and why or why not?
MiddleCodeOccasionalImplement a Sleep that returns early if its context.Context is cancelled
Implement a Sleep that returns early if its context.Context is cancelled
select over two cases: case <-ctx.Done(): return false and case <-timer.C: return true. Don't use time.After, which leaks the underlying timer until it fires; instead t := time.NewTimer(d); defer t.Stop() so a cancellation reclaims the timer immediately.
Common mistakes
- ✗Using
time.Sleepthen checking the context, which cannot return before the full duration elapses - ✗Using
time.Afterin the select, which leaks the timer until it fires when the context cancels first - ✗Adding an unnecessary goroutine when a single timer plus
ctx.Done()already covers both cases
Follow-up questions
- →Why does
time.Afterleak its timer, whiletime.NewTimerplusStopdoes not? - →Does calling
Stopon a timer that has already fired cause any problem here?
MiddleCodeOccasionalWrap a slow function with a timeout using a channel and select
Wrap a slow function with a timeout using a channel and select
Run unpredictableFunc in a goroutine that sends its result on a buffered channel of capacity 1, then select over that channel versus ctx.Done(). The buffer is load-bearing: with an unbuffered channel the worker blocks forever on the send after a timeout, leaking the goroutine. If ctx has no deadline, wrap it with context.WithTimeout and defer cancel().
Common mistakes
- ✗Using an unbuffered channel, leaking the worker goroutine on timeout
- ✗Believing
contextcancellation propagates into an opaque blocking call automatically - ✗Forgetting
defer cancel()aftercontext.WithTimeout, leaking the timer
Follow-up questions
- →Why does an unbuffered result channel cause the worker to leak after a timeout?
- →Why does
context.WithTimeoutneed a matchingcancel()call?
SeniorTheoryRareHow is context.Context implemented — Done channel, deadline, value chain?
How is context.Context implemented — Done channel, deadline, value chain?
A cancelCtx holds a lazily-created Done channel and a set of child contexts; cancel closes that channel and recursively cancels children. A timerCtx wraps it with a time.Timer that fires cancel at the deadline. valueCtx is a single key/value node; lookups walk the parent chain.
Common mistakes
- ✗Thinking
Donesends a value on cancel rather than closing the channel for all waiters - ✗Believing the
Donechannel is allocated eagerly rather than lazily on first call - ✗Assuming
WithValuebuilds a merged map instead of a linked node walked at lookup
Follow-up questions
- →Why is closing the
Donechannel the right primitive for broadcasting cancellation? - →What overhead does a deep
valueCtxchain add to actx.Valuelookup?
SeniorDebuggingRareDebug this channel fan-in merge: range merge(...) deadlocks. Find both bugs.
Debug this channel fan-in merge: range merge(...) deadlocks. Find both bugs.
Two bugs. The closing goroutine is written go func(){ wg.Wait(); close(out) } with no trailing (), so it is declared but never launched — out never closes and range merge(...) deadlocks. Second, the worker closure captures the loop variable c, so pre-1.22 every goroutine ranges over the same last channel. Fix: call the closer with (), and pass c as an argument (or rebind it).
Common mistakes
- ✗Reading
go func(){...}as a launched goroutine when the missing()makes it just a declared, never-called value - ✗Assuming pre-1.22 loop variables are per-iteration, so the captured
cis the last channel for every worker - ✗Blaming the deadlock on a
close/send race instead of onoutnever being closed
Follow-up questions
- →Why does Go 1.22's per-iteration loop variable remove the need to pass
cas an argument? - →How would
go vetor the race detector help you catch the loop-variable capture before runtime?
SeniorTheoryRareHow do channels cause goroutine leaks, and how do you prevent them?
How do channels cause goroutine leaks, and how do you prevent them?
A goroutine leaks when it blocks forever on a channel send or receive nobody will satisfy — e.g. a worker sends a result after its caller already returned. Prevent it by giving every blocking operation an exit: a select on ctx.Done() or a buffered result channel.
Common mistakes
- ✗Thinking the garbage collector reclaims a goroutine blocked forever on a channel
- ✗Starting a worker that sends a result but giving it no
ctx.Done()escape path - ✗Believing only an unread buffer leaks, when an unsatisfied send or receive does
Follow-up questions
- →How does a buffered result channel of capacity one let an abandoned worker still exit?
- →How would you detect a goroutine leak in a test or in production?
SeniorCodeRareWrite a fan-in merge that funnels N input channels into one output channel
Write a fan-in merge that funnels N input channels into one output channel
Start one goroutine per input channel, each range-ing its channel and forwarding every value to a shared out. A sync.WaitGroup counts the forwarders; a separate goroutine calls wg.Wait() then close(out), so the output closes exactly once after all inputs drain. Return out immediately. Ranging the inputs and closing out only after Wait avoids both a send-on-closed panic and a goroutine leak.
Common mistakes
- ✗Closing
outinside each forwarder, causing a send-on-closed panic on the other goroutines - ✗Using a single
selectand assuming one receive per channel means it is drained - ✗Never closing
out, so the caller'srangeblocks forever
Follow-up questions
- →How would you add cancellation so
mergestops early when acontext.Contextis cancelled? - →Why must
close(out)happen in its own goroutine rather than after theforloop that starts the forwarders?