Concurrency patterns in Go: worker pool, pipeline, fan-in
Interview questions on patterns climb a ladder: write a worker pool — how many workers to take — in what order the results arrive — who closes the channel — how to merge several channels into one — how to limit parallelism — how to stop a pipeline if the consumer has left. A run answers each step: on two cores eight workers compute no faster than two, while on a sleeping task they are eight times faster than one; a stage that forgot close crashes a small program and silently hangs one where even a single timer is alive; and a consumer that left without cancelling leaves two goroutines behind forever.
Full technical treatment
TL;DR
A concurrency pattern is a way to connect goroutines with channels so that every channel has one owner who closes it, and every goroutine has a way out whatever happens. A worker pool is N goroutines reading one job channel. A pipeline is stages connected by channels: each reads its input until it is closed and closes its own output. Fan-in merges several channels into one, and it is closed by whoever has waited for all the senders. A semaphore on a buffered channel lets no more than K goroutines into the work at once.
The main consequence: the number of workers runs into what the task is busy
with. On a CPU-bound task in our configuration (2 cores, GOMAXPROCS=2) the
second worker gave a speedup of about 2.0, and the third and the rest gave
nothing more: no more than GOMAXPROCS threads execute Go code at once. On a
sleeping task eight workers were 8.0 times faster than one, sixteen 16.0 times:
a sleeping goroutine does not occupy a core. These are observations from one
machine; the mechanism explains why, and the ratio for your own task has to be
measured on your own machine.
Beyond that comes what separates real understanding from having read about
it. Pool results arrive in order of readiness: tasks a b c d came back as
[b d c a], and the order is restored by the task index. A pipeline stage that
forgot close crashes the program with fatal error: all goroutines are asleep - deadlock! — but only if everyone is asleep: with a live timer the
program does not crash, it hangs. wg.Wait() before reading the results is the
same deadlock, and a buffer smaller than the number of results only postpones
it. close(out) in a merge without
wg.Wait() is panic: send on closed channel. A semaphore of capacity 3 let
no more than three into the work at a time; there were 12 goroutines if the slot
is taken inside the goroutine, and no more than three if it is taken before
go. A consumer that left without cancelling left two goroutines behind
forever. With context — none, but only if the stage listens for
cancellation at both of its waiting points: a stage with range over its input
did not hear cancellation on an open empty input. And sync.WaitGroup.Go
exists only from go1.25.
Measurements — go1.24.7 and go1.25.1, linux/amd64, 2 cores; documentation and sources checked on 14.10.2026. The newer Go 1.26 and 1.27 were not run by this lesson.
- that
go f()runs a function in a new goroutine, and a channel frommake(chan T)passes values between goroutines: send isch <- v, receive is<-ch; in detail — in the lesson on channels; - how a function inside a loop captures variables — that is the lesson on closures;
rangeover a channel,select,sync.WaitGroupandctx.Done()— "Base" has one sentence on each; in detail — in the lessons on channels, on sync and atomic and on context.
- the worker pool, the pipeline, fan-out and fan-in, a semaphore on a channel;
- who closes a channel and what happens if it is not closed or closed too early;
- stopping a pipeline on cancellation and on the first error,
sync.WaitGroup.Go.
What is really being asked
The ladder is almost always this one:
- "Write a worker pool" — the warm-up; almost everyone writes the code, and the questions start after it.
- "How many workers should you take?" — this checks whether the candidate tells a CPU-bound task from a task that waits.
- "Will the results arrive in the same order as the tasks?"
- "Who closes the result channel, and when?" — and what happens if it is not closed, or closed in the wrong place.
- "Merge N channels into one" — fan-in.
- "How do you limit the number of concurrent queries to the database?" — a semaphore.
- "The consumer left early — what happens to the pipeline's goroutines?" — cancellation and leaks; then "how do you stop everything on the first error".
The ladder has one spine: a channel is closed by whoever knows there will be no more sends — and only by them; and every goroutine has a way out whatever happens — both when the work is done and when it is no longer needed. Every breakage below violates one of the two halves of that sentence.
Base: four shapes made of goroutines and channels
The shapes are built from four parts, and one sentence about each is enough for
this lesson. range ch receives values from a channel one after another.
select waits until at least one of the channel operations listed in it can
proceed, and performs one of the ready ones. sync.WaitGroup is a counter:
Add(n) adds, Done() subtracts one, Wait() waits for zero. ctx.Done() is
a channel that is closed when the context's cancel() is called. In detail —
in the lessons on channels,
on sync and atomic and
on context.
All four shapes rest on one rule of the specification:
the built-in function close records that no more values will be sent on the
channel
And how the receiver sees it — range over a channel:
For channels, the iteration values produced are the successive values sent on
the channel until the channel is closed.
By itself, a range over a channel ends only on close. The whole mechanics follows
from this: a goroutine reading with range will exit when someone closes its
input — which means someone has to. Here is the worker pool on which sections 1
and 2 of the run were taken.
Fragment of bench/gopattern/internals.go:
func pool(workers int, jobs []uint64, work func(uint64) uint64) []uint64 {
in := make(chan int)
out := make(chan result)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range in {
out <- result{i, work(jobs[i])}
}
}()
}
go func() {
for i := range jobs {
in <- i
}
close(in)
}()
go func() {
wg.Wait()
close(out)
}()
res := make([]uint64, len(jobs))
for r := range out {
res[r.idx] = r.val
}
return res
}There are three kinds of goroutine here, and every channel has exactly one
closer. The job channel in is closed by the feeder — only it knows there are
no more jobs. The result channel out is written by N workers, and none of them
knows whether it is the last — so it is closed by a separate goroutine that
waited in wg.Wait(). The caller reads the results, and its range ends when
out is closed.
The other three shapes are made of the same parts:
| shape | what it does | who closes the output | typical breakage |
|---|---|---|---|
| worker pool | N goroutines read one job channel | a goroutine after wg.Wait() | wg.Wait() before reading the results |
| pipeline | stages connected by channels | each stage its own output | a stage forgot close |
| fan-in | N channels merge into one | a goroutine after wg.Wait() | close without Wait — a panic |
| semaphore | no more than K jobs at once | the channel is not closed | limits work, not the number of goroutines |
Fan-out is handing one channel to several readers; the worker pool is fan-out, so it has no row of its own.
That is enough for a Junior-level answer: a worker pool is N goroutines that
read jobs from one channel and write results to another. The job channel is
closed by whoever hands out the jobs; the result channel by a separate
goroutine after wg.Wait(), because no worker knows whether it is the last.
Mechanism 1: how many workers — depends on what the task is busy with
The second question on the ladder, and the one people get wrong most often:
"the more workers, the faster". The runtime package documentation sets the
ceiling:
The GOMAXPROCS variable limits the number of operating system threads that can
execute user-level Go code simultaneously.
The pool above, 16 tasks — a CPU loop with no memory and no waiting. A machine with two cores; each configuration was run 15 times interleaved with the others.
Output of bench/gopattern/internals.go, section 1; labels translated:
1) WORKER POOL: A CPU-BOUND TASK
────────────────────────────────
16 tasks, each 3,000,000 xorshift steps; best of 15 interleaved rounds
GOMAXPROCS workers time speedup over 1 worker cores busy (CPU/time)
2 1 102 ms 1.0 1.0
2 2 52 ms 2.0 2.0
2 4 53 ms 1.9 1.9
2 8 53 ms 1.9 2.0
1 1 104 ms 1.0 1.0
1 8 102 ms 1.0 1.0
Two workers are twice as fast as one, and after that there is no gain: with
eight workers the same two run at any moment, and the rest wait in the
scheduler's queue. The bottom two rows are the same check from the other side:
with GOMAXPROCS=1 eight workers are no faster than one. On two cores the
"cores busy" column cannot rise above two anyway. That the ceiling comes from
GOMAXPROCS is shown by the bottom rows: with GOMAXPROCS=1 eight workers used
one core (1.0), although two were free.
This works through P, the scheduler's logical processor. The comment at the top
of runtime/proc.go (line 28 on go1.24.7) defines it as "P - processor, a
resource that is required to execute Go code". How many Ps to create the
runtime takes at startup from GOMAXPROCS (lines 876–880, the call
procresize(procs)). A thread without a P does not execute Go code, so workers
beyond the number of Ps simply wait their turn. In detail — in the
lesson on the scheduler.
Now the same shape, but the task is time.Sleep(20 ms), like waiting for a disk
or the network.
Output of bench/gopattern/internals.go, section 2; labels translated:
2) WORKER POOL: A SLEEPING TASK
───────────────────────────────
16 tasks, each time.Sleep(20 ms); best of 3 rounds
workers time speedup over 1 worker
1 324 ms 1.0
2 162 ms 2.0
4 81 ms 4.0
8 41 ms 8.0
16 20 ms 16.0
A sleeping goroutine does not occupy a core, and the speedup grows with the
number of workers in this run until there were as many workers as tasks. Hence
the answer to "how many workers": for a CPU-bound task — on the order of
runtime.GOMAXPROCS(0): no more run at once, and in our measurement more did
not speed it up; for a task that waits — as many as whatever it waits on can
take: the database, someone else's API, the disk. The exact number for your
task is found by measuring. Cores are not the limit
here, the external resource is, and it is enforced with a semaphore (Mechanism 4).
Results arrive in order of readiness
The third question. Four tasks a b c d take 80, 20, 60 and 40 ms, there are
four workers — each task got its own.
Output of bench/gopattern/internals.go, section 3; labels translated:
3) ORDER OF RESULTS
───────────────────
tasks a b c d, taking 80, 20, 60, 40 ms; 4 workers
in order of arrival: [b d c a]
res[i] by task index: [A B C D]
arrival order matched the first one in 20 repetitions out of 20
Values leave the result channel in the order the workers sent them — that is,
in order of readiness, not of tasks. Here the durations are 20 ms apart, and in this
run the order matched in all 20 repetitions. That is not a guarantee: the
arrival order depends on which worker managed to send first, and on a loaded
machine the 40 ms task can arrive before the 20 ms one. The task order is restored by an index: the
worker sends not only the answer but also the task number, and the receiver
puts the answer into res[i]. That is how pool above is written —
result{i, work(jobs[i])} and res[r.idx] = r.val. Writes to different slice
elements from a single receiving goroutine create no race: only it writes them.
Mechanism 2: a pipeline rests on close
A pipeline is a chain of stages: each reads its input with range and writes to
its output. Each stage closes its output when its input has ended — and the
"no more values" signal travels down the chain by itself.
Fragment of bench/gopattern/internals.go:
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}Output of bench/gopattern/internals.go, section 4; labels translated:
4) PIPELINE: GEN → SQ → CONSUMER
────────────────────────────────
the consumer received: [1 4 9 16 25]
range ended: the gen stage closed its output, the range in sq
exited, sq closed its own — the "no more values" signal went down the chain
goroutines after the pipeline: +0
Now sq forgot close(out) — one line. The program cases/forgotclose: the
same gen and sq, and main reads the output of sq with range.
Output of bench/gopattern/internals.go, section 5; labels translated; cases/forgotclose crashes on purpose, the fix is sq with close(out) above:
5) A STAGE FORGOT CLOSE: EVERYONE IS ASLEEP
───────────────────────────────────────────
1 4 9 16 25 fatal error: all goroutines are asleep - deadlock!
goroutine <N> [chan receive]:
main.main()
<cases>/forgotclose/main.go:37
exit code: 2
The consumer received all five values. Then gen closed its output, the
range in sq exited, sq returned — and its output stayed open. main
waits in range for a sixth value that will never come, and there is nobody
left to tell it "no more". There are no other goroutines, and the runtime
crashes the program. The stack shows where it waited: [chan receive] on the
range line.
The same fatal error comes from a mistake people make in a worker pool: wg.Wait()
right in main, before reading the results (cases/waitbeforeread, three
workers, six jobs, an unbuffered result channel).
Output of bench/gopattern/internals.go, section 6; labels translated; cases/waitbeforeread crashes on purpose, the fix is pool in "Base":
6) WG.WAIT BEFORE READING THE RESULTS
─────────────────────────────────────
fatal error: all goroutines are asleep - deadlock!
goroutine <N> [sync.WaitGroup.Wait]:
goroutine <N> [chan send]:
goroutine <N> [chan send]:
goroutine <N> [chan send]:
goroutine <N> [chan send]:
exit code: 2
The runtime's report reads like a diagram of mutual waiting: main sits in
[sync.WaitGroup.Wait] waiting for the workers, three workers sit in
[chan send] waiting for main to start reading, and the feeder sits in
[chan send] with jobs nobody can take. The cure is what pool does:
wg.Wait() and close(out) in a separate goroutine, and the caller reads right
away.
A buffer on the result channel does not cure the bug, it hides it. The same
program with a buffer of 6 (cases/waitbuffered).
Output of bench/gopattern/internals.go, section 6, continued; labels translated:
cases/waitbuffered — the same bug, but a result buffer of 6:
jobs 6: jobs: 6 sum of squares: 91
exit code: 0
jobs 7: fatal error: all goroutines are asleep - deadlock!
exit code: 2
As long as there are no more jobs than the capacity, the workers drop the results into the buffer and exit, and tests with six jobs pass. The seventh result has no room — and the deadlock comes back under the first real load.
A buffer by itself is not a bug. It is a correct design when the number of
results is known in advance and the capacity is derived from that same number:
make(chan result, len(jobs)) with one result per job. Then every send finds
room — the specification lets a send on a buffered channel proceed if there is
room in the buffer — and wg.Wait() before reading will not block. The bug is
a constant unrelated to the number of jobs, like the 6 in this example.
Why a forgotten close in a real service usually does not crash but hangs is
covered in the "Deeper" section.
Mechanism 3: fan-in — only whoever waited for everyone can close the output
Merging several channels into one: a forwarding goroutine per input, one shared output. It cannot be closed in any of the forwarding goroutines: each knows only about its own input. The rule is the same as for the pool's result channel — it is closed by a goroutine that waited for everyone.
Fragment of bench/gopattern/internals.go:
func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range cs {
wg.Add(1)
go func() {
defer wg.Done()
for v := range c {
out <- v
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}Output of bench/gopattern/internals.go, section 8; labels translated:
8) FAN-IN: MERGING CHANNELS
───────────────────────────
three sources of 4 values each: received 12, sum 78
range ended: close(out) comes after wg.Wait()
goroutines after the merge: +0
Two details of this code are asked about separately. wg.Add(1) comes before
go, not inside the goroutine — the WaitGroup.Add documentation says so
directly:
Typically this means the calls to Add should execute before the statement
creating the goroutine or other event to be waited for.
Otherwise Wait may see zero before the goroutine gets to its Add. And each
goroutine has its own loop variable c — since Go 1.22 every iteration of
for creates a new variable; why this matters for a function started in a loop
is covered in the lesson on closures.
What happens if close(out) is put right away, without wg.Wait()
(cases/earlyclose; the sources answer after 50 ms, like a network).
Output of bench/gopattern/internals.go, section 8, continued; labels translated; cases/earlyclose crashes on purpose, the fix is merge above:
cases/earlyclose — close(out) right away, without wg.Wait():
values received: 0
panic: send on closed channel
goroutine <N> [running]:
main.merge.func1()
<cases>/earlyclose/main.go:36
created by main.merge in goroutine <N>
<cases>/earlyclose/main.go:33
exit code: 2
The receiver saw a closed channel and left the range with zero values. And the
very first forward (line 36 — out <- v) crashed: the specification calls
sending to a closed channel a run-time panic. This is a different breakage
from a forgotten close: the forgotten one hangs the receiver, the early one
crashes the sender and loses data.
Since go1.25 WaitGroup has a method that puts Add in place by itself:
Go calls f in a new goroutine and adds that task to the WaitGroup. When f
returns, the task is removed from the WaitGroup.
In the go1.25.1 source the method has a five-line body: wg.Add(1), then a goroutine with
defer wg.Done() and a call to f() (sync/waitgroup.go, lines 235–241). On
go1.24.7 the method does not exist — the difference between versions is
collected in "Version history".
Mechanism 4: a semaphore — where you take the slot decides how many goroutines there are
The sleeping task from Mechanism 1 sped up with the number of workers, but in real life the "sleep" is a database query, and the database will not take any number of concurrent queries. This is limited with a buffered channel: to take a slot — send into it, to release — receive from it. A send into a full buffered channel waits, so no more than its capacity will be inside at once.
Where exactly the slot is taken is not a matter of style. The first arrangement
is a goroutine per job, the slot taken inside. Fragment of
bench/gopattern/semaphore.go (c holds counters of live and working
handlers):
for i := 0; i < tasks; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.enter()
defer c.leave()
sem <- struct{}{}
defer func() { <-sem }()
c.work()
}()
}Output of bench/gopattern/internals.go, section 9 — the same arrangement,
next to a version without a semaphore; labels translated:
9) A SEMAPHORE ON A BUFFERED CHANNEL
────────────────────────────────────
12 goroutines, each sleeps 20 ms; best time of 3 rounds
semaphore ran at once goroutines time
make(chan, 3) 3 12 81 ms
none 12 12 20 ms
The observed maximum running at once is exactly the capacity, 3; in this run the
time grew fourfold, because twelve jobs went through in four waves of three.
But there were 12 goroutines: in this arrangement all are created at once,
and nine wait on sem <- struct{}{}, each with its own stack.
The second arrangement is the slot taken before go, released in a
defer of the started goroutine. Fragment of the same file:
for i := 0; i < tasks; i++ {
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
c.enter()
defer c.leave()
c.work()
}()
}Output of bench/gopattern/semaphore.go, sections 1 and 2; goroutines are
counted with our own counter of live handlers, not with runtime.NumGoroutine(),
which also includes service goroutines; labels translated:
1) A GOROUTINE PER JOB, THE SLOT TAKEN INSIDE
─────────────────────────────────────────────
jobs 12, capacity 3: handlers alive at once 12, working 3
2) THE SLOT TAKEN BEFORE GO
───────────────────────────
jobs 12, capacity 3: handlers alive at once 3, working 3
Now the dispatch loop itself waits on a send into the full channel, and no new
goroutine appears until a slot frees up: no more than three handlers are alive.
If the dispatch has a context, waiting for a slot must also listen for
cancellation — otherwise the loop may sit on sem <- struct{}{} for as long as
it likes. Fragment of the function acquireWithCancel from the same file:
select {
case sem <- struct{}{}:
case <-ctx.Done():
err = ctx.Err()
}
if err != nil {
break
}Output of bench/gopattern/semaphore.go, section 3; labels translated:
3) A SLOT BEFORE GO, WAITING FOR A SLOT LISTENS TO CTX.DONE()
─────────────────────────────────────────────────────────────
handlers started: 3 of 12
dispatch stopped with an error: context canceled (errors.Is context.Canceled: true)
Three handlers took all the slots and hold them; when the context was cancelled, the fourth wait for a slot ended with an error, and the other nine jobs were never started.
Hence a comparison of the three shapes by what really differs between them:
| worker pool | goroutine per job, slot inside | slot before go | |
|---|---|---|---|
| live handlers | exactly N, living the whole time | one per job — 12 in the run | no more than K — 3 in the run |
| goroutine reused | yes: the worker takes the next job | no | no: a new one per job |
| where jobs wait | in the job channel | in goroutines waiting on the semaphore | in the dispatch loop waiting on the semaphore |
| results and errors | a result channel | each goroutine on its own | each goroutine on its own |
Both a pool and a semaphore with the slot before go can limit the number of
goroutines; the difference is whether a goroutine lives longer than one job and
where the queue builds up. The "slot inside" shape will create a million
goroutines for a million jobs — that is a property of the arrangement, not of
the semaphore.
Mechanism 5: stopping — the consumer left early, or an error happened
The last question on the ladder. The source countFrom is endless — like
reading from a network or a queue — and the consumer takeThree takes three
values with range, does break and returns.
Output of bench/gopattern/internals.go, section 10; labels translated:
10) THE CONSUMER LEFT EARLY: WITHOUT CONTEXT
────────────────────────────────────────────
takeThree(sq(countFrom())) -> [1 4 9]
goroutines after: +2
countFrom: [chan send]
sq: [chan send]
The program did not crash and the answer is right — yet two goroutines stayed
behind forever: both sit on a send nobody will accept. There is no fatal error here, because main is alive and running. The garbage collector does not
collect such goroutines; why — and how such leaks are caught — is covered in the
lesson on channels, Mechanism 5. What matters
here is that counting runtime.NumGoroutine() before and after shows the
leak. For a test that is not enough: a program can have service goroutines, and
a snapshot of the counter can match by chance. It is more reliable to wait for
a completion signal — more on that below and in "Next they ask".
The first attempt at a cure is to give the send a second way out,
cancellation. Fragment of bench/gopattern/internals.go (in cancel.go the
same function is called sqRange):
func sqCtx(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}Output of bench/gopattern/internals.go, section 11 — combined with the source
countFromCtx, which also listens for cancellation; labels translated:
11) THE CONSUMER LEFT EARLY: WITH CONTEXT
─────────────────────────────────────────
takeThree(sqCtx(ctx, countFromCtx(ctx))) -> [1 4 9]
goroutines after defer cancel(): +0
In this combination it all worked out: on defer cancel() the source exited and
closed its output, and the range in the stage ended. But a stage has two
waiting points — receiving from its input and sending to its output — and only
the second listens to ctx.Done(). A range over the input does not hear
cancellation. bench/gopattern/cancel.go checks both versions of the stage on
four scenarios, and decides completion not by counting goroutines but by a
signal: whether the stage's output closed within 500 ms.
Output of bench/gopattern/cancel.go, section 1; labels translated:
1) THE sqRange STAGE: RANGE OVER INPUT, SELECT ONLY ON SEND
───────────────────────────────────────────────────────────
normal flow 1, 2, 3: received [1 4 9], output closed: yes
input closed and empty: output closed: yes
cancel, input open and empty: output closed within 500 ms: NO
cancel, input nil: output closed within 500 ms: NO
cancel, nobody reads the output: output closed within 500 ms: yes
If the upstream stage neither sends nor closes the channel, the stage with
range does not react to cancellation: cancel() is a signal, not a forced
stop, and a goroutine exits only if it listens to ctx.Done() at the moment it
waits. The fix is a select with cancellation at both points. Fragment of
bench/gopattern/cancel.go:
func sqCtx(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
var n int
var ok bool
select {
case <-ctx.Done():
return
case n, ok = <-in:
if !ok {
return
}
}
select {
case <-ctx.Done():
return
case out <- n * n:
}
}
}()
return out
}Output of bench/gopattern/cancel.go, section 2; labels translated:
2) THE sqCtx STAGE: SELECT ON BOTH RECEIVE AND SEND
───────────────────────────────────────────────────
normal flow 1, 2, 3: received [1 4 9], output closed: yes
input closed and empty: output closed: yes
cancel, input open and empty: output closed within 500 ms: yes
cancel, input nil: output closed within 500 ms: yes
cancel, nobody reads the output: output closed within 500 ms: yes
All four scenarios ended with a closed output. Two caveats. Cancellation has no
priority: if both the send and ctx.Done() are ready, select picks one of the
ready branches, so after cancel() the stage may still send one more value. And
the stage does not close its input — it belongs to whoever writes to it. How
cancel closes Done and why it is always called, not only on cancellation, is
in the lesson on context.
All of this fits into a short program. The full file
bench/gopattern/cases/pipeline/main.go — you can copy it into an empty
directory next to a go.mod (module example, go 1.24) and run it with
go run .:
package main
import (
"context"
"fmt"
)
func count(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := 1; ; n++ {
select {
case <-ctx.Done():
return
case out <- n:
}
}
}()
return out
}
func sq(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
var n int
var ok bool
select {
case <-ctx.Done():
return
case n, ok = <-in:
if !ok {
return
}
}
select {
case <-ctx.Done():
return
case out <- n * n:
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := sq(ctx, count(ctx))
for i := 0; i < 3; i++ {
fmt.Println(<-out)
}
cancel()
for range out { // drain: sq may still send one more value
}
fmt.Println("sq closed its output: pipeline stopped")
}Output (runs/pipeline.txt, go1.24.7; the same on go1.25.1):
1
4
9
sq closed its output: pipeline stopped
The last line is printed only after the range over the output of sq has
ended — that is, after sq closed its output. That is the check of stopping by
a signal, not by a count.
Stopping on the first error is the same thing, only it is not the consumer
that cancels but the stage where the error happened. On the standard library
this is about thirty lines. Below is a minimal analogue of errgroup.WithContext
from golang.org/x/sync: the module itself is not part of the standard library
and was not pulled in here, so the code is our own.
Fragment of bench/gopattern/internals.go:
type group struct {
wg sync.WaitGroup
once sync.Once
err error
cancel context.CancelCauseFunc
}
func withContext(parent context.Context) (*group, context.Context) {
ctx, cancel := context.WithCancelCause(parent)
return &group{cancel: cancel}, ctx
}
func (g *group) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := f(); err != nil {
g.once.Do(func() {
g.err = err
g.cancel(err)
})
}
}()
}
func (g *group) Wait() error {
g.wg.Wait()
g.cancel(g.err)
return g.err
}Five goroutines: a source that returns an error on the fourth record, three
handlers and one long job of 10 seconds — all listen to ctx.Done().
Output of bench/gopattern/internals.go, section 12; labels translated:
12) STOPPING ON THE FIRST ERROR
───────────────────────────────
Wait returned: record 4: bad record
errors.Is(err, errBad): true
context.Cause(ctx): record 4: bad record
the 10 s long job was interrupted: true (Wait took under a second: true)
goroutines after: +0
The first error cancelled the shared context, the long job left on
ctx.Done() without waiting out its ten seconds, and Wait returned exactly
that error — wrapped, so errors.Is finds it. context.Cause gives the same
cause to anyone who holds ctx. sync.Once is there so that a second error
does not overwrite the first.
Deeper: why a forgotten close crashes a small program and hangs a real one
In Mechanism 2 a forgotten close gave a fatal error. That is a convenient
answer at an interview but a dangerous habit: it seems such a bug shows up at
once. The run checks the same sq stage without close in a program where one
timer is alive — time.After in a neighbouring select.
Output of bench/gopattern/internals.go, section 7; labels translated:
7) A STAGE FORGOT CLOSE, BUT THE PROGRAM HAS A TIMER
────────────────────────────────────────────────────
after 500 ms the consumer is still in range; no fatal error
goroutines hanging: +1
the consumer started from main.main, [chan receive]
No crash at all: the consumer sits in [chan receive], and it is just one
extra goroutine. Why — you can see in the source of checkdead, the function
that prints that error. In runtime/proc.go on go1.24.7 it starts at line
6026, and before the call to fatal at line 6127 it has several early
returns; two of them matter here. The first is at lines 6055–6058: if even one
thread is executing code, the check ends (if run > run0 { return }). The
second is at lines 6119–6124.
Fragment of runtime/proc.go, lines 6119–6124 on go1.24.7:
// There are no goroutines running, so we can look at the P's.
for _, pp := range allp {
if len(pp.timers.heap) > 0 {
return
}
}If any P has a timer, the check ends without reaching fatal. "all
goroutines are asleep" literally means "everyone is asleep, and there are no
timers". In a service where a metrics timer ticks, any time.After waits or
even one goroutine is busy working, this condition almost never occurs — a
forgotten close there does not crash the process but piles up hanging
goroutines, one per such pipeline. It is caught by counting goroutines, as in Mechanism 5, not by
waiting for a crash.
How to answer in an interview
Short answer: a worker pool is N goroutines that read jobs from one channel
and write results to another; the job channel is closed by the feeder, the
result channel by a separate goroutine after wg.Wait(). For a CPU-bound task
no more than GOMAXPROCS workers run at once, so there is nowhere for a gain
beyond that to come from; for a task that waits, the number is limited by the
external resource, and it is enforced with a semaphore. A pipeline is stages
connected by channels, each closes its own output; so that stages do not leak
when the consumer has left, every wait of a stage — both the receive and the
send — sits in a select with ctx.Done().
That is enough to answer correctly. What follows is what gets added if the interviewer digs.
If the interviewer digs deeper
On "how many workers" — two lines of the run. "On a machine with two cores two workers gave about 2.0, and eight no more: no more than GOMAXPROCS threads execute Go code at once. On a sleeping task eight workers gave 8.0: a sleeping goroutine does not occupy a core. For your own task you measure it."
On "in what order are the results" — in order of readiness. "Tasks
a b c d came as [b d c a]. The order is restored with an index: the worker
sends the task number along with the answer, the receiver puts it into
res[i]."
On "who closes the channel" — whoever knows there will be no more sends.
"If there are several senders — a separate goroutine after wg.Wait(). Not
closing it — the receiver hangs; closing it too early — panic: send on closed channel."
On "what happens if you forget close" — not always a fatal error. "The
runtime crashes the program only if everyone is asleep and there are no
timers — that is how checkdead is written. In a service it is just a hanging
goroutine."
On "why goroutines remained after cancel" — the waiting points. "cancel()
is a signal, not a stop. A stage with range over its input did not hear
cancellation on an open empty input; with a select on both the receive and
the send it exited in all four scenarios."
On "how to stop on an error" — a shared context. "The first error calls
cancel(err), all stages listen to ctx.Done(), Wait returns the first
error. That is about thirty lines on WaitGroup, Once and
context.WithCancelCause."
Next they ask
How is a semaphore different from a worker pool, if both limit parallelism?
Not in whether it can limit the number of goroutines: a semaphore with the slot
taken before go kept no more than three handlers alive in the run — as many
as a pool of three workers. With the slot taken inside the goroutine there were
12. The differences are elsewhere. A pool reuses goroutines: a worker lives
the whole time and takes the next job from the channel, and the job queue is
that channel itself. With a semaphore every job gets its own goroutine that
lives for one job; the queue is the dispatch loop waiting on the semaphore. And
a pool collects results and errors into a channel, while with a semaphore each
goroutine hands them over itself — for example, through the group from
Mechanism 5.
Hence the choice: jobs arrive as a stream and a long-lived handler is needed — a
pool; the number of jobs is known and each naturally becomes its own goroutine —
a semaphore with the slot before go is shorter.
Can you just give the result channel a buffer instead of a separate goroutine with wg.Wait()?
You can, if the capacity is derived from the number of results:
make(chan result, len(jobs)) with one result per job is a correct design. You
cannot if the capacity is a constant and there may be more jobs. The run of
cases/waitbuffered with a buffer of 6.
Output of bench/gopattern/internals.go, section 6, fragment; labels translated:
jobs 6: jobs: 6 sum of squares: 91
exit code: 0
jobs 7: fatal error: all goroutines are asleep - deadlock!
exit code: 2
With six jobs and a buffer of 6 the program works, with the seventh — a
deadlock. Tests usually run on a small number of jobs, so such code passes them
and fails under load. If the number of results is not known in advance, there
is one way: read the results while the workers run, and do close after
wg.Wait() in a separate goroutine.
How do you check in a test that a pipeline does not lose goroutines?
Wait for a completion signal with a timeout instead of looking at a counter. A
stage closes its output in a defer, so a closed output is a signal that its
goroutine has returned. Fragment of bench/gopattern/cancel.go:
func closedWithin(out <-chan int, d time.Duration) ([]int, bool) {
var got []int
timeout := time.After(d)
for {
select {
case v, ok := <-out:
if !ok {
return got, true
}
got = append(got, v)
case <-timeout:
return got, false
}
}
}The timeout here is a limit after which the check honestly says "no", not a way
to "wait until things settle". If the goroutines have no output channel, the
same trick is done with a WaitGroup: a goroutine calls wg.Wait() and closes
a done channel, the test waits for done or the timeout. Counting
runtime.NumGoroutine() before and after, as in sections 10 and 11, is fine for
a demonstration but not for a test: a program can have service goroutines, and
a snapshot can match by chance. Once a leak is found, the stack of all
goroutines (runtime.Stack with all = true) shows who got stuck and on what:
in the run it is countFrom: [chan send] and sq: [chan send].
The race detector will not help here: there is no race, everything is synchronized by channels. This is covered in detail in the lesson on channels.
Why WaitGroup.Go, if there are Add and Done?
It removes the place where people make mistakes: Add ends up before the
goroutine starts by construction — the method itself does wg.Add(1) and only
then go. In the go1.25.1 source it has a five-line body. The caveat from the
documentation — the function must not panic.
The catch is the version. On go1.24.7 the method does not exist, and if
go.mod declares go 1.24, go1.25.1 will build the code, but go vet will
warn.
Output of bench/gopattern/internals.go under go1.25.1, section 13, fragment:
wggo/main.go:18:6: sync.Go requires go1.25 or later (module is go1.24)
This is the stdversion analyzer: it checks standard library symbols against
the version in go.mod. It is not in the subset of checks that go test runs
by itself (go help test), so the warning is visible only with an explicit
go vet.
Common misconceptions
The more workers, the faster
Only while there are no more workers than can run at once. On a CPU-bound task in our configuration (2 cores, GOMAXPROCS=2) two workers gave a speedup of about 2.0, eight no more, and with GOMAXPROCS=1 eight workers are no faster than one. On a sleeping task eight workers gave 8.0.
A pool returns results in task order
In order of readiness: tasks a b c d came as [b d c a]. The order is restored by the task index sent along with the answer.
A forgotten close always crashes the program with a fatal error
Only if every goroutine is asleep and there is not a single timer — that is how checkdead is written. In the run with one time.After the program did not crash: the consumer hung in [chan receive], +1 goroutine.
A channel can be closed in any of the senders — say, in the last worker
No sender knows whether it is the last. Only whoever waited for everyone can close it — a goroutine after wg.Wait(). Closing too early is panic: send on closed channel.
A channel semaphore does not limit the number of goroutines — only the work
It depends on where the slot is taken. Inside an already started goroutine — in the run three were working while there were 12 goroutines. Before go — no more than three live handlers: the dispatch loop itself waits for a free slot.
defer cancel() stops every stage of the pipeline
cancel() is a signal: a stage exits only if it listens to ctx.Done() at the moment it waits. The stage with range over its input on an open empty input did not close its output within 500 ms after cancellation; with a select on both the receive and the send it closed it in all four scenarios.
If the consumer has left, the garbage collector will collect the pipeline's goroutines
It will not: in the run, after the consumer left, +2 goroutines remained in [chan send]. With a select on ctx.Done() and defer cancel() — +0.
Version history
The lesson was run on go1.24.7 and go1.25.1 (linux/amd64, 2 cores);
documentation and sources were checked on 14.10.2026. By that date Go 1.26 and
Go 1.27 had been released — the lesson was not run on them, and the table has no
rows about them. The semantics of close, range and select are the language
specification; in this lesson only WaitGroup.Go depends on the version.
| Version | Change | What it means for code |
|---|---|---|
| go1.24.7 | There is no WaitGroup.Go method: go doc sync.WaitGroup.Go answers no method or field WaitGroup.Go in package sync, and the build — wg.Go undefined (type "sync".WaitGroup has no field or method Go). Checked by running bench/gopattern/internals.go, section 13. | only Add before go and defer Done inside |
| go1.25.1 | The method WaitGroup.Go(f func()) exists (api/go1.25.txt): the same run built cases/wggo and printed the sum. With go 1.24 in go.mod the build passes, while go vet warns: sync.Go requires go1.25 or later (module is go1.24). | wg.Go(f) instead of the Add and Done pair; go 1.25 in go.mod |
Practice
Two tasks. Answer first, then check against the real output: in both the correct answer is taken from a run of the script, not assigned.
Practice · predict the output
before := runtime.NumGoroutine()
in := sq(gen())
fmt.Println(<-in, <-in, <-in)
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutines left:", runtime.NumGoroutine()-before)
sem := make(chan struct{}, 2)
var now, peak atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
n := now.Add(1)
for {
m := peak.Load()
if n <= m || peak.CompareAndSwap(m, n) {
break
}
}
time.Sleep(10 * time.Millisecond)
now.Add(-1)
<-sem
}()
}
wg.Wait()
fmt.Println("ran at once:", peak.Load())Practice · estimate
Knowledge check
A worker pool on a CPU-bound task, GOMAXPROCS=2. Two workers gave a speedup of about 2 over one. What will eight give?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A concurrency pattern is a way to connect goroutines with channels so that every channel has one owner who closes it, and every goroutine has a way out whatever happens. A worker pool is N goroutines reading one job channel. A pipeline is stages connected by channels: each reads its input until it is closed and closes its own output. Fan-in merges several channels into one, and it is closed by whoever has waited for all the senders. A semaphore on a buffered channel lets no more than K goroutines into the work at once.
- The main consequence: the number of workers runs into what the task is busy with. On a CPU-bound task in our configuration (2 cores,
GOMAXPROCS=2) the second worker gave a speedup of about 2.0, and the third and the rest gave nothing more: no more than GOMAXPROCS threads execute Go code at once. On a sleeping task eight workers were 8.0 times faster than one, sixteen 16.0 times: a sleeping goroutine does not occupy a core. These are observations from one machine; the mechanism explains why, and the ratio for your own task has to be measured on your own machine. - Beyond that comes what separates real understanding from having read about it. Pool results arrive in order of readiness: tasks
a b c dcame back as[b d c a], and the order is restored by the task index. A pipeline stage that forgotclosecrashes the program withfatal error: all goroutines are asleep - deadlock!— but only if everyone is asleep: with a live timer the program does not crash, it hangs.wg.Wait()before reading the results is the same deadlock, and a buffer smaller than the number of results only postpones it.close(out)in a merge withoutwg.Wait()ispanic: send on closed channel. A semaphore of capacity 3 let no more than three into the work at a time; there were 12 goroutines if the slot is taken inside the goroutine, and no more than three if it is taken beforego. A consumer that left without cancelling left two goroutines behind forever. Withcontext— none, but only if the stage listens for cancellation at both of its waiting points: a stage withrangeover its input did not hear cancellation on an open empty input. Andsync.WaitGroup.Goexists only from go1.25. - Measurements — go1.24.7 and go1.25.1, linux/amd64, 2 cores; documentation and sources checked on 14.10.2026. The newer Go 1.26 and 1.27 were not run by this lesson.
In fact
- Only while there are no more workers than can run at once. On a CPU-bound task in our configuration (2 cores,
GOMAXPROCS=2) two workers gave a speedup of about 2.0, eight no more, and withGOMAXPROCS=1eight workers are no faster than one. On a sleeping task eight workers gave 8.0. - In order of readiness: tasks
a b c dcame as[b d c a]. The order is restored by the task index sent along with the answer. - Only if every goroutine is asleep and there is not a single timer — that is how
checkdeadis written. In the run with onetime.Afterthe program did not crash: the consumer hung in[chan receive], +1 goroutine. - No sender knows whether it is the last. Only whoever waited for everyone can close it — a goroutine after
wg.Wait(). Closing too early ispanic: send on closed channel. - It depends on where the slot is taken. Inside an already started goroutine — in the run three were working while there were 12 goroutines. Before
go— no more than three live handlers: the dispatch loop itself waits for a free slot. cancel()is a signal: a stage exits only if it listens toctx.Done()at the moment it waits. The stage withrangeover its input on an open empty input did not close its output within 500 ms after cancellation; with aselecton both the receive and the send it closed it in all four scenarios.- It will not: in the run, after the consumer left,
+2goroutines remained in[chan send]. With aselectonctx.Done()anddefer cancel()—+0.
By version
- go1.24.7
- There is no
WaitGroup.Gomethod:go doc sync.WaitGroup.Goanswersno method or field WaitGroup.Go in package sync, and the build —wg.Go undefined (type "sync".WaitGroup has no field or method Go). Checked by runningbench/gopattern/internals.go, section 13.< - go1.25.1
- The method
WaitGroup.Go(f func())exists (api/go1.25.txt): the same run builtcases/wggoand printed the sum. Withgo 1.24ingo.modthe build passes, whilego vetwarns:sync.Go requires go1.25 or later (module is go1.24).<
What is covered
- What is really being asked
- Base: four shapes made of goroutines and channels
- Mechanism 1: how many workers — depends on what the task is busy with
- Mechanism 2: a pipeline rests on close
- Mechanism 3: fan-in — only whoever waited for everyone can close the output
- Mechanism 4: a semaphore — where you take the slot decides how many goroutines there are
- Mechanism 5: stopping — the consumer left early, or an error happened
- Deeper: why a forgotten close crashes a small program and hangs a real one
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Knowledge check
Sources & further reading
6 SOURCES
- The Go Specification — Close, For statements with range clauseOfficial documentation. What close means: «the built-in function close records that no more values will be sent on the channel». What is forbidden: «Sending to or closing a closed channel causes a run-time panic». When a range over a channel ends: «For channels, the iteration values produced are the successive values sent on the channel until the channel is closed». Checked against /usr/local/go1.24.7/doc/go_spec.html.https://go.dev/ref/spec#Close
- The runtime package — GOMAXPROCS, NumGoroutineOfficial documentation. The ceiling for a CPU-bound task: «The GOMAXPROCS variable limits the number of operating system threads that can execute user-level Go code simultaneously». What leaks are counted with: «NumGoroutine returns the number of goroutines that currently exist». Checked with go doc runtime, go1.24.7.https://pkg.go.dev/runtime#GOMAXPROCS
- The sync package — WaitGroup.Add and WaitGroup.GoOfficial documentation. Where to call Add: «Typically this means the calls to Add should execute before the statement creating the goroutine or other event to be waited for», go doc sync.WaitGroup.Add, go1.24.7. The Go method appeared in go1.25 (api/go1.25.txt, line 96): «Go calls f in a new goroutine and adds that task to the WaitGroup. When f returns, the task is removed from the WaitGroup»; and the caveat: «The function f must not panic». Checked with go doc sync.WaitGroup.Go, go1.25.1; on go1.24.7 go doc answers: no method or field WaitGroup.Go in package sync.https://pkg.go.dev/sync#WaitGroup.Go
- The context package — WithCancelCause, CauseOfficial documentation. How the first error reaches everyone: «Cause returns a non-nil error explaining why c was canceled». Checked with go doc context.Cause, go1.24.7.https://pkg.go.dev/context#Cause
- runtime/proc.go — checkdeadGo source code. Where «fatal error: all goroutines are asleep - deadlock!» comes from, and why not always. On go1.24.7 the function checkdead starts at line 6026; at lines 6055–6058 it returns if even one thread is executing code (
if run > run0 { return }); at lines 6119–6124 it returns if any P has a timer (if len(pp.timers.heap) > 0 { return }), under the comment «There are no goroutines running, so we can look at the P's»; and only after that, at line 6127, comesfatal("all goroutines are asleep - deadlock!"). Same on go1.25.1: checkdead at line 6091, the timer check at line 6186, fatal at line 6192.https://github.com/golang/go/blob/go1.24.7/src/runtime/proc.go#L6026-L6128 - sync/waitgroup.go — WaitGroup.Go (go1.25.1)Go source code. Lines 235–241 on go1.25.1: the whole method is
wg.Add(1), thengo func() { defer wg.Done(); f() }(). So it is the same pair of Add and Done, only Add is guaranteed to come before go. The go vet stdversion analyzer (go tool vet help stdversion, go1.25.1): «The stdversion analyzer reports references to symbols in the standard library that were introduced by a Go release higher than the one in force in the referring file».https://github.com/golang/go/blob/go1.25.1/src/sync/waitgroup.go#L235-L241