Worker Pools

A worker pool is a concurrency pattern where a fixed number of goroutines — the workers — pull units of work off a shared channel and process them concurrently, instead of spawning a brand new goroutine for every single task. It gives you bounded, predictable concurrency: you decide up front how many tasks run at once, which matters enormously when the work is expensive (network calls, database queries, CPU-heavy computation) and letting concurrency grow without limit would overwhelm a downstream service, exhaust memory, or thrash the CPU. Worker pools are one of the most common concurrency patterns you will write in real Go programs, and they build directly on channels, goroutines, and sync.WaitGroup.

Overview: How Worker Pools Work

Every goroutine you start does real work: it needs its own stack (starting at just a few kilobytes but able to grow) and it has to be scheduled onto an OS thread by the Go runtime. Goroutines are far cheaper than OS threads, so starting a few thousand of them is completely normal, but starting one goroutine per unit of work when the number of units is unbounded — one per row of a multi-million-row export, one per incoming request during a traffic spike — can exhaust memory, hammer a downstream service with thousands of simultaneous calls, or simply add no benefit once your CPU cores are already saturated. A worker pool solves this by decoupling the number of concurrent workers from the number of jobs: you start a small, fixed number of goroutines — often tied to runtime.NumCPU() for CPU-bound work, or a larger fixed number for I/O-bound work such as HTTP requests — and every one of them pulls from the same shared queue of work.

That shared queue is a channel. A Go channel is a typed, thread-safe queue with blocking semantics built in: a send blocks until a receiver is ready (for an unbuffered channel) or until there is room in the buffer (for a buffered one), and a receive blocks until a value is available. A worker pool typically uses two channels: a jobs channel that the main goroutine sends work into, and a results channel that workers send their output into. Function signatures usually declare these as directional channels — jobs <-chan int means receive-only from this function, and results chan<- int means send-only — so the compiler can catch a worker that accidentally tries to send to jobs or receive from results.

Each worker runs a for range jobs loop. Ranging over a channel receives values one at a time until the channel is both closed and drained, at which point the loop exits automatically — this is the standard way to tell every worker there is no more work and it can finish up. Only the sender should ever close a channel, and it must be closed exactly once, after the last send; in a worker pool that means the main goroutine (or a dedicated dispatcher goroutine) closes the jobs channel once it has sent every job — never a worker, and never more than once, since closing an already-closed channel panics.

Under the hood, when a worker blocks on a channel receive (waiting for the next job) or a channel send (waiting for room in results), the Go runtime scheduler parks that goroutine and frees its OS thread to run other runnable goroutines. This is why blocking on a channel is cheap in Go: you can have far more goroutines than OS threads, and the M:N scheduler — M goroutines multiplexed onto N OS threads, where N is influenced by GOMAXPROCS — keeps threads busy with real work instead of idling on a blocked call. That efficiency is what makes the worker-pool pattern practical even with thousands of jobs flowing through a handful of workers. Because every worker sends to the same results channel, you also need a way to know when all of them have finished so you can safely close results — closing it too early panics any worker still trying to send. That is the job of sync.WaitGroup: each worker calls wg.Done() when its jobs loop ends, and a separate goroutine calls wg.Wait() and then close(results) once every worker has reported done.

Syntax

The general shape of a worker pool is the same in almost every Go program that uses it: a worker function that loops over the jobs channel, a fixed number of goroutines running that function, a dispatcher that sends jobs and closes the jobs channel, and a closer goroutine that waits for all workers before closing results.

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		results <- process(job)
	}
}

func main() {
	jobs := make(chan Job, jobBufferSize)
	results := make(chan Result, resultBufferSize)
	var wg sync.WaitGroup

	for w := 0; w < numWorkers; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	go func() {
		for _, j := range allJobs {
			jobs <- j
		}
		close(jobs)
	}()

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

	for r := range results {
		handle(r)
	}
}
Part Purpose
jobs channel Shared queue of work; the dispatcher sends into it, workers receive from it via <-chan.
results channel Shared queue of output; workers send into it via chan<-, the consumer receives from it.
worker function Runs in its own goroutine; loops with for range jobs until the channel is closed and drained.
sync.WaitGroup Tracks how many workers are still running so the results channel can be closed exactly once, after the last one finishes.
dispatcher Sends every job into the jobs channel, then calls close(jobs) exactly once.
closer goroutine Calls wg.Wait() then close(results), usually run concurrently so the consumer can drain results as they arrive.

Examples

Example 1: A Minimal Worker Pool

This first example distributes five numbers across three workers, has each worker square its number, and sums the results back in main. It shows the minimal skeleton: a jobs channel, a results channel, and no error handling yet.

package main

import (
	"fmt"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		results <- j * j
	}
}

func main() {
	const numJobs = 5
	const numWorkers = 3

	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)

	for w := 1; w <= numWorkers; w++ {
		go worker(w, jobs, results)
	}

	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}
	close(jobs)

	sum := 0
	for a := 1; a <= numJobs; a++ {
		sum += <-results
	}

	fmt.Println("Sum of squares:", sum)
}

