Deep Engineering
Intermediate·Published·65 MIN

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.

Where to start
Before this lesson it is enough to understand
  • that go f() runs a function in a new goroutine, and a channel from make(chan T) passes values between goroutines: send is ch <- 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;
  • range over a channel, select, sync.WaitGroup and ctx.Done() — "Base" has one sentence on each; in detail — in the lessons on channels, on sync and atomic and on context.
You do not need to know in advance
  • 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:

  1. "Write a worker pool" — the warm-up; almost everyone writes the code, and the questions start after it.
  2. "How many workers should you take?" — this checks whether the candidate tells a CPU-bound task from a task that waits.
  3. "Will the results arrive in the same order as the tasks?"
  4. "Who closes the result channel, and when?" — and what happens if it is not closed, or closed in the wrong place.
  5. "Merge N channels into one" — fan-in.
  6. "How do you limit the number of concurrent queries to the database?" — a semaphore.
  7. "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

— The Go Specification — Close

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.

— The Go Specification — For statements with range clause
language contractThe semantics of close, range and the panic on sending to a closed channel are the language specification; the GOMAXPROCS ceiling is the runtime package documentation. The run of bench/gopattern/internals.go illustrates them rather than establishes them.

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:

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:

shapewhat it doeswho closes the outputtypical breakage
worker poolN goroutines read one job channela goroutine after wg.Wait()wg.Wait() before reading the results
pipelinestages connected by channelseach stage its own outputa stage forgot close
fan-inN channels merge into onea goroutine after wg.Wait()close without Wait — a panic
semaphoreno more than K jobs at oncethe channel is not closedlimits 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 runtime package

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
measured observationbench/gopattern/internals.go, section 1, go1.24.7 linux/amd64, 2 cores, best of 15 rounds. The machine is shared with other processes, so the milliseconds wobble from run to run; the «cores busy» column (process CPU time divided by wall time) shows how many cores the program actually got. Under go1.25.1 the picture is the same (runs/internals-go1.25.txt). The ratios are an observation of this configuration, not a constant: on another machine, task and version they have to be measured again.

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:

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:

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.

— The sync package — WaitGroup.Add

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.

— The sync package — WaitGroup.Go (go1.25.1)

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):

GO
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:

GO
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:

GO
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 poolgoroutine per job, slot insideslot before go
live handlersexactly N, living the whole timeone per job — 12 in the runno more than K — 3 in the run
goroutine reusedyes: the worker takes the next jobnono: a new one per job
where jobs waitin the job channelin goroutines waiting on the semaphorein the dispatch loop waiting on the semaphore
results and errorsa result channeleach goroutine on its owneach 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):

GO
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:

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 .:

GO
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:

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

implementation detail · Go 1.24The conditions under which the runtime declares a deadlock are the checkdead function in runtime/proc.go of a particular version (lines 6026–6127 on go1.24.7, 6091–6192 on go1.25.1), not a language guarantee. The specification only promises that a receive from a channel waits for a value.

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:

GO
// 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

Next they ask

How is a semaphore different from a worker pool, if both limit parallelism?

Short answer

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.

Next they ask

Can you just give the result channel a buffer instead of a separate goroutine with wg.Wait()?

Short answer

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.

Next they ask

How do you check in a test that a pipeline does not lose goroutines?

Short answer

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:

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.

Next they ask

Why WaitGroup.Go, if there are Add and Done?

Short answer

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

Claim

The more workers, the faster

Actually

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.

Claim

A pool returns results in task order

Actually

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.

Claim

A forgotten close always crashes the program with a fatal error

Actually

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.

Claim

A channel can be closed in any of the senders — say, in the last worker

Actually

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.

Claim

A channel semaphore does not limit the number of goroutines — only the work

Actually

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.

Claim

defer cancel() stops every stage of the pipeline

Actually

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.

Claim

If the consumer has left, the garbage collector will collect the pipeline's goroutines

Actually

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.

VersionChangeWhat it means for code
go1.24.7There 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.1The 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

gen endlessly sends 1, 2, 3, …; sq squares and closes its output when its input is closed. The consumer takes three values and leaves without cancelling anything. Then six goroutines go through a semaphore of capacity 2 and record the highest number running at once. What does this code print?
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

A worker pool, 16 CPU-bound tasks (a loop with no memory and no waiting), a machine with two cores, GOMAXPROCS=2. How many times faster are 8 workers than one?
times

Knowledge check

Question 1 of 5

A worker pool on a CPU-bound task, GOMAXPROCS=2. Two workers gave a speedup of about 2 over one. What will eight give?

Sources & further reading

6 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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, comes fatal("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
  6. 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), then go 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