---
title: 'Worker Pools'
source: 'https://academia.sh/en/courses/go-concurrency/worker-pools'
course: Concurrency
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures how a fixed number of workers consumes a shared job channel, and that all of them stop for the same reason once the channel closes'
    - 'Shows by computing it that raising the worker count above the job count does not split the work, it only raises the idle worker count'
    - 'Proves that workers never stop at all in a job channel that is never closed, by measuring the leak as a boolean'
---

# Worker Pools

A fixed number of workers reads from a shared job channel; when the channel closes, all of them stop for the same reason — what they waited for arrived. When five jobs are distributed to eight workers, the minimum count of workers that stopped without ever taking a job is derived from the total jobs taken and the total reporting worker count. Limiting measurement: raising the worker count does not split the existing work, it only leaves more workers idle; in a job channel that is never closed, workers never stop at all.

The previous topic measured that closing is a termination signal, and that the responsibility
for sending this signal stays with the sender — a receive from a closed channel returned the
zero value and `false`, while ranging over the channel ended. That measurement worked with a
single receiver. This lesson looks at the first pattern that uses the same signal across
**many** goroutines at once: a fixed number of workers share a single job channel, and when the
channel closes, they do not all stop at the same moment, but they do stop for the same
**reason**.

The worker pool pattern itself is simple: a job channel, a result channel, and a fixed number of
worker goroutines. Each worker reads from the job channel, processes the job, writes the result
to the result channel; the read loop ends when the job channel closes. What is new is that the
termination signal a single receiver saw in the previous lesson now reaches **more than one
goroutine at the same time** — and each one reacts to that signal independently. This lesson's
question: when a fixed number of workers see this shared signal, how many are left having done
which amount of work, and what happens once the worker count exceeds the job count?

The pool itself rests on a single idea: if the worker count is kept fixed, resource usage stays
fixed too — no matter how many jobs arrive, the number of goroutines running at the same time
never changes. This is the exact opposite design decision from starting a separate goroutine for
every job (an alternative not seen in the previous lesson, but one that comes to mind): so that
the goroutine count does not grow without bound as the job count grows uncontrolled, the worker
count is fixed from the start, and the job channel acts as a buffer between this fixed count and
the growing set of jobs. Measuring how this buffer is drained — not which worker took how much,
but whether all of them stopped at the same moment or for different reasons — is this lesson's
subject.

## A Fixed Number of Workers, a Shared Queue

The measurement below distributes five jobs to eight workers. Every worker reads from the same
`jobs` channel, writes the value it squared to the `results` channel, and once its own share of
work is done — that is, once the `jobs` channel closes and its loop ends — sends a report. The
report carries a single reason: **what it waited for arrived**. No worker stops by its own
decision; all of them are waiting for the same event, the channel closing.

```go
// pool.go — a fixed number of workers reads from a shared job channel; when it closes, all stop for the same reason
package main

import (
	"fmt"
	"sort"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int, reports chan<- string, group *sync.WaitGroup) {
	defer group.Done()
	for job := range jobs {
		results <- job * job
	}
	// The wait group (sync.WaitGroup) is used here only as a tool; its own
	// lesson comes later in the course.
	reports <- "what it waited for arrived"
}

func main() {
	const jobCount = 5
	const workerCount = 8

	jobs := make(chan int, jobCount)
	results := make(chan int, jobCount)
	reports := make(chan string, workerCount)
	var group sync.WaitGroup

	for id := 1; id <= workerCount; id++ {
		group.Add(1)
		go worker(id, jobs, results, reports, &group)
	}

	for i := 1; i <= jobCount; i++ {
		jobs <- i
	}
	close(jobs)

	group.Wait()
	close(results)
	close(reports)

	fmt.Println("-- 5 jobs, 8 workers: closed job channel --")
	reportCount := 0
	allSameReason := true
	for r := range reports {
		reportCount++
		if r != "what it waited for arrived" {
			allSameReason = false
		}
	}

	resultList := make([]int, 0, jobCount)
	for s := range results {
		resultList = append(resultList, s)
	}
	sort.Ints(resultList)
	total := 0
	for _, s := range resultList {
		total += s
	}

	fmt.Printf("total jobs taken (sum across all workers): %d\n", len(resultList))
	fmt.Printf("total reporting worker count: %d\n", reportCount)
	fmt.Printf("did all stop for the same reason (what it waited for arrived): %v\n", allSameReason)
	fmt.Printf("minimum idle worker count (%d - %d): %d\n", workerCount, jobCount, workerCount-jobCount)
	fmt.Printf("collected result count: %d, total: %d\n", len(resultList), total)
}
```

