Skip to content
intermediate Phase 10 · Concurrency Patterns and Context

Worker Pools

Bound concurrency with a fixed set of goroutines consuming a shared jobs channel.

50m
0 problems
Topic Progress 0%

Ten Thousand Thumbnails, Four Cores

An image service receives 10,000 upload events and the obvious code passes review:

for _, img := range images {
	wg.Add(1)
	go func(img Image) {
		defer wg.Done()
		results <- thumbnail(img) // one goroutine per image
	}(img)
}

It works in staging with 50 images. In production the burst arrives at once: 10,000 goroutines materialize, each holding its own image bytes in memory, each competing for four cores through the scheduler, and the decoder's file handles multiply until the process is either thrashing or out of file descriptors. Goroutines are cheap — a few kilobytes each — but 10,000 of them is not a concurrency strategy, it is an absence of one. The service does not want 10,000 things happening; it wants four things happening, most of the time.

A worker pool fixes the count. A fixed number of long-lived goroutines share one jobs channel; the channel — not the code — controls how much work exists at once:

func pool(images <-chan Image, n int) <-chan Result {
	results := make(chan Result, n)

	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for img := range images {
				results <- convert(img)
			}
		}()
	}

	go func() {
		wg.Wait()
		close(results)
	}()
	return results
}

Read what changed structurally. Goroutine count is now n, set before launch, independent of job count — 10 or 10,000,000 jobs produce exactly n workers. Each worker loops over the shared jobs channel, which means idle workers are parked in a receive, costing nothing, and a burst of jobs is processed n at a time with the rest waiting in the channel (or at the producer, if the buffer is full — this is backpressure from a buffered channel, working as designed). The results channel's buffer of n guarantees a worker can always deposit its result without a rendezvous with the consumer, keeping workers from queueing on the drain path.

The closing pattern is the one from multi-producer close: workers never close results — two finishing workers would double-close and panic. Instead a detached goroutine waits on the WaitGroup and closes exactly once after the last worker's range ends. The whole construction uses only primitives already covered: directional signatures (images <-chan Image) declare which side owns each stream, range terminates workers when the job source closes, and the coordinator owns the shutdown of the output.

The Complete Pool: Backlog, Results, and Failure

The pretty version hides three production questions: what happens when jobs arrive faster than the pool drains them, what happens to results nobody reads, and what happens when a job fails. Here is the pool with all three answered:

type Job struct {
	ID   int
	Path string
}

type Result struct {
	ID       int
	ThumbURL string
	Err      error
}

func RunPool(jobs <-chan Job, workers int, buf int) <-chan Result {
	results := make(chan Result, buf)

	var wg sync.WaitGroup
	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := range jobs {
				url, err := makeThumb(j.Path)
				results <- Result{ID: j.ID, ThumbURL: url, Err: err} // errors ride the result channel
			}
		}(i)
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	return results
}

func main() {
	jobs := make(chan Job, 256)

	go func() {
		defer close(jobs)
		for _, j := range scanQueue() {
			jobs <- j // blocks at 256 deep while the pool is saturated: backpressure, not memory growth
		}
	}()

	failed := 0
	for r := range RunPool(jobs, 8, 64) {
		if r.Err != nil {
			failed++
			log.Printf("job %d: %v", r.ID, r.Err)
		}
	}
	log.Printf("done, %d failed", failed)
}

Each design decision has a failure it prevents. Errors ride the results channel as data rather than being logged inside workers: the consumer already ranges over results, so failure handling lives in exactly one place, and a worker that returned early on first error would have silently skipped jobs — instead every job produces exactly one result, success or failure. This mirrors the errors-as-values discipline from Error Handling, carried across a channel boundary. The producer's jobs <- j blocks at buffer depth rather than allocating a million-job slice: when the pool saturates, the backlog stops at 256 and the producer (maybe an HTTP handler) applies natural backpressure upstream. The consumer's range over results terminates because the coordinator closed it after all workers exited — which in turn happened because someone closed jobs. Chain the closes correctly and shutdown is automatic; break any link and you get a goroutine leak, the subject of Goroutine Leaks.

What this shape deliberately does not do: cancel early. If the consumer stops reading after the first error, workers finish their current job and then block forever sending results nobody takes — a leak waiting to happen. Cancellation needs a done signal threaded through every send, which is where context plugs into this exact structure in the next topics.

Sizing the Pool: The Only Question That Matters

How many workers? The answer is not runtime.NumCPU() by reflex — it depends on what the worker spends its time doing.

CPU-bound work (encode, compress, hash, compile): each worker occupies a core for the whole job, so more workers than cores means contention and context-switch overhead for no gain. Start at runtime.GOMAXPROCS(0) — the parallelism the Go scheduler itself targets — and raise only with profiling evidence:

workers := runtime.GOMAXPROCS(0) // CPU-bound default

I/O-bound work (HTTP calls, database queries, disk reads): the worker spends most of its life waiting, and a waiting worker holds no core. The limit is the downstream system's tolerance — connection-pool size, API rate limits, ulimit — not your CPU count. An HTTP fan-out to a service that tolerates 32 concurrent requests wants ~32 workers, and the honest way to find the number is to drive it up and watch error rates and p99 latency at the dependency:

workers := downstreamMaxConcurrency // e.g. 32, from the API's published limit

Mixed workloads usually want two pools or a weighted queue — one slow-blocking class will otherwise idle the slots that fast work could use. A quick diagnostic for mis-sizing: if workers sit in runtime.BlockingProfile-style waits while the jobs queue is deep, you are I/O-starved and should add workers; if the run queue is saturated and wall-clock stops improving, you are CPU-bound and workers are overhead.

