Writing a concurrent worker pool in Go

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.