Output:

Sum of squares: 55

Three worker goroutines all range over jobs, so whichever worker is free next picks up the next number — the work is distributed automatically without any manual load balancing. Both channels are buffered to numJobs so every send in this small example completes without blocking. main sends all five jobs, closes jobs so the workers’ for range loops know when to stop reading, and then receives exactly five values from results with a plain <-results in a loop, since we know in advance how many results to expect. Notice that the result order is not guaranteed — worker 2 might finish job 4 before worker 1 finishes job 1 — but since we are just summing, the order does not matter here.

Example 2: Using sync.WaitGroup to Close Results Safely

Real programs usually do not know the exact result count as neatly as example 1, or they want the workers and the dispatcher to run independently of each other. This example adds a sync.WaitGroup so the pool can signal precisely when every worker has finished, then closes results safely from its own goroutine and drains it with for range results — a pattern that works no matter how many jobs there are.

package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan string, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- len(j)
	}
}

func main() {
	words := []string{"go", "channels", "worker", "pool", "concurrency", "goroutine"}

	jobs := make(chan string, len(words))
	results := make(chan int, len(words))

	var wg sync.WaitGroup
	numWorkers := 3

	for w := 1; w <= numWorkers; w++ {
		wg.Add(1)
		go worker(w, jobs, results, &wg)
	}

	for _, word := range words {
		jobs <- word
	}
	close(jobs)

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

	total := 0
	for length := range results {
		total += length
	}

	fmt.Println("Total characters:", total)
}

Output:

Total characters: 40

The key addition here is the goroutine go func() { wg.Wait(); close(results) }(). Because closing results happens in its own goroutine, main is free to start draining results with for range results immediately — the two run concurrently. If you instead called wg.Wait() and close(results) directly in main before the draining loop, you would deadlock: the workers cannot finish sending to a full results buffer while main is stuck on wg.Wait() instead of receiving. Running the wait-and-close logic in a separate goroutine is the standard fix, and it is the shape you should reach for by default.

Example 3: Reporting Per-Job Errors

Worker pools frequently need to report errors per job rather than crash the whole program, since Go has no exceptions to propagate a single failure automatically. This example wraps each result in a small struct that carries either a value or an error, mirroring how you would report failures from real work like an HTTP request or a database write.

package main

import (
	"errors"
	"fmt"
	"sync"
)

type Result struct {
	Input int
	Value int
	Err   error
}

func worker(jobs <-chan int, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for n := range jobs {
		if n == 0 {
			results <- Result{Input: n, Err: errors.New("cannot process zero")}
			continue
		}
		results <- Result{Input: n, Value: 100 / n}
	}
}

func main() {
	inputs := []int{5, 0, 2, 10, 4}

	jobs := make(chan int, len(inputs))
	results := make(chan Result, len(inputs))

	var wg sync.WaitGroup
	numWorkers := 2

	for i := 0; i < numWorkers; i++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	for _, n := range inputs {
		jobs <- n
	}
	close(jobs)

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

	successCount := 0
	errorCount := 0
	for r := range results {
		if r.Err != nil {
			errorCount++
			continue
		}
		successCount++
	}

	fmt.Println("Successes:", successCount)
	fmt.Println("Errors:", errorCount)
}

Output:

Successes: 4
Errors: 1

Instead of sending a bare int on results, each worker sends a Result struct with an Input, a Value, and an Err field. When the input is 0, the worker builds a Result with only Err set, rather than panicking or silently skipping the job — every input still produces exactly one result, which matters because the consumer’s loop count should always match the number of jobs sent. The consumer then checks r.Err on each result exactly the way you would check any other error return in Go, tallying successes and failures separately.

How It Works Step by Step

  1. main creates the jobs and results channels, sized to the workload so sends do not block unnecessarily in these examples.
  2. main launches a fixed number of worker goroutines, calling wg.Add(1) once per worker before starting it, so the WaitGroup’s internal counter matches the number of goroutines it needs to wait for.
  3. main sends every job into jobs, then calls close(jobs) exactly once, after the last send.
  4. Each worker’s for range jobs loop keeps receiving jobs concurrently with the others; when jobs is closed and every buffered value has been drained, each worker’s loop exits and its deferred wg.Done() runs.
  5. A dedicated goroutine calls wg.Wait(), which blocks until every worker has called Done, and then calls close(results) exactly once.
  6. Meanwhile, main‘s for range results loop has already been receiving results as they arrive; once results is closed and drained, that loop exits automatically and the program continues past it.
  7. Running the wait-and-close step in its own goroutine, rather than inline in main, is what lets steps 4 and 6 happen concurrently instead of deadlocking.

Common Mistakes

Mistake 1: Forgetting to Close the Jobs Channel