The measurement loop matters more than the initial guess — instrument queue depth and per-job latency, then adjust:

Symptom Likely cause Move
len(jobs) pinned at cap, CPU idle too few workers for I/O-bound work raise workers toward downstream limit
CPU pegged, throughput flat as workers grow CPU-bound, past the useful count lower to GOMAXPROCS
Dependency 429s / connection errors workers exceed downstream tolerance lower, or add a limiter (sem := make(chan struct{}, cap))
Latency spikes with deep results buffer results consumer too slow buffer helps only to a point — speed up or parallelize consumption

Sizing also decides failure character. Too few workers and the system degrades into latency (queues grow, requests time out); too many and it degrades into errors at whatever resource you oversubscribed — memory, sockets, the dependency's own limits. Interviewers ask for the number you would pick and the reason it is a measurement, not a constant; the answer that lands is GOMAXPROCS for CPU work, the dependency's concurrency budget for I/O work, and a plan to watch queue depth to revise both.

Quiz

1. A pool spawns one goroutine per job and the job queue hits 50,000 items during a burst. What does a fixed-size pool with a jobs channel change?

Question 1 options

2. Workers must handle jobs that make HTTP calls to a rate-limited API allowing 20 concurrent requests. What is the best starting pool size?

Question 2 options

3. In a pool where workers send to a shared `results` channel, which goroutine should close `results`, and why?

Question 3 options

4. The consumer stops ranging over `results` early because it found a fatal error, while jobs keep arriving. What goes wrong?

Question 4 options

Flashcards

Question

What is the structural difference between 'spawn a goroutine per job' and a worker pool?

Answer

Per-job spawning makes concurrency proportional to backlog — memory, scheduler pressure, and resource use spike with the queue. A pool launches a fixed number of long-lived goroutines that loop over a shared jobs channel, so concurrency is a constant you choose and excess work waits in the channel (backpressure) instead of becoming goroutines.

Question

Walk through who closes what in a standard worker pool.

Answer

The producer closes the jobs channel after its last send (often via defer), which ends every worker's `range jobs` loop. Workers never close anything. A detached coordinator goroutine waits on a sync.WaitGroup for all workers and then closes the results channel exactly once, letting the consumer's range terminate.

Question

How do you decide between runtime.GOMAXPROCS(0) workers and a much larger number?

Answer

It follows the blocking profile: CPU-bound jobs occupy a core for their duration, so workers beyond GOMAXPROCS add contention, while I/O-bound workers spend their lives waiting and should be sized to the downstream system's tolerance (connection limits, rate limits). The wrong guess shows up as either an idle CPU with a deep queue or a pegged CPU with flat throughput.

Question

Where should errors be reported in a worker pool, and why not have workers return early on failure?

Answer

As data on the results channel — each Result carries an error field, so the consumer handles every failure in the one place it already ranges. Returning early inside a worker skips remaining jobs invisibly and breaks the one-result-per-job invariant the consumer counts on; logging-and-continuing inside the worker scatters failure handling across N goroutines.

Question

Why is a deep results buffer not a solution when the consumer falls behind?

Answer

It converts the stall into memory growth and latency: results pile up unread while each waits longer before anyone acts on them, and the overload stays hidden until the buffer's cap. Buffers absorb bursts between stages of equal average rate — a consumer that is sustainably slower needs to be sped up or parallelized, exactly as in the buffered-channel sizing analysis.

Revision Notes

Key Takeaways

  • 1. A pool converts concurrency from a function of backlog into a constant you choose before launch
  • 2. Idle workers park in a channel receive at zero cost; excess jobs exert backpressure instead of allocating goroutines
  • 3. Exactly one coordinator closes the results channel after WaitGroup.Wait — workers close nothing
  • 4. CPU-bound pools start at GOMAXPROCS; I/O-bound pools start at the dependency's concurrency budget and move with measurements
  • 5. Errors travel as values on the results channel; early cancellation needs a done signal through every send or senders leak

Interview Tips

  • • "Design a system that processes a million files" — lead with the pool: N workers, jobs channel, results channel, one closer; then be grilled on N and answer with workload class plus measurement
  • • "How many workers would you use?" — split the answer: GOMAXPROCS for CPU work, downstream limits (rate limit, connection pool) for I/O work, and name the metric (queue depth, dependency error rate) that revises it
  • • "What happens if the consumer stops reading?" — workers block on the results send and the pool stalls; propose context cancellation wrapping each send, showing you've seen the leak in practice
  • • "Why not just spawn goroutines per job?" — quantify: 50k jobs = 50k stacks and scheduler pressure with no backpressure; pools bound memory and let the queue say where the system actually is

Cheat Sheet

Worker Pools

Skeleton:

func RunPool(jobs <-chan Job, n, buf int) <-chan Result {
	results := make(chan Result, buf)
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs {           // park here when idle
				results <- do(j)             // errors ride the result struct
			}
		}()
	}
	go func() { wg.Wait(); close(results) }() // exactly one closer
	return results
}

Close chain: producer closes jobs → workers' ranges end → WaitGroup hits zero → coordinator closes results → consumer's range ends. Break a link and something leaks.

Sizing decision:

Workload Start at Adjust when
CPU-bound runtime.GOMAXPROCS(0) throughput flat + CPU pegged → already at limit
I/O-bound downstream concurrency budget queue deep + CPU idle → raise until dependency pushes back
Mixed split pools by class one class starves the other's slots

Early exit: consumer quitting mid-stream requires cancellation (done/context) on every worker send — close discipline alone cannot unblock parked senders.

Continue Learning

Next Topics

Related Reading