Lesson 1 of 5
Pipelines, Fan-out & Fan-in
"We should have some ways of coupling programs like garden hose — screw in another segment when it becomes necessary to massage data in another way." — Doug McIlroy, internal Bell Labs memo (1964)
Part IV was the vocabulary: goroutines, channels, select, the sync package, context. Each was introduced on its own terms, in examples sized to demonstrate one idea. Part V is what happens when they meet a real system, and the shapes that recur often enough to have names.
The first of those shapes is the pipeline, and it is the oldest idea in this book. McIlroy's garden-hose memo led to Unix pipes six years later, and the insight has not changed since: a program that reads a stream and writes a stream can be joined to any other program that does the same, and the composition is more useful than the parts. Go's version replaces processes with goroutines and pipes with channels, which makes the segments cheap enough to use casually and puts them inside one program where they share a type system.
What Go adds — and what most of this chapter is about — is that the segments can fan out. A Unix pipe is strictly one-to-one. When a stage is slow, Go lets you run ten copies of it reading the same input, and merge their outputs back into one stream. That is the pattern that makes concurrency pay for itself, and it is also where the leaks live.
A pipeline stage is a goroutine that reads from one channel and writes to another. Chain four of them and you have four goroutines, three channels, and a satisfying diagram. So what happens when the consumer at the end stops reading — because it found what it wanted, hit an error, or timed out?
Every stage upstream blocks on its next send and stays blocked forever. The diagram does not show this, the code looks symmetrical, and nothing reports an error. A pipeline is not a data structure you can walk away from; it is a set of live goroutines, each holding the one before it. So the real subject of this chapter is not how to build one — that is twenty lines — but the discipline that lets you stop one, which is where every pipeline bug actually is.
A stage is a function that returns a channel
The convention is uniform across Go, and once you see it the rest follows.
Click Run to execute the code.
Four rules are encoded in those signatures, and every one of them prevents a specific bug.
A stage takes <-chan T and returns <-chan U. The receive-only return type means the caller physically cannot close the channel — the compiler rejects it — which enforces Chapter 2's rule that only the sender closes.
The goroutine is started inside the stage, not by the caller. The function returns immediately with a channel that will fill up later. This is what makes composition work: double(square(gen(...))) builds the whole pipeline in one expression, and nothing has run to completion yet.
defer close(out) is the first line of the goroutine. When the input channel closes, the range ends, the function returns, and the deferred close propagates the end-of-stream signal downstream. A stage that forgets it leaves its consumer ranging forever — a hang with no error, exactly the leak of Chapter 2 dressed as an ordinary loop.
The stage owns exactly one channel: the one it created. It reads someone else's and writes its own, and that division is what makes ownership unambiguous all the way along the chain.
Notice also that the intermediate channels are unbuffered. That is the right default here: it means each stage runs only as fast as the next one consumes, so a slow final stage exerts backpressure all the way to the source rather than letting work pile up in memory.
A pipeline is a production line, and the unbuffered channels are the spaces between stations. If the packer at the end slows down, the space in front of them fills, the labeller stops, then the filler stops, and eventually the line's start stops. Nobody coordinated that — it propagated backwards one station at a time, which is exactly what backpressure is and why unbuffered is the sensible default.
The failure this chapter is about is what happens when the packer walks off. Every station upstream is left holding a part, mid-motion, waiting for someone to take it — and they wait forever. In a factory that is obvious from across the room. In a Go program the line is invisible, the workers cost two kilobytes each, and the only symptom is memory that never comes back down.
Fan-out: many workers, one input
When one stage is slower than the rest, run several copies of it against the same input channel.
Click Run to execute the code.
The mechanism is worth being precise about, because it looks like magic and is not. Several goroutines ranging over the same channel is well-defined: each value goes to exactly one of them, chosen by the runtime, and when the channel closes every one of their range loops ends. There is no coordination, no work-stealing queue, and no need for one — the channel is the queue, and receive is atomic.
That gives you a distribution property for free: work is pulled rather than pushed, so a worker that finishes early takes the next item, and a worker stuck on something slow simply does not. Compare that with splitting the input into three slices up front, where one unlucky partition full of slow items leaves two workers idle while the third grinds. Pull-based distribution is self-balancing, and it is the single strongest argument for this shape.
Two limits are worth knowing now. Fan-out only helps when the stage is the bottleneck — parallelising a fast stage in front of a slow one just moves the queue. And the number of workers is a decision, not a detail: the next chapter is entirely about why it must be bounded, and what happens when it is not.
McIlroy proposed the garden-hose coupling in 1964 and spent six years arguing for it; Ken Thompson implemented pipes in one evening in 1973, and the Unix toolset was rewritten around them within days. The idea that a program should read a stream and write a stream — rather than owning its input and output — turned a pile of utilities into a composable system, and it is fair to call it the most successful concurrency abstraction ever shipped.
Go's pipelines are that idea with two changes, both consequences of living inside one process. Segments are goroutines rather than processes, which makes them roughly a thousand times cheaper to create, so a pipeline stage is no longer a decision you weigh. And the stream is typed: a Unix pipe carries bytes and every stage re-parses them, which is the tax McIlroy's "universal interface" charges, while a Go channel carries Order or Result with no serialisation at either end. What Go gives up is the part Unix got right by accident — pipe stages are separate processes, so one crashing does not take the others with it, and the shell cleans them up when the pipeline ends. In Go, nothing cleans up an abandoned stage, which is why the rest of this chapter exists.
Fan-in: many outputs, one channel
Fan-out produces several channels. Merging them back into one is the piece that needs care, because it is where "who closes this?" gets hard.
Click Run to execute the code.
The wg.Wait(); close(out) goroutine is the whole trick, and it deserves to be understood rather than copied.
out has several senders, so by Chapter 2's rule none of them may close it — a close while another sender is live panics that sender and kills the process. But somebody must, or the consumer's range never ends. The resolution is a separate goroutine whose only job is to wait for every sender and then close, which orders the close after the last send by construction rather than by hope.
It has to be a goroutine, not a plain wg.Wait() before the return. merge returns immediately with a channel that fills later, exactly like every other stage; blocking on Wait in the function body would mean nothing could receive from out until every sender had finished, and since the senders block on out, that deadlocks instantly. This is the most common way people get merge wrong, and the symptom is a total hang on the first item.
One playground note: this book's interpreter cannot iterate a slice of channels with the two-variable form (for _, c := range cs), so the loop above is indexed. for i := range cs works too. Both are ordinary Go; only the value form trips this runtime.
Three questions for every pipeline stage you write.
Who closes this channel, and when? Exactly one goroutine, after the last send. One sender means defer close(out) in that goroutine. Several means a WaitGroup and a dedicated closer. If you cannot name the closer, the consumer's range will not terminate.
What happens if my consumer stops early? Trace it upstream: this stage blocks on send, so the one before it blocks, all the way to the source. Every stage needs a cancellation path, or the pipeline can only be run to completion.
Is this stage actually the bottleneck? Fan-out on a stage that was never the constraint adds goroutines, a merge, and a set of new failure modes to move the queue somewhere else. Measure which stage is slow before parallelising it.
Abandoning a pipeline leaks it
Everything so far assumed the consumer drains to the end. Here is what happens when it does not, and it is worth watching closely.
Click Run to execute the code.
The three cases are the lesson. The first producer ran to completion and set its flag. The second is still sitting on out <- 3 and will be for as long as the process lives, holding its stack, its loop variables, and — in a real pipeline — a database cursor, an open file, or a buffer. The third took the same early exit and returned cleanly, because cancel() reached it.
Now scale the middle case. That was one goroutine. A pipeline with four stages and a fan-out of eight leaks every stage above the abandonment point, so one break in a handler can strand a dozen goroutines. Do that per request and the graph is the straight line from Part IV: memory climbing with no sawtooth, goroutine count climbing in lockstep, and a restart as the only remedy.
Every send in a cancellable stage becomes a select. That is the whole change, and it must be applied at every stage — one stage without it is enough to strand everything above it, because that stage will block on its own send and never reach its ctx.Done() check.
The older idiom, still common in code written before Go 1.7, uses a plain done chan struct{} closed by the consumer, which works identically because closing broadcasts. context supersedes it by carrying deadlines and threading through APIs you do not own, but the mechanism underneath is the same closed channel.
The pipeline leak has a specific tell that distinguishes it from ordinary memory growth: goroutine count rises in steps that match request volume and never falls. A handler streams results from a database through two or three transform stages, and returns early — the client disconnected, a limit was reached, an error came back from something else. Every stage above that point parks on a send, and because the bottom stage holds a database cursor, the connection is never returned to the pool either. So the first symptom is usually not memory at all; it is connection-pool exhaustion, which looks like a database problem and sends the investigation in the wrong direction for hours.
What makes it survive review is that the early return is obviously correct. Returning as soon as you have enough rows is good behaviour, and the reviewer is looking at the handler, where nothing is wrong. The bug is in the relationship between the handler and stages defined in another file, and it is invisible unless you are specifically asking "what happens upstream if this returns here?" The diagnostic is /debug/pprof/goroutine?debug=1, where a few hundred goroutines parked on the same channel send in the same stage function is unambiguous. The structural fix is to make cancellation a requirement of the stage signature — if every stage takes a ctx as its first parameter, the reviewer can at least see which ones ignore it.
Generic stages, and when not to build one
Type parameters make pipeline stages reusable, and this is one of the cases where generics earn their place.
Click Run to execute the code.
Map changes the element type — <-chan int in, <-chan string out — with the compiler checking both ends. Before generics this needed either chan interface{} and a type assertion per element, or a hand-written stage per type pair. It is a genuine improvement, and Map, Filter and Take cover most of what a pipeline library needs.
Now the part that matters more than any of the above. Most pipelines should not be pipelines.
That last example processes eight integers using four goroutines and three channels, and a plain loop would do it in three lines with no concurrency, no leak risk, and better performance. Channel operations are not free — each send and receive is a scheduler interaction costing on the order of a hundred nanoseconds — so a pipeline over cheap per-item work is slower than the loop it replaced, sometimes by an order of magnitude.
A pipeline earns its complexity under three conditions, and it wants at least two of them: the stages block on something external (network, disk, a lock), so overlapping them recovers real time; the stream is unbounded or large enough that you cannot hold it in memory; or the stages have genuinely different rates and you want backpressure to manage them rather than doing it yourself. Absent those, you are paying goroutine coordination costs and inheriting an entire class of lifecycle bugs to express a for loop with extra steps.
Common Mistakes
Forgetting
defer close(out)in a stage. The downstreamrangenever ends and its goroutine never returns — a hang with no error, and the most common pipeline bug.Closing a merged channel from a sender. With several senders, any one closing panics the others. Use a
WaitGroupand a dedicated goroutine that waits, then closes.Calling
wg.Wait()in the body ofmergeinstead of a goroutine. The senders block on the output channel that nobody can drain untilWaitreturns, so it deadlocks on the first item.Returning
chan Tinstead of<-chan T. The receive-only type is what stops a caller closing a channel it does not own. Give it away for free and the compiler stops helping.Breaking out of a consumer loop without cancelling. Every stage upstream parks on its next send, permanently. One
breakin a handler can strand a dozen goroutines and a database cursor.Adding cancellation to some stages but not all. A stage without a
selecton its send blocks there and never reaches its ownctx.Done()check, stranding everything above it. It has to be every stage.Buffering intermediate channels to "speed things up". Buffers remove the backpressure that was keeping memory bounded, so a slow final stage now accumulates a queue in your heap instead of slowing the source.
Fanning out a stage that is not the bottleneck. It adds goroutines, a merge, and new failure modes, and moves the queue rather than shortening it. Measure first.
Unbounded fan-out. One worker per input item makes concurrency a function of load, which is the subject of the next chapter and the usual way a pipeline exhausts a connection pool.
Building a pipeline for cheap per-item work. Channel operations cost far more than the arithmetic they carry. If nothing blocks and the data fits in memory, write the loop.
Interview Corner
in <-chan int, creates its own out := make(chan int), starts a goroutine that ranges over in and sends v*v, and returns out as <-chan int. The goroutine's first line is defer close(out), so when the input closes the range ends and the close propagates downstream — without it the consumer ranges forever, which is a hang with no error. The signature tells the caller two things. The receive-only return means they cannot close it, which enforces the rule that only the sender closes; and because the function starts the goroutine and returns immediately, stages compose as expressions — double(square(gen(...))) builds the whole pipeline before anything runs. I would leave the channel unbuffered by default, so each stage runs at the pace of the next one and a slow consumer pushes back all the way to the source rather than letting work pile up in memory.merge — fan several channels into one. What are the two ways to get it wrong?out, a WaitGroup counting those forwarders, and a separate goroutine doing wg.Wait(); close(out). The first mistake is closing out from a forwarder: there are several senders, and a close while any of them is live panics that sender and kills the process. That is why the close has to be its own goroutine ordered after the last send by construction. The second is calling wg.Wait() directly in the function body before returning — that looks tidier and deadlocks immediately, because the forwarders block sending into out, nobody can drain out until merge returns, and merge is waiting on the forwarders. The symptom is a total hang on the first item. The rule underneath both is the one from channels: exactly one goroutine closes, and only after every sender is provably finished. I would also return <-chan T so callers cannot close it either./debug/pprof/goroutine?debug=1, where a few hundred goroutines parked on the same send in the same stage function is unambiguous. What makes it survive review is that the early return is correct behaviour and the reviewer is looking at the handler, while the bug lives in its relationship with stages defined elsewhere. The fix is to thread a context through and make every send a select on out <- v and ctx.Done(), with defer cancel() in the handler — and it has to be every stage, because one stage without it blocks on its own send and never reaches its cancellation check. Structurally I would make ctx the first parameter of every stage so a reviewer can see which ones ignore it. And I would ask whether it needs to be a pipeline at all: if the stages are pure transforms with nothing blocking, a loop over the rows has none of these failure modes.Hands-on
Four exercises building a pipeline from parts. Exercise 3 is merge, which is the one worth getting right from memory. Exercise 4 adds cancellation, which is what separates a demo pipeline from one you can put in a server.
Click Run to execute the code.
Summary
- A stage takes
<-chan Tand returns<-chan U, starts its own goroutine, and returns immediately — which is what lets stages compose as expressions. defer close(out)is the first line of every stage's goroutine. Forgetting it leaves the consumer ranging forever: a hang with no error.- Return receive-only channels. The type is what stops a caller closing a channel it does not own, and the compiler enforces it.
- Unbuffered intermediate channels give you backpressure. A slow final stage throttles the source instead of accumulating a queue in your heap.
- Fan-out is several goroutines ranging one channel. Each value goes to exactly one, distribution is pull-based and self-balancing, and no coordination is needed.
- Fan-in needs a
WaitGroupand a dedicated closing goroutine. No sender may close a shared channel, andwg.Wait()in the function body deadlocks instead. - An abandoned pipeline leaks every stage above the abandonment point — parked on a send, holding cursors and buffers, with no error and no way to notice.
- Cancellation means every send becomes a
select, at every stage. One stage without it blocks on its own send and strands everything upstream. - Generics make stages reusable across types —
Mapcan change<-chan intinto<-chan stringwith both ends checked. - Most pipelines should be loops. Channel operations cost far more than the work they usually carry; a pipeline earns its complexity when stages block, the stream is large, or rates genuinely differ.
This chapter fanned out to three workers because three looked reasonable. That number was never justified, and in a real system it is the most consequential line in the file — one goroutine per item is a design that works beautifully in testing and takes down a database in production, because concurrency becomes a function of load rather than of what your dependencies can absorb. The next chapter is worker pools and bounded concurrency: how to size a pool against the scarcest resource downstream rather than the arrival rate, why a full channel is a feature, and why "add more workers" is usually the wrong response to a queue.
Unix pipes and Go pipelines look like the same idea, and the difference between them is the most instructive thing in this chapter. A shell pipeline is made of processes: the kernel owns their lifecycles, the shell reaps them, a crash in one stage does not corrupt the others, and when the last stage exits everything upstream gets SIGPIPE and dies. Nobody writing grep foo | head -1 thinks about cleanup, because the operating system spent decades making that unnecessary.
Go took the composition and left the lifecycle management behind. Goroutines are cheaper than processes by three orders of magnitude, which is exactly why the pattern is worth having in-process — and they are cheaper precisely because nothing supervises them. There is no parent, no exit status, no reaper, no equivalent of SIGPIPE when the consumer walks away. The whole of this chapter's difficulty is the bill for that trade, and head -1 is the proof: the single most common thing anyone does with a Unix pipeline is abandon it early, and in Go that same act is the leak.
What is genuinely interesting is that Go's answer — thread a context through every stage and make every send a select — is manual reimplementation of SIGPIPE, done by hand, in application code, correctly, at every stage, forever. It works, and I do not know of a better answer that keeps goroutines as cheap as they are. But it is worth noticing that the abstraction we borrowed from 1973 came with a runtime that handled the hard part, and the version we kept did not. That is not an argument against Go's design. It is an argument for being honest that a pipeline in Go is a heavier commitment than the syntax suggests — which is the real reason most of them should be loops.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.