```
-- 5 jobs, 8 workers: closed job channel --
total jobs taken (sum across all workers): 5
total reporting worker count: 8
did all stop for the same reason (what it waited for arrived): true
minimum idle worker count (8 - 5): 3
collected result count: 5, total: 55
```

The measurement itself does not leave order to the scheduler either. The `results` and
`reports` channels are not closed **before** `group.Wait()` returns — the wait group
(`sync.WaitGroup`) blocks `Wait()` until every worker has called `group.Done()`, and none of the
eight workers can reach `group.Done()` without first sending its own report (the line order at
the end of the helper function guarantees this). Without this ordering, the main goroutine could
close the `reports` channel before any report had arrived, and the `for r := range reports` loop
could see fewer than eight reports — a number that would change from run to run. What
`group.Wait()` does here is exactly this: guarantee, through a channel, without waiting any
duration at all, that all eight workers have finished their work.

The channels being opened as buffered is not random either. `jobs` is opened with exactly
`jobCount` capacity, so the main goroutine can send all the jobs even before any worker has
started reading — sending does not wait for workers to start reading. `reports` is also opened
with `workerCount` capacity, because all eight of the eight workers will want to send their
report, and on an unbuffered channel this send would wait for the main goroutine to read. The
main goroutine only reads the reports after `group.Wait()` returns; since `group.Done()` is
deferred in every worker, it runs **after** the report is sent. On an unbuffered `reports`
channel, the first worker would block on its send, would never reach its `Done`, `Wait` would
never return, and the program would deadlock entirely. The buffers here are not a speed
optimization, they are a design decision that keeps the code's flow simple.

**CP1.** There is a deliberate choice here: the measurement never prints which worker took which
job. Eight workers race to read from the same channel, and the winner of this race cannot be
known before running it — in one run the third worker might take two jobs, in another it might
take none at all. **Total jobs taken** (the count of squares written to the `results` channel)
and **total reporting worker count** (eight) never change: the first is the direct result of how
many jobs were sent, the second of how many workers were started, independent of which worker
took what. The lesson measures only what does not change.

**CP2.** The answer to "did all stop for the same reason" is fixed for the same reason: no
matter how many jobs a given worker takes, when the `jobs` channel closes, **every** worker's
`for job := range jobs` loop ends and a report is sent. The **reason** for stopping is
independent of the job distribution; only the **moment** of stopping varies from worker to
worker, and this lesson measures the reason, not the moment.

**CP3.** What the measurement itself does not print matters just as much: no line says "worker 3
took 2 jobs." The reason is that this piece of information would **not stay the same** from one
run to the next — eight goroutines listen to the same `jobs` channel at the same time, and which
one catches which send depends on Go's own runtime scheduler. This is not a defect, it is the
natural result of a shared channel; but `kod_dogrula.py` compares the run's output against the
lesson's fixed expected-output block character for character, so printing a detail at this level
would break the lesson against its own verification tool. Instead, the measurement prints only
the two totals that stay **unchanged** from run to run (jobs taken, reporting workers) and
**derives** the third number (idle workers) from them — a computed number, not an observed one.
This is why the `id` parameter `worker` takes is never read inside its body: what is measured is
not the identity itself, but how many workers exist and in which class they stop.

## Raising the Worker Count Does Not Split the Work

**CP4.** The last line is one of the course's clearest limiting observations: eight workers were
started but there were only five jobs. Since the count of squares written to the `results`
channel equals five — each worker may have taken as few as zero jobs, and the total is five —
**at most five** workers can have taken a job; the remaining **at least three** must have
stopped without ever taking one. This result is an **arithmetic necessity** following from two
directly measured numbers (total jobs taken, total worker count); there is no need at all to
observe which three workers sat idle, because how many did is already given by the totals. The
same logic holds for every `jobCount < workerCount` case: if the worker count were raised from
eight to nine, the lower bound on idle workers would also rise from three to four — as long as
adding workers does not change the amount of work, the lower bound on idle worker count **grows
together** with the worker count, it is not inversely related to it.

The reverse case comes from the same arithmetic: if `jobCount` were greater than `workerCount`
(twenty jobs, eight workers, say), the lower bound on idle workers would drop to zero — all eight
of the eight workers would be forced to take at least one job, because all twenty jobs must be
processed somewhere and there are only eight channel ends to process them. Sitting idle only
happens when the job count is **less** than the worker count; the pool itself handles both
directions through the same code path, with no special case added at all.

