Why does a goroutine that sends its result on an unbuffered channel leak when the caller stops waiting?
answer
- a send is a rendezvous, not a mailbox
- the caller left; the sender did not know
- one leaked goroutine per abandoned call
- one character of capacity fixes it
- closing it would panic the sender instead
basics
~20 sA send on an unbuffered channel completes only when a receiver is ready. Once the caller has returned on a timeout, no receiver ever arrives, so the sending goroutine parks on that line forever, holding its result.
solid answer
~50 s`make(chan T)` is a rendezvous: the send blocks until some goroutine receives. A worker that does `ch <- result` and a caller that abandons the wait — a `select` that took the cancellation case, an early `return` on an error — leave the send with no counterparty, permanently. The goroutine parks in that send and never returns, pinning the result it computed. There are two standard fixes. Give the channel a buffer big enough for every value that could be sent (`make(chan T, 1)` for a single result), so the send always completes whether or not anyone reads it; or make the send itself abandonable with `select { case ch <- v: case <-done: return }`. The buffered version is preferred for the one-result case because it cannot be got wrong. Note that closing the channel is not a fix: closing a channel a goroutine is blocked sending on makes that send panic.
code
go · 12 linesfunc fetch(ctx context.Context) (string, error) {
ch := make(chan string, 1) // was make(chan string): the worker leaked
go func() {
ch <- slowWork()
}()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return "", ctx.Err()
}
}go deeper
Know that an unbuffered channel send waits for a receiver and does not drop the value. Be able to say that a goroutine still holding an unfinished send has not exited.
Explain the rendezvous semantics that make the send permanent once the caller returns, and give both fixes: enough buffer for every value that could still be sent, or a select with a cancellation case. Know why closing from the receiver would panic instead.
Demonstrate that you size the buffer by the number of possible senders, not by the one result you consume, and that you read every abandonment path of the caller before approving the code. Be ready to estimate the leak rate from the timeout rate.
Treat this as an API-shape decision: a helper that starts background work should make the abandonment case safe by construction, so callers cannot leak by writing an ordinary timeout. Decide whether that belongs in a shared helper rather than in every call site.
## The shape This is the most common goroutine leak in real Go code, and it appears wherever work is started in the background and the caller has any way to give up: ```go func fetch(ctx context.Context) (string, error) { ch := make(chan string) // unbuffered go func() { ch <- slowWork() // blocks until someone receives }() select { case v := <-ch: return v, nil case <-ctx.Done(): return "", ctx.Err() // the goroutine above is now stranded } } ``` On the happy path this is correct: the `select` receives, the send completes, both goroutines move on. On the deadline path the caller returns. Nothing is left holding the receiving end. When `slowWork()` eventually finishes — a second later, a minute later — the goroutine reaches `ch <- ...` and parks. Forever. ## Why unbuffered means "forever" An unbuffered channel has no storage. A send is a **rendezvous**: the sending goroutine is parked on the channel's send queue until a receiver shows up, at which point the value is handed over directly and both continue. There is no "deliver it later", no drop, no error return. If no receiver ever arrives, the send never completes, and there is no timeout on a channel operation anywhere in Go. The leak rate is what makes this dangerous: it is *one goroutine per abandoned call*. A service that times out one request per second leaks 3,600 goroutines an hour, each holding the string, struct or buffer it computed, plus everything that value references. ## Fix 1: buffer for every value that can be sent ```go ch := make(chan string, 1) ``` One line. The worker's send now completes immediately into the buffer whether or not anyone is listening; the goroutine returns; the channel and its one value become garbage together once the caller drops its reference. The rule generalises: the buffer must have room for **every** value that could still be sent after the caller walks away. One goroutine sending one result needs capacity 1. Three racing workers each sending one result need capacity 3 — capacity 1 fixes the leak for the winner and leaves the other two parked. ## Fix 2: make the send abandonable ```go select { case ch <- v: case <-ctx.Done(): return } ``` A `select` with a send case and a cancellation case cannot park forever, because the cancellation channel is eventually closed and a receive from a closed channel is always ready. This is the right shape when the number of pending values is unbounded — a producer streaming into a pipeline — where you cannot size a buffer. ## What is not a fix - **Closing the channel from the caller.** A `close` unblocks blocked *receivers*; a goroutine blocked on a **send** to a channel that is then closed **panics** (`send on closed channel`). You have converted a leak into a crash. - **Setting a timeout on the send with `time.After` inside the worker.** It works, but it needs a timer per call and it duplicates a deadline the caller already has. Prefer the buffer. - **Assuming the runtime notices.** It does not: the rest of the service is runnable, so there is no deadlock to detect. ## Reviewing for it When you read `go func(){ ... ch <- x }()`, look at every path by which the receiving side can stop receiving. If any of them exists — a `select` with a second case, an early return, a `break` out of a loop, a caller with a deadline — then either the channel is buffered for the full number of outstanding sends, or the send is inside a `select` with an escape. Anything else leaks on that path, quietly, one goroutine at a time.
- Three goroutines each send one result on the same channel and the caller takes only the first. What capacity does the channel need?Three. The buffer must hold every value that could still be sent after the caller stops receiving, not just the one it consumes. With capacity 1 the winner's send completes and the other two goroutines park forever — the leak is smaller, not gone. Size the buffer by the number of possible senders.
- Would closing the channel from the caller before it returns release the blocked sender?No — it would crash the program. Closing unblocks receivers, but a goroutine blocked sending on a channel that gets closed panics with `send on closed channel`, and a panic in any goroutine takes the whole process down. Closing is the sender's job, never the receiver's.
- Why doesn't the worker goroutine simply notice that nobody is listening and drop the value?There is no such notion in Go. A channel has no concept of a live receiver, and a send has no non-blocking form outside `select` with a `default`. The sender is parked on the channel's send queue until a receive dequeues it, which is why the escape has to be written explicitly.
saying these in an interview costs you the question
- Thinks an unbuffered send drops the value when no one receives
- Suggests closing the channel from the receiving side to release the sender
- Buffers capacity 1 when several goroutines can still send
- Believes returning from the caller stops the goroutine it started
- Expects a channel send to time out on its own