skip to content

Pipelines and Stages

Chaining stages that each read one channel and return another gives you streaming with natural backpressure, since a slow stage stalls its producer. The rule interviewers want stated is that a stage closes only its own output and must abandon work early when the consumer goes away.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

In a Go pipeline, why does each stage return a receive-only `<-chan Out` and close only that channel?

level: juniorimportance: must knowfreq 60%

answer

  1. one maker, one closer
  2. the goroutine that sends is the one that closes
  3. a send on a closed channel panics
  4. defer close(out) at the top of the goroutine
  5. the return type can forbid a close

basics

~20 s

A stage owns the one channel it creates and sends on, so only it may close that channel, in a defer when its input runs dry. Returning that channel receive-only makes any downstream close or send a compile error.

solid answer

~40 s

A pipeline stage takes `<-chan In`, makes its own `out` channel, starts one goroutine that ranges over the input and sends results, and returns `out` immediately. The rule is sender-closes: a send on a closed channel panics, so the only goroutine that can safely close a channel is the one sending on it. Each stage therefore does `defer close(out)` inside its goroutine and never touches its input's close. Shutdown then cascades for free: the generator at the head closes its output, the next stage's `for range` ends, its deferred `close` fires, and so on down to the consumer. Declaring the return type `<-chan Out` is the enforcement rather than a comment: `close` and send are not operations on a receive-only channel, so a downstream stage cannot break the rule by accident.

code

go · 10 lines
go
func resize(in <-chan Frame) <-chan Frame {
	out := make(chan Frame)
	go func() {
		defer close(out) // this stage closes only the channel it made
		for f := range in {
			out <- shrink(f)
		}
	}()
	return out
}

go deeper

for a junior

Be ready to write the stage shape from memory: make the output channel, start one goroutine that ranges over the input, defer the close of the output, return the channel. Say out loud that the sender closes.

for a middle

Explain why sender-closes is not a style preference: a send on a closed channel panics, and close is a broadcast to receivers. Show how one close at the head cascades all the way down to the consumer's loop.

for a senior

Show that you enforce ownership with types rather than comments — receive-only returns, make and close in the same function — and that you know what a missing close costs downstream: a range that never returns and a hang with no error.

for a principal

Own the convention across a codebase: every stage-shaped function returns a receive-only channel and documents who closes it, so reviewers never have to re-derive channel ownership pipeline by pipeline.

## The shape of a stage A Go pipeline is a chain of functions that all share one signature: ```go func stage(in <-chan In) <-chan Out ``` Each stage takes a receive-only input channel, creates its own output channel with `make`, launches exactly one goroutine to do the work, and returns the output channel **immediately** — before any value has been produced. The caller wires the chain by feeding one stage's return value into the next: `encode(resize(decode(paths)))`. Three stages means three live goroutines, each parked at its send or its receive until a neighbour is ready. Three properties fall out of that shape, and they are what an interviewer is checking. ## 1. One channel, one creator, one closer A stage calls `make` exactly once, for its output. That is the only channel it sends on, and the only one it closes. The input belongs to the stage upstream; this stage only receives from it. The rule is not aesthetic — the runtime enforces it with panics: - a send on a closed channel panics with `send on closed channel`; - closing an already-closed channel panics with `close of closed channel`; - closing a nil channel panics. A receiving goroutine has no way to know whether some sender is mid-send, so a receiver can never close safely. A sender always can: it knows when it has sent its last value. Hence "the sender closes". ## 2. close is a broadcast, not a cleanup Closing a channel signals receivers that no more values are coming. Every receive on a closed and drained channel returns immediately with the element type's zero value and `ok == false`, and a `for v := range ch` loop terminates. That is the **only** way a range over a channel ends: not when the channel happens to be empty (an empty open channel blocks the receiver), not on a zero value, and not when the sending goroutine simply returns without closing — that leaves the downstream loop blocked forever. Closing is also not a resource release. A channel is an ordinary heap object; once nothing references it, the collector reclaims it whether or not it was closed. You close a pipeline channel so the downstream `range` terminates, not to free memory. The mirror image is worth knowing too: a channel still referenced by a goroutine blocked on it is never collected, and no amount of closing elsewhere fixes that. ## 3. Shutdown cascades Because every stage closes its own output once its input runs dry, closing the head closes the whole chain: generator's `defer close(out)` → resize's `range` ends → resize's `defer close(out)` → encode's `range` ends → encode's `defer close(out)` → the consumer's `range` ends. Write the close as `defer close(out)` at the **top** of the goroutine rather than as a statement after the loop. Deferred there, it also runs on an early `return` — a cancellation branch, say — and on a recovered panic, so the downstream loop always terminates no matter how this stage exits. ## The type is the enforcement Return `<-chan Out`, not `chan Out`. Direction is part of a channel's type, and `close` and send are simply not operations on a receive-only channel: `close(c)` where `c` is `<-chan Frame` fails to compile. Likewise the parameter is `in <-chan In`, so no stage can close its input by accident and no stage can inject values into another stage's channel. That one typing decision turns the ownership convention from something a reviewer must remember into something the compiler checks. It is why you will see this exact signature in every idiomatic Go pipeline. ## The two mistakes - **Closing the input to be tidy.** It panics the stage upstream, which is still sending — and the stack trace names that upstream stage, not the closer. - **Never closing the output.** The downstream `range` never returns, so the pipeline hangs at the consumer with no panic and no error at all.

  • Where exactly should `close(out)` sit inside a stage's goroutine?
    As `defer close(out)` at the top of the goroutine, above the loop. Deferring it there guarantees it runs on every exit path — the normal end of `range`, an early `return` on cancellation, or a recovered panic — so the downstream `range` always terminates. Calling `close` after the loop only works when the goroutine has exactly one exit.
  • What actually ends a stage's `for f := range in` loop?
    Only the upstream stage closing the channel it sends on. Ranging over a channel keeps receiving until the channel is closed and drained, then exits. It never ends because the channel is momentarily empty, and never on a zero value. If the upstream goroutine dies without closing, this stage blocks in `range` for the life of the process.
  • Does a channel have to be closed for its memory to be reclaimed?
    No. Closing is a signal to receivers, not a deallocation: an unreferenced channel is collected whether or not it was ever closed. You close a pipeline channel because downstream `range` loops need to terminate. What is not collected is a channel still referenced by a goroutine blocked on it — that is a goroutine leak, not a missing close.

saying these in an interview costs you the question

  • Says the receiving stage should close its input when it is done
  • Closes the output from the caller instead of inside the stage's goroutine
  • Thinks a channel must be closed or its memory leaks
  • Lets two goroutines close the same channel
  • Returns a bidirectional chan so anything downstream can send into it
open as a page

If a Go pipeline's consumer stops receiving halfway, what happens to the upstream stage goroutines?

level: middleimportance: must knowfreq 68%

basics

~20 s

They block forever on their next send and never return, pinning every value they hold. The fix: thread a context through every stage and write each send as a select against cancellation, so an abandoned stage exits.

open as a page

A Go pipeline panics with `send on closed channel` in its decode stage — what does that tell you about a downstream stage?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Something other than decode closed decode's output channel while decode was still sending, almost always the next stage closing its input. Only the sending goroutine may close a channel; returning stages as receive-only makes a downstream close a compile error.

open as a page

In a Go pipeline handing `[]byte` pixel buffers between stages, what breaks if a stage reuses its scratch buffer?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

A channel send copies only the slice header, so both stages point at the same backing array. Overwriting it for the next frame corrupts the frame already in flight, so treat a send as handing over ownership of those bytes.

open as a page