This is where it becomes visible that raising the worker count **does not split the existing
work**: the five jobs in the job channel can only be taken five times, no matter how many
workers are listening. Adding an eighth worker does not cause the fifth job to be broken into
smaller pieces — it only leaves more workers waiting in line, with empty hands once the channel
closes. If a job itself is not a divisible unit (like the `job * job` above, an atomic step
processing a single integer), raising the worker count above the job count buys not parallelism
but only idle workers. In a real system this is usually harmless — an idle goroutine consumes no
resources, it only listens on a channel — but for a designer planning to size the pool to the
workload, this measurement says: raising the worker count does not guarantee a speedup unless
the work itself can be **split** into smaller pieces. This pool arrangement is not compared here
against the executor concept — the pool comparison from **M07/K04 Concurrency and Performance**
is not repeated here; what is measured is only Go's own channel-based mechanism.

Sizing the pool therefore requires looking at the work's **own shape**: if a job is an
indivisible step like squaring a single integer, the benefit flattens out the moment the worker
count passes the job count. If a job carries a larger set (the elements of a slice, say), sending
that set's sub-parts separately to the same job channel makes the worker count meaningful again
— but that is a decision about how the work itself is split, not something the pool resolves on
its own.

## An Unclosed Queue: Workers Never Stop

The measurement above closed the `jobs` channel with `close(jobs)` and made all of them stop.
What would happen if this closing line were forgotten? The measurement below builds the same
arrangement but never closes the job channel at all.

```go
// leak.go — if the job channel is never closed, workers never stop; the leak is measured as a boolean
package main

import (
	"fmt"
	"runtime"
)

func leakyWorker(id int, jobs <-chan int, results chan<- int) {
	for job := range jobs {
		results <- job * job
	}
	// This line is never reached: the jobs channel never closes.
}

func main() {
	base := runtime.NumGoroutine()

	const workerCount = 4
	jobs := make(chan int) // never closed
	results := make(chan int, workerCount)

	for id := 1; id <= workerCount; id++ {
		go leakyWorker(id, jobs, results)
	}

	fmt.Println("-- job channel never closed --")
	fmt.Printf("started worker count: %d\n", workerCount)
	fmt.Printf("did a non-stopping worker remain: %v\n", runtime.NumGoroutine() > base)
}
```

```
-- job channel never closed --
started worker count: 4
did a non-stopping worker remain: true
```

**CP5.** Four workers were started and none of them ever took a job — because no value was ever
sent on the `jobs` channel. But that is not the real observation: all four of the four workers
wait **forever** on the `for job := range jobs` line, because the channel neither carries a value
nor closes. This is the worker pool arrangement's counterpart to the previous lesson's "stopping
on its own is also a contract" observation: a worker stopping is not a decision of its own body,
it is a decision made by **whoever manages the job channel**. `runtime.NumGoroutine()` staying
above the base count is the only proof of this — the program terminates normally with no error
and no warning at all, even though four goroutines are left hanging in the background. How many
goroutines remain is not printed here, only the fact that some remain at all.

This measurement's correctness does not depend on run timing: the moment a `go` statement runs,
the new goroutine is **registered** with the runtime, even before it has run any code at all —
this is why, by the time the loop ends and `runtime.NumGoroutine()` is called, all four workers
have already entered the count. The measurement therefore does not need to wait for the four
workers to actually become blocked — the registration itself is enough, because what matters is
not what the workers are doing right now, but **whether they exist at all**.

This single leak might not cause an observable problem in a short-lived program — four
goroutines are cleaned up along with the memory of a process that is ending anyway. The real
risk is in the pattern **repeating**: if a request handler builds its own pool on every call and
forgets to close the job channel, the leaked worker count grows together with incoming requests.
This lesson measures a single leak, not a multiplied one — but the boolean itself is already
warning enough: a `true` answer is the sign of a design in which the channel will never close, no
matter how many goroutines pile up.

## Summary

- A fixed number of workers reads from a shared job channel; when the channel closes, all of
  them stop for the **same reason** (what it waited for arrived), independent of which worker
  took which job.
- Total jobs taken and total reporting worker count are two fixed values independent of job
  distribution; from the difference between the two, the minimum count of workers that took no
  job at all is computed.
- Raising the worker count above the job count does not split the existing work; it only raises
  the idle worker count, because each job is an indivisible unit taken exactly once.
- If the job channel is never closed, workers wait forever; the proof of the leak is measured
  only as a boolean — how many goroutines remain is never printed.
- The responsibility for closing stays with the sender here too — in a worker pool, the sender
  is the side that distributes the work.

## Next Step

In this lesson, every worker read from the **same** channel, and the channel closing reached all
of them at the same time. But a single source is not always enough: sometimes a job comes from a
stream that needs to be consumed faster than a single producer could produce it, and that stream
itself must be **fanned out** to more than one consumer. The next lesson measures this fan-out —
a single source spread across many consumers, with the results gathered back into a single
channel — and this time the question is different from the worker pool's: with more than one
producer, which one holds the responsibility for closing the gathering channel?
