Worker Pool Pattern

A worker pool is a fixed-size set of goroutines that all consume tasks from a single shared channel, process them, and (optionally) push results onto a second channel. It exists to solve one specific problem: bounding concurrency. Because Go makes spawning a goroutine almost free, the naive reflex — for _, job := range jobs { go process(job) } — happily creates ten thousand goroutines, each of which may open a file descriptor, a database connection, or a socket, exhausting the resource it depends on. A worker pool caps the number of simultaneously in-flight tasks at exactly N, decoupling the amount of work (which may be unbounded) from the degree of parallelism (which is a deliberate, tunable constant).

Mental Model

Think of a worker pool as a small factory floor. There is one inbound conveyor belt — the jobs channel — and N identical workstations — the worker goroutines. Each worker loops forever: take the next item off the belt, do the work, put the result on the outbound belt. When the belt runs dry and is closed, every worker’s range loop ends naturally and the worker exits. The belt itself does the load-balancing for free: Go’s channel runtime hands each queued value to exactly one ready receiver, so a fast worker simply grabs more items than a slow one. There is no scheduler, no work-stealing logic, no per-worker queue to write — the channel is the dispatcher.

graph LR
    P[producer] -->|"jobs chan"| JC(["jobs channel<br/>(buffered)"])
    JC --> W1[worker 1]
    JC --> W2[worker 2]
    JC --> W3[worker N]
    W1 -->|"results chan"| RC(["results channel"])
    W2 --> RC
    W3 --> RC
    RC --> C[collector]

One jobs channel feeds N workers; N workers feed one results channel. The insight: the channel between producer and workers is the load balancer — a value is delivered to whichever worker is ready, so work self-distributes without any explicit dispatch logic. Concurrency is capped at exactly N regardless of how many jobs exist.

The three lifecycle questions a correct worker pool must answer are: (1) how do workers learn there is no more work? (the jobs channel is closed); (2) how does the program know all results are in? (a sync.WaitGroup counts workers, and a separate goroutine closes the results channel once the count hits zero); (3) how does the pool stop early on cancellation? (every blocking channel operation sits in a select alongside <-ctx.Done()). Getting any one of these wrong produces either a deadlock or a goroutine leak.

Mechanical Walk-through

A worker pool has four moving parts that must be assembled in a precise order.

The jobs channel. A chan Job, usually buffered. The producer sends every job and then — critically — closes the channel. Closing is the broadcast signal: a for job := range jobs loop terminates exactly when the channel is both drained and closed. The producer must be the sole closer; if any worker closed it, a second worker still sending would panic, and a producer still sending would panic. This is the channel-ownership rule: whoever sends, closes.

The workers. Exactly N goroutines, each running the same function: for job := range jobs { results <- process(job) }. Because all N range the same channel, they compete for values; the runtime delivers each value to one of them. When the jobs channel closes and empties, all N loops end together.

The result-channel close. This is the part everyone gets wrong first. The results channel must be closed so the collector’s range loop can end — but it must be closed exactly once, and only after every worker has finished its last send. No single worker knows it is the last. The solution: a sync.WaitGroup initialized to N; each worker calls wg.Done() as it exits; a separate goroutine does wg.Wait(); close(results). That goroutine is the only entity that can observe “all workers done” and is therefore the correct closer.

Cancellation. A worker blocked on results <- value while the collector has stopped reading will hang forever. A worker blocked on <-jobs after the producer died will hang forever. Both blocking operations must therefore be guarded by a select with a <-ctx.Done() arm so the worker can abandon a stuck send or receive and exit.

The startup/shutdown sequence, in order: (1) create jobs and results channels; (2) start the wg.Wait(); close(results) closer goroutine; (3) start N workers, wg.Add(N); (4) feed all jobs into jobs, then close(jobs); (5) range the results channel in the collector until it closes. Steps 2–4 can interleave, but the closer must be wired up before workers can finish.

Code Examples

A complete, cancellation-correct worker pool:

type Job struct{ ID int }
type Result struct {
    JobID int
    Value int
    Err   error
}
 
func runPool(ctx context.Context, workers int, jobs []Job) []Result {
    jobCh := make(chan Job)            // 1. unbuffered: producer paces to workers
    resCh := make(chan Result)
    var wg sync.WaitGroup
 
    // 2. Start N workers.
    wg.Add(workers)
    for w := 0; w < workers; w++ {
        go func(id int) {
            defer wg.Done()            // 3. signal "this worker has exited"
            for {
                select {
                case <-ctx.Done():
                    return             // 4. cancellation: abandon work, exit
                case job, ok := <-jobCh:
                    if !ok {
                        return         // 5. jobs channel closed+drained: clean exit
                    }
                    r := Result{JobID: job.ID, Value: job.ID * job.ID}
                    select {
                    case resCh <- r:   // 6. deliver result...
                    case <-ctx.Done(): // 7. ...unless canceled mid-send
                        return
                    }
                }
            }
        }(w)
    }
 
    // 8. The ONLY closer of resCh — runs after every worker's wg.Done().
    go func() {
        wg.Wait()
        close(resCh)
    }()
 
    // 9. Feed jobs, then close jobCh so workers' range/receive can end.
    go func() {
        defer close(jobCh)
        for _, j := range jobs {
            select {
            case jobCh <- j:
            case <-ctx.Done():
                return                 // 10. stop feeding if canceled
            }
        }
    }()
 
    // 11. Collect until resCh is closed by goroutine 8.
    var out []Result
    for r := range resCh {
        out = append(out, r)
    }
    return out
}