If you never call close(jobs) after sending the last job, every worker’s for range jobs loop keeps waiting for the next value forever — the channel is never closed, so range never sees a reason to stop. The workers block permanently on a channel that will never produce anything, which is a real goroutine leak in a long-running program.

jobs := make(chan int, 5)
results := make(chan int, 5)

for w := 1; w <= 3; w++ {
	go worker(w, jobs, results)
}

for j := 1; j <= 5; j++ {
	jobs <- j
}
// missing close(jobs) here -- workers range over jobs forever and never exit

for a := 1; a <= 5; a++ {
	fmt.Println(<-results)
}

The fix is a single line: close jobs once every job has been sent, so each worker’s range loop can finish naturally.

jobs := make(chan int, 5)
results := make(chan int, 5)

for w := 1; w <= 3; w++ {
	go worker(w, jobs, results)
}

for j := 1; j <= 5; j++ {
	jobs <- j
}
close(jobs) // signals "no more work" so each worker's for range jobs loop can exit

for a := 1; a <= 5; a++ {
	fmt.Println(<-results)
}

Mistake 2: Closing the Results Channel Too Early

It is tempting to close results right after close(jobs) in main, since that is when you know no more jobs will be sent. But workers are still running at that point — they may still be processing the last few jobs and trying to send their output to results. Sending on a closed channel panics immediately, crashing the program.

jobs := make(chan int, 5)
results := make(chan int, 5)
var wg sync.WaitGroup

for w := 1; w <= 3; w++ {
	wg.Add(1)
	go worker(w, jobs, results, &wg)
}

for j := 1; j <= 5; j++ {
	jobs <- j
}
close(jobs)

close(results) // BUG: workers may still be sending; this can panic

for r := range results {
	fmt.Println(r)
}

The fix is to only close results after every worker has confirmed it is done, which is exactly what sync.WaitGroup is for. Running the wait in its own goroutine also avoids the deadlock you would get by calling wg.Wait() directly in main before draining results.

jobs := make(chan int, 5)
results := make(chan int, 5)
var wg sync.WaitGroup

for w := 1; w <= 3; w++ {
	wg.Add(1)
	go worker(w, jobs, results, &wg)
}

for j := 1; j <= 5; j++ {
	jobs <- j
}
close(jobs)

go func() {
	wg.Wait()
	close(results) // only closed once every worker has called wg.Done()
}()

for r := range results {
	fmt.Println(r)
}

Best Practices

  • Size the pool to the workload: for CPU-bound work, a pool around runtime.NumCPU() usually gets the best throughput; for I/O-bound work like HTTP calls, a larger fixed number often helps since workers spend most of their time blocked waiting on the network.
  • Always close a channel from the sending side only, and close it exactly once — closing it from a worker, or closing it twice, panics.
  • Close the results channel from a dedicated goroutine that calls wg.Wait() first, so the consumer can keep draining results concurrently instead of deadlocking.
  • Give workers a way to stop early. Pass a context.Context into the worker and select on ctx.Done() alongside the jobs channel so a cancellation or timeout can stop the pool before all jobs are processed.
  • Report per-job errors as data (a Result struct with an Err field, or a separate errors channel) rather than letting one bad job crash the whole pool.
  • Size channel buffers deliberately. An unbuffered or small buffer gives natural backpressure, which is often what you want when producing jobs faster than workers can consume them; a very large buffer just delays the moment you notice the consumer is falling behind.
  • Never spawn one goroutine per job as a substitute for a worker pool when the number of jobs is large or unbounded — you lose the bounded-concurrency guarantee that makes worker pools useful in the first place.

Practice Exercises

  1. Write a worker pool with four workers that reads a slice of 20 integers and sends back whether each one is prime. Use a Result struct with the input and a bool. Expected output: a count of how many of the 20 numbers were prime.
  2. Modify example 3 so that a worker recovers from a panic instead of crashing, and reports it as a Result with an Err field. (Hint: use recover() inside a deferred function in the worker.)
  3. Add a context.Context with a short timeout to example 2’s worker function, and have the worker check ctx.Done() inside its for range jobs loop so it stops picking up new jobs once the context expires, even if jobs still has buffered values.

Summary

  • A worker pool runs a fixed number of goroutines that all pull work from a shared jobs channel, giving you bounded, predictable concurrency instead of one goroutine per job.
  • Directional channel types (<-chan and chan<-) document and enforce which side of a channel a function is allowed to use.
  • Only the sender closes a channel, and only once; close(jobs) after the last send tells every worker’s for range jobs loop to stop.
  • sync.WaitGroup tracks when every worker has finished, so results can be closed safely — typically from its own goroutine, so the consumer can drain results concurrently instead of deadlocking.
  • Report per-job failures as data (a struct with an Err field) rather than letting a single bad job take down the whole pool.
  • The Go scheduler parks goroutines that block on channel operations and frees their OS thread for other work, which is why blocking on channels scales to many more goroutines than you have OS threads.