Fan-out is the channel-based way to spread work across multiple worker goroutines pulling from a single source. One producer puts jobs on a channel, N workers each read from it, and the runtime balances who gets what. This chapter covers the worker-pool shape, when to pick it, and the trade-offs that come with parallelism.
Spawning N goroutines where each gets one slot in a result slice works when you know the work up front and want one goroutine per item. The channel version is different. You don't spawn one goroutine per item. You spawn a fixed number of worker goroutines and feed them items through a shared jobs channel.
The producer doesn't care which worker picks up a given job. Workers compete for items on the channel, and whichever one is ready next reads the next value. The Go runtime hands the value to exactly one receiver, so each job is processed once. This is sometimes called a worker pool, and the two terms refer to the same pattern.
The shape looks like this in pseudocode:
One source, multiple workers, one merged result stream. The result side is a fan-in, so a full worker pool is really fan-out plus fan-in stitched together.
The producer writes to one channel. The N workers all read from that same channel, each processing whatever they pick up. Results flow into a second channel that the consumer drains. Replacing N is a one-line change. The producer and consumer code don't know how many workers exist.
The reason this shape comes up so often is that it handles two real problems at once. The first is parallelism: if a single worker can do 100 jobs per second and you have four CPU cores, four workers running together can do roughly 400 jobs per second on CPU-bound work. The second is bounded concurrency: you might have 10,000 jobs but only want 50 of them running at any moment, because the downstream database can't handle more. Fan-out caps the in-flight work at N, no matter how many jobs the producer pushes.
A worker pool has three pieces: a producer that fills the jobs channel, a fixed number of workers that read from it, and a consumer that reads results. Each piece is small. The wiring is what takes care.
Here's the canonical shape. The job is a product ID; the work is "look up the price"; the result is the price tagged with the ID so the consumer knows what came back.
The output order isn't fixed. Worker 2 might happen to pick up product 103 before worker 1 finishes product 101, and the result for 103 lands in the channel first. Run it again and you'll likely see a different order.
The interesting parts are all in main and the closer goroutine. Workers stop themselves: the for job := range jobs loop exits when the jobs channel is closed and drained, the worker returns, and wg.Done() runs in the deferred call. Once every worker has returned, wg.Wait() unblocks, and the closer goroutine closes results. That close signals the consumer's for r := range results to exit cleanly.
Without the closer goroutine, the consumer would block forever on an empty results channel. Without close(jobs) after the producer is done, the workers would block forever waiting for more jobs. Both closes are mandatory for the pool to shut down.
Cost: The default jobs channel here is unbuffered. The producer blocks on every send until a worker is ready to receive, which naturally throttles production to the workers' speed. If you want the producer to run ahead, use make(chan priceJob, capacity) to buffer.
Fan-out solves two distinct problems. Understanding which one you're solving determines how you size the pool.
For CPU-bound work like hashing, parsing, image processing, or any computation that genuinely uses the processor, one goroutine can only use one core at a time. Two goroutines doing CPU-bound work can use two cores in parallel, three can use three, and so on, up to the number of logical cores the runtime is allowed to use.
That limit is controlled by GOMAXPROCS. By default it equals the machine's logical CPU count. A worker pool of size runtime.NumCPU() is the standard starting point for CPU-bound work.
Here's a pool that computes SHA-256 hashes across multiple workers. The hash is a stand-in for any real CPU work.
On a machine with 8 cores, 8 workers can run truly in parallel because the SHA-256 loop is pure CPU. Running this with numWorkers = 1 takes roughly 8x as long. Past 8 workers, you don't get more parallelism, only more scheduler overhead, because there are only 8 cores to run on. The digests are illustrative; real runs produce different hex values because the work nests hashes 200,000 times.
Cost: For CPU-bound work, going above GOMAXPROCS workers doesn't speed things up. The extra goroutines compete for the same cores and add context-switching overhead.
IO-bound work behaves differently. A goroutine doing a network call spends most of its time waiting for bytes to come back, not using the CPU. Running 50 such goroutines in parallel on a 4-core machine works fine, because at any moment most of them are parked in a system call.
The cap on IO-bound work usually doesn't come from CPU. It comes from the downstream system. A database might allow 20 simultaneous connections. A third-party API might rate-limit you to 10 requests per second. A file system might choke under hundreds of concurrent opens. Fan-out lets you fix the number of in-flight requests regardless of how many jobs are queued.
Suppose you have 1,000 product IDs and need to call a slow API for each one. A worker pool of size 10 keeps exactly 10 requests in flight at any time. The other 990 wait in the jobs channel until a worker is free.
The in-flight count sits at 10 for the entire run. There are 100 jobs queued but only 10 active calls at any moment. The downstream API sees a steady stream of 10 concurrent requests, never a spike of 100. That's the bounded concurrency property fan-out gives you.
Without the pool, a naive for _, id := range ids { go fakeAPICall(id) } would briefly spike to 100 concurrent calls. With 1,000 IDs it would be 1,000. Some downstream systems handle that; many don't.
There's no universal correct value for N. The right number depends on what the workers actually do.
| Workload Type | Starting N | Why |
|---|---|---|
| CPU-bound (hashing, parsing, compression) | runtime.NumCPU() | Each worker uses one core; more workers waste scheduling time. |
| IO-bound, downstream-limited (database, API) | Match the downstream limit | Workers spend most time waiting; cap matches what the service accepts. |
| IO-bound, no downstream limit (local files) | 10-100, benchmark | Diminishing returns once IO is saturated. |
| Mixed (some CPU, some IO per job) | Start at runtime.NumCPU() * 2, benchmark | Workers can overlap CPU and IO. |
The CPU number isn't always equal to the physical core count. Hyperthreaded machines report logical cores; some container runtimes lie about CPU availability. For production workloads, always benchmark with the real workload before locking in N.
A reasonable default for a tool you're writing right now: start with runtime.NumCPU(), measure throughput, then change N and measure again. If throughput goes up with more workers, you were CPU-bound or IO-bound and have room to grow. If it stays flat or drops, you've found the ceiling.
One catch with fan-out: results come back in whatever order workers finish, not the order jobs were submitted. If job 1 takes 100ms and job 2 takes 10ms, job 2's result lands in the results channel first.
For many use cases this doesn't matter. If you're tallying API responses or computing checksums, order is irrelevant. But if you're feeding the results back into an ordered pipeline, like rendering a paginated report or writing rows to a CSV in input order, you need a way to reassemble them.
The fix is to tag every job with an ID and have the consumer reorder by ID. Here's the shape.
The consumer collects everything first, then sorts by jobID. The output is in input order even though the workers produced results out of order. For very large inputs that can't fit in memory, you'd write results into a pre-allocated slice indexed by jobID instead of appending and sorting at the end.
The simpler alternative: don't use fan-out when order matters and per-item parallelism gains are small. A single goroutine processing items in order avoids the reorder step entirely. Fan-out wins when the parallelism savings outweigh the bookkeeping.
Cost: Reordering at the end means buffering all results. If the result set is huge, this is a memory hit. For streaming output that must stay in order, you need a more complex setup (per-worker output channels merged in order) that often isn't worth it.
A subtle benefit of the unbuffered worker pool: the producer naturally slows down to match the workers. The send jobs <- job{...} blocks until some worker is ready to receive. If workers are slow, the producer waits. If workers are fast, the producer runs at full speed.
This is backpressure, and it emerges directly from channel semantics. The producer can't get ahead of the consumers because the channel acts as a one-slot rendezvous point.
If you add a buffer, you get a bounded queue: the producer can run up to cap(jobs) slots ahead before blocking. That's useful when the producer is bursty and you want to keep workers busy during gaps. It's not useful when the producer can generate jobs much faster than workers process them, since the buffer just fills up and then the producer blocks anyway. Buffering trades a small steady-state memory cost for smoother throughput; it doesn't change the underlying rate-limiting property.
The naive alternative, having the producer push everything into a slice up front and then start workers, loses this property. With a 1-million-entry slice, the program holds a million items in memory whether or not the workers are keeping up. With a streaming channel, only the in-flight items take memory.
The worker pool above already does both. The producer fans out to N workers by writing to one shared channel. The workers fan in to one results channel by all writing to it. The pattern is sometimes drawn as a single stage:
When workers each have their own output channel, you merge them with a separate goroutine per channel. That's needed when workers produce streams instead of one result per job, or when you need to attach metadata about which worker produced what. For the simple "one job in, one result out" case, all workers sharing a single results channel is enough.
In production pipelines, you often see this stage chained with others: a producer reads from a database, fans out to workers that enrich rows, fans into a results channel, which feeds another worker pool that writes to a downstream service. Each stage applies bounded concurrency where it makes sense.
A few mistakes show up reliably the first few times you write a worker pool.
Forgetting to close the jobs channel. Workers stay blocked on for range jobs, wg.Wait() never returns, the closer never closes results, and the consumer hangs forever. Always close the jobs channel after the producer's loop. The producer goroutine should own that close.
Closing results from a worker. If you write close(results) inside a worker, the moment the first worker finishes, every other worker's results <- ... panics with "send on closed channel". The fix is the dedicated closer goroutine that waits on wg and then closes results. Only one entity should close a channel, and it must be sure no one else will send to it.
Forgetting `wg.Add` before the goroutine starts. Calling wg.Add(1) inside the worker goroutine creates a race with wg.Wait(): main might call Wait() before any worker has called Add(), see a counter of 0, and return immediately. Always Add from the goroutine that starts the worker, before the go statement.
Sharing the `wg` by value instead of pointer. A sync.WaitGroup must be passed by pointer (*sync.WaitGroup) so all goroutines update the same counter. Passing by value gives each worker its own copy and Wait() never sees the increments.
A panic in one worker takes down the pool. A panic propagates and the program exits unless you recover. For long-running pools, wrap the worker body in defer recover() and log the error.
10 quizzes