1. Why fan-out and fan-in are needed
When you’re just starting to write concurrent code, almost always you come up with one naive plan: “I’ll launch N goroutines, they’ll compute something, and somehow I’ll collect the results later.” The naivety is in the phrase “somehow”: in concurrency, “somehow” usually means “on Friday evening you’ll be staring at a hung program and thinking it’s karma.”
Fan-out/fan-in are two very practical templates that give you a clear scheme: how to distribute independent work to multiple goroutines (fan-out), and how to then gather their answers in one place (fan-in). This is not magic and not a framework—just discipline: who writes, who reads, who closes the channel, and what happens if someone “left.”
To make it easier to remember, imagine a restaurant. Fan-out: you hand orders to different cooks. Fan-in: you collect finished dishes at a single pickup counter. Important: cooks must not stand forever holding a plate if the counter is closed.
Let’s draw the overall picture:
flowchart LR
A[Input data] -->|dispatch| B1[worker 1]
A -->|dispatch| B2[worker 2]
A -->|dispatch| B3[worker 3]
B1 -->|result| C[shared results]
B2 -->|result| C
B3 -->|result| C
C --> D[collector / main]
2. Fan-out: dispatching work to goroutines
Fan-out usually starts simple: we have a list of tasks (for example, numbers, strings, elements). We want to process each element independently. The processing logic can be anything: compute, parse, validate, “normalize” a string, compute a hash, and so on. In tutorial examples we’ll do something simple, but we’ll think like engineers: “what happens if the handler is fast/slow, and the result reader is also fast/slow?”
The smallest fan-out: squares of numbers
Here’s the “skeleton”: create results, launch one goroutine per element, then read N results.
package main
import (
"fmt"
)
func main() {
values := []int{2, 3, 4}
results := make(chan int, len(values)) // buffer for all results
for _, v := range values {
go func(v int) {
results <- v * v
}(v) // important: passed v as a parameter
}
for i := 0; i < len(values); i++ {
fmt.Println(<-results) // order is not guaranteed
}
}
Notice two important ideas.
First, results is buffered to len(values). That means each goroutine can “put the result” and finish even if main hasn’t started reading yet. This is a very useful safety net against accidental blocking.
Second, we pass v into the anonymous function as a parameter: }(v). Even in Go 1.22+ many typical range pitfalls got better, but the habit of “making the goroutine input explicit” is like the habit of buckling up. Maybe you’ll get there anyway, but it’s calmer when you’re buckled in.
Why sending a result can block
Now an important point many people underestimate: sending results <- x is also synchronization. If the channel is unbuffered (or the buffer is full), the sender will wait for a reader. And if there is no reader and won’t be—then the goroutine hangs.
From the program’s lifecycle point of view it looks like this: your code “seems to have finished,” but the process for some reason is still holding dozens/hundreds/thousands of goroutines that are stuck on send. This is one of the most common paths to goroutine leaks.
3. Fan-in: collecting results
Fan-in is the other side of the story. We want results to flow into one channel that is read by main or a “collector.” In practice, fan-in comes in two variants.
The first variant: we already have one shared results (as in the squares example), and we simply read N values from it. This works fine if we know N and we guarantee that each worker sends exactly one result.
The second variant: we have several input channels (for example, different data sources), and we want to “merge” them into one output. That’s where the classic function merge comes in.
merge: combining two channels into one
Below is the minimal version: two goroutines “forward” values, and a separate coordinator goroutine closes out when both forwards have finished.
package main
import (
"sync"
)
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
forward := func(in <-chan int) {
defer wg.Done()
for v := range in {
out <- v
}
}
go forward(a)
go forward(b)
go func() {
wg.Wait()
close(out)
}()
return out
}
The key discipline here: a single coordinator closes out. Not “anyone who got tired,” not “whoever figured it out first,” not “let’s all close it and then see what happens.” In Go, closing a channel is an event you can’t do twice.
Since it’s important for us not just to “copy code,” but to understand the protocol, let’s phrase it in words.
forward reads from in via range. This means: forward will finish only when the input channel is closed. Therefore, closing in is the responsibility of whoever writes to in (the producer). When both forward functions have finished, the coordinator does close(out), and the reader of out can finish reading via range.
4. Combining and extending the patterns
Fan-out + fan-in: a parallel task processor
Now let’s assemble both templates into a mini-story that resembles real application tasks. Suppose we have a list of “tasks” (in our tutorial style—just numbers), and we want to process them in parallel and then collect the results.
Let’s make a result type so it’s clearer what exactly we return.
package main
import (
"fmt"
"time"
)
type Result struct {
Input int
Value int
}
func work(x int) Result {
time.Sleep(20 * time.Millisecond) // simulate work
return Result{Input: x, Value: x * x}
}
func main() {
values := []int{2, 3, 4}
results := make(chan Result, len(values))
for _, v := range values {
go func(v int) {
results <- work(v)
}(v)
}
for i := 0; i < len(values); i++ {
r := <-results
fmt.Println(r.Input, "->", r.Value) // e.g.: 3 -> 9
}
}
This is already a complete fan-out (one goroutine per task) and fan-in (one results channel that collects answers).
Note: the output order will be “whatever happened.” If you need a stable order, you typically either sort results afterward, or use a different protocol (for example, include the index in the result). Concurrency rarely gives you “everything fast and in the order I like,” so the engineering habit of “not relying on order” saves nerves.
The first result wins
Sometimes you don’t need all results. You need the first successful/first arrived one. A typical example: “ask several sources and take the answer that comes back faster.”
In Go this is usually done with a channel of capacity 1 and a non-blocking send via select with default. A non-blocking send guarantees that a goroutine won’t hang even if the reader has already “left” or the result is no longer needed.
package main
import (
"fmt"
)
func main() {
values := []int{2, 3, 4}
out := make(chan int, 1) // only one answer needed
for _, v := range values {
go func(v int) {
r := v * v
select {
case out <- r:
// winner sent the result
default:
// channel already full: loser just exits
}
}(v)
}
fmt.Println(<-out) // one of: 4, 9, 16
}
Why is a 1 buffer important here? Because otherwise you can hit a race: the “winner” tried to send, but the reader hasn’t started reading yet. The idea of this pattern is: the buffer guarantees the first sender has somewhere to put the value, and default guarantees the rest won’t hang.
By the way, this shows in a very “grown-up” way that default is a dangerous tool. It makes an operation non-blocking, but at the price that you can lose data. In this pattern, losing data is a feature by contract (“we need one answer”). If by contract loss is not allowed, you must not use default.
5. Protocols: who closes the channel and when
With concurrency, the problem is often not syntax, but that people don’t verbalize the protocol. Let’s fix a few rules as a small table. This is not the “one true truth,” but it’s a very good starting contract.
| Question | Good answer | Why |
|---|---|---|
| Who closes the channel? | The one who writes to the channel (producer) | The reader doesn’t know “whether there will be more values” |
| Can you close a channel from multiple places? | No | Double close = panic |
| Can you close results if N goroutines write to it? | Yes, but it is closed by a coordinator who waited for all writers to finish | Otherwise someone may write to a closed channel |
| What’s better: read N values or read range until close? | Depends on the protocol | If N is known—read N times; if the stream is “until it ends”—use range |
What does this look like in practice in fan-out, where there are many writers? Often WaitGroup is used, and a separate goroutine closes results when all writers are done. This is almost the same technique we saw in merge.
package main
import (
"sync"
)
func fanOutSquares(values []int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(values))
for _, v := range values {
go func(v int) {
defer wg.Done()
out <- v * v
}(v)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Now the consumer can do:
for x := range fanOutSquares([]int{2, 3, 4}) {
_ = x
}
This is convenient because reading “until the stream ends” is usually easier to maintain than “exactly N times.” But again: this works only if you really close the channel correctly.
6. Typical mistakes
Mistake #1: unbuffered results + “I’ll read later” → senders hang.
Beginners often do results := make(chan int) and launch a dozen goroutines, each writing a result. And they read results “at the end,” after starting all goroutines. But an unbuffered channel requires a simultaneous reader: goroutines will start blocking on results <- ... on the very first sends. Fix it either with a buffer “for the expected count,” or by starting to read immediately, or with a separate collector.
Mistake #2: “first result wins,” but sending is done with a plain out <- r.
It seems logical: “I’ll read one result and leave.” The problem is that losing goroutines will still try to send and will hang, because there is no reader anymore. The correct protocol is either a non-blocking send via select { case out <- r: default: }, or a separate cancellation signal.
Mistake #3: closing out from multiple goroutines.
This is one of the most common “panic out of nowhere” cases. In fan-in merge it’s tempting to do: “as soon as my input ends—I’ll close the output.” But if there are two inputs, the output will be closed twice. The solution is simple and boring (which means correct): the output is closed by one coordinator who waits for all participants (for example, via WaitGroup).
Mistake #4: confusing “channel is empty” with “channel is closed.”
Sometimes people do a non-blocking read and think: “if it didn’t read, it means it’s closed.” No: “didn’t read” means “there is no value right now.” Closure is determined either via v, ok := <-ch (where ok == false), or via the end of range. If you build logic on the wrong distinction, behavior will be unstable: sometimes it works, sometimes it doesn’t—the classic concurrency bug pattern.
Mistake #5: relying on result order.
Fan-out almost guarantees that the order “like in the original list” will not be preserved. If order matters, it must be part of the data (for example, add an index to Result) and then reorder, or collect results into a structure by index. And yes, this is the moment when many people first realize that concurrency is not a “make it fast” button, but a contract: “do it in parallel, but think.”
GO TO FULL VERSION