Line 1 uses an unbuffered jobs channel so the producer cannot run far ahead of the workers — backpressure for free. Line 3’s defer wg.Done() guarantees the count is decremented on every exit path, including the cancellation return at line 4. Lines 4–5 are the worker’s two clean-exit doors: cancellation, or a closed-and-drained jobs channel. Lines 6–7 are the subtle part — a result send can block, so it too needs a ctx.Done() escape, otherwise a canceled pool whose collector has stopped reading deadlocks every worker. Line 8’s goroutine is the single legal closer of resCh; closing it from a worker would risk a double-close panic. Line 10 stops the producer early on cancellation; without it the producer leaks, blocked forever on a send to a channel no worker is reading.

A simpler variant when there is no cancellation and no result channel — fire-and-forget work, the shape from the Go Tour:

func process(jobs []Job) {
    jobCh := make(chan Job, len(jobs)) // buffered to hold all jobs at once
    var wg sync.WaitGroup
 
    for w := 0; w < runtime.NumCPU(); w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobCh {   // range ends when jobCh closes
                doWork(job)
            }
        }()
    }
    for _, j := range jobs {
        jobCh <- j
    }
    close(jobCh)  // signal workers: no more jobs
    wg.Wait()     // block until all workers' range loops have ended
}

Here the buffered channel sized to len(jobs) lets the producer dump everything without blocking, then close + wg.Wait() is the entire shutdown protocol. runtime.NumCPU() is a sensible default pool size for CPU-bound work; I/O-bound work usually wants a larger pool, tuned empirically against the downstream resource’s connection limit.

Failure Modes

Closing the results channel from a worker. Each worker thinks “I’m done, I’ll close results.” The second worker to do so panics with close of closed channel; a worker still mid-send panics with send on closed channel. Fix: one dedicated closer goroutine gated on wg.Wait().

Forgetting wg.Add before go, or calling wg.Add inside the worker. If wg.Add(1) runs inside the goroutine, the closer’s wg.Wait() can observe a counter of zero before the worker even starts, close results early, and the collector misses results — or a worker panics sending on the now-closed channel. wg.Add must complete on the spawning goroutine before wg.Wait can run.

The unguarded send leak. A worker doing a bare results <- r with no ctx.Done() arm leaks the instant the collector stops early (an error, a break, a returned-first-result). The worker blocks forever on the send; the wg.Wait() never completes; the closer goroutine leaks too. The race detector will not catch this — it is a liveness bug. Diagnose it with a goroutine-count metric or pprof’s goroutine profile showing many goroutines parked in chan send.

Deadlock from an unclosed jobs channel. If the producer forgets close(jobCh), every worker’s for job := range jobCh blocks forever waiting for a value that never comes; wg.Wait() hangs. With all goroutines blocked, the Go runtime detects it and prints fatal error: all goroutines are asleep - deadlock! — but only if literally every goroutine is stuck.

Pool size of zero. for w := 0; w < 0; w++ starts no workers; the producer fills the buffer (or blocks immediately on an unbuffered channel) and nothing ever drains it. Validate workers >= 1.

Alternatives and When to Choose Them

For the extremely common case “run these tasks with bounded parallelism and stop everything if one fails,” do not hand-roll the WaitGroup + closer + cancellation dance — use errgroup with Group.SetLimit(N). SetLimit turns errgroup into a worker pool whose Go call blocks until a slot frees, and it propagates the first error and cancels the shared context automatically. The hand-rolled pool in this note is the right choice when you need a long-lived pool that processes a stream of jobs (not a fixed batch), need per-job results routed back, or need fine control over the channel topology.

A counted sync.WaitGroup alone gives you “wait for N goroutines” but no bounding and no error handling — fine for unbounded fire-and-forget. A buffered channel used purely as a counting semaphore (sem := make(chan struct{}, N), acquire with a send, release with a receive) bounds concurrency without a persistent pool and is the lightest option when each task is genuinely independent and you don’t want long-lived worker goroutines. For pooling objects rather than goroutines (reusing buffers), see sync.Pool Internals — a different concept that shares the word “pool.”

Production Notes

The single most important production decision is pool size, and it is not “number of CPUs” for I/O-bound work. A pool of 200 goroutines all calling a database with a 100-connection pool will queue 100 of them inside the driver, adding latency and possibly tripping connection timeouts; the correct size is bounded by the downstream resource, discovered by load testing, not by runtime.NumCPU(). For CPU-bound work, GOMAXPROCS (defaulting to the CPU count, see GMP Scheduler Model) is the natural ceiling — more workers than that just adds scheduling churn.

A second production concern is graceful shutdown: a long-lived pool should drain in-flight jobs on SIGTERM rather than dropping them. The pattern is to stop the producer, let workers finish the jobs already in the channel, then wg.Wait() with its own timeout — covered in Graceful Shutdown Patterns. Finally, always export a metric for queue depth (length of the jobs channel) and active workers; a queue that grows without bound is the early warning that the pool is undersized or a downstream dependency has slowed down.

See Also