Skip to content

Day 071 — Worker Pool Pattern

Month 3 · Week 3 · ⬅ Day 070 · Day 072 ➡ · Journal index

🎯 Learning Objective

Build a reusable worker-pool: a fixed set of N goroutines that pull jobs from a channel and push results to another, so concurrency stays bounded no matter how much work arrives.

📚 Topics

  • Goroutine-per-task (unbounded) vs worker pool (bounded N)
  • Jobs channel · results channel · the closer goroutine
  • Backpressure from an unbuffered/bounded jobs channel

📖 Reading / Sources

📝 Notes

  • A worker pool decouples how much work exists from how much runs at once. Spawn exactly N long-lived goroutines, each ranging over a shared jobs channel → [[worker-pool]].
  • One goroutine per task is fine for a handful, but unbounded for 1M tasks: it can exhaust memory, file descriptors, or hammer a downstream service. The pool caps that at N → [[bounded-concurrency]].
  • All workers range the same jobs channel — Go delivers each value to exactly one receiver, so jobs are load-balanced automatically (whoever is free reads next) → [[channel-axioms]].
  • close(jobs) is the stop signal: every worker's range loop ends when the channel is drained and closed. Close from the single producer, after the last job is queued.
  • Collect results on a separate results channel. Close results in a closer goroutine (wg.Wait(); close(results)) so the consumer's range terminates exactly once → [[close-once-closer]].
  • An unbuffered jobs channel gives backpressure: the producer blocks until a worker is ready, so the queue can't balloon. Buffer it only to smooth bursts → [[backpressure]].
  • Worker-pool results arrive out of order (whichever worker finishes first). Tag each job with an index if you need to restore input order.

💻 Code Examples

func pool[T, R any](workers int, in []T, fn func(T) R) []R {
    jobs := make(chan int)       // send indices, not values, to keep order
    results := make([]R, len(in))
    var wg sync.WaitGroup
    wg.Add(workers)
    for w := 0; w < workers; w++ {
        go func() {
            defer wg.Done()
            for i := range jobs { // each worker pulls the next free index
                results[i] = fn(in[i])
            }
        }()
    }
    for i := range in {
        jobs <- i
    }
    close(jobs) // workers' range loops end once drained
    wg.Wait()
    return results
}

Full code: examples/month-03/workerpool/main.go · Run: go run ./examples/month-03/workerpool

🏋️ Exercises / Practice

Exercise Status Link
Counting semaphore (bounded parallelism core) exercises/month-03/week-3/semaphore/
Order-preserving bounded Map with context exercises/month-03/week-3/boundedmap/

🐛 Mistakes Made

  • Closed jobs from inside a worker → the other workers panicked on the next send. Close belongs to the single producer.
  • Forgot the closer goroutine and closed results right after queuing jobs → workers were still writing → send-on-closed panic. The closer must wait on wg.

❓ Open Questions

  • How do I size the pool? (Rule of thumb: CPU-bound → ~GOMAXPROCS; I/O-bound → higher, tune by benchmark. More on this Day 074.)

🧠 Active Recall (answer without looking)

  1. Q: Why does sending each value to a shared jobs channel load-balance automatically?

    A A channel delivers every sent value to exactly one receiver; whichever worker is currently blocked in `range` (i.e. free) takes the next job. No dispatcher logic needed.

  2. Q: Who should close(jobs) and who should close(results)?

    A The single producer closes `jobs` after the last job. A dedicated closer goroutine closes `results` after `wg.Wait()` confirms every worker has finished writing.

🪶 Feynman Reflection

A worker pool is a small crew at a conveyor belt. Tasks ride in on one belt (jobs); each free worker grabs the next one; finished work rides out on a second belt (results). The crew size is fixed, so a flood of tasks just queues up — it never spawns a million workers and crashes the warehouse.

🕳️ Knowledge Gaps

  • Returning errors from workers and cancelling the rest — that's errgroup territory (Day 076).

✅ Summary

I can build a bounded worker pool: N goroutines ranging a jobs channel, results gathered via index or a results channel closed by a closer goroutine after wg.Wait().

⏭️ Next Steps / Prep for Tomorrow

  • Day 072: generalise this into the fan-out / fan-in vocabulary.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): worker pool pattern (day 071)