Go's concurrency primitives are simple, but combining them correctly takes practice. A worker pool is one of the most common patterns — a fixed number of goroutines draining a shared job queue. Here's how to build one that doesn't leak goroutines or deadlock.
The core idea
A worker pool has three moving parts:
- A jobs channel — callers send work here
- N worker goroutines — each pulls from the jobs channel in a loop
- A WaitGroup — tracks when all workers have drained and exited
The key invariant: close the jobs channel exactly once, after all jobs are sent. Workers range over the channel, so they exit cleanly when it closes.
Basic implementation
package main
import (
"fmt"
"sync"
)
type Job struct {
ID int
Input string
}
type Result struct {
JobID int
Output string
}
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
// process the job
results <- Result{
JobID: job.ID,
Output: fmt.Sprintf("worker %d processed: %s", id, job.Input),
}
}
}
func RunPool(numWorkers int, jobList []Job) []Result {
jobs := make(chan Job, len(jobList))
results := make(chan Result, len(jobList))
var wg sync.WaitGroup
for i := range numWorkers {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
for _, job := range jobList {
jobs <- job
}
close(jobs)
wg.Wait()
close(results)
var out []Result
for r := range results {
out = append(out, r)
}
return out
}
The buffered channels sized to len(jobList) mean senders never block. If your
job list is large or unbounded, use an unbuffered results channel and collect in a
separate goroutine.
Adding context cancellation
Real pools need a way to abort in-flight work. Thread a
context.Context through:
func workerWithContext(ctx context.Context, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
results <- Result{JobID: job.ID, Output: process(job)}
}
}
}
The select races the context cancellation against the next job. Workers stop
as soon as the context is cancelled, even if jobs remain in the channel.
When to use a pool vs just goroutines
A naive approach spawns one goroutine per job:
for _, job := range jobs {
go process(job) // don't do this for large workloads
}
This works fine for dozens of jobs. For thousands — database rows, HTTP fan-outs, file
processing — unbounded goroutines exhaust memory and overwhelm downstream systems. A pool
with N=runtime.NumCPU() goroutines saturates the CPU without overcommitting.
If you're hitting an external API, tune N to the API's rate limit, not the CPU count. Goroutines are cheap; downstream connections are not.
Measuring throughput
Use a simple benchmark to find the right pool size for your workload:
go test -bench=BenchmarkPool -benchmem -count=5 ./...
Plot results against N — throughput usually peaks between
runtime.NumCPU() and 2×NumCPU() for CPU-bound work, and much
higher for I/O-bound work.