Day 23 — Worker pools & pipelines
Day 23 — Worker pools & pipelines
Stage III · ~3h (theory-heavy)
Goal: Bound concurrency with worker pools, design multi-stage pipelines with backpressure, and build a race-clean concurrent “fetcher” that does not spawn unbounded goroutines.
Unbounded go per item is the most common production footgun. Pools turn “as much as possible” into “as much as safe.”
Why this day exists
You can start 10k goroutines (Day 19). Doing that per HTTP request or per file in a million-file walk:
- Exhausts memory and file descriptors
- Overwhelms remote APIs
- Makes cancellation (Day 24) painful
Worker pools and pipelines are the default industrial shape of concurrent Go.
Theory 1 — Bound concurrency, not just spawn
Three common bounders:
| Technique | Mechanism |
|---|---|
| Fixed workers | N goroutines ranging over a jobs channel |
| Semaphore channel | Buffered chan struct{} of size N |
| errgroup.SetLimit | Day 27 — structured concurrency with limits |
Fixed worker pool skeleton
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
// sender:
go func() {
defer close(jobs)
for _, j := range all {
jobs <- j
}
}()
for r := range results {
handle(r)
}Properties:
- At most
workersconcurrentprocesscalls
- Backpressure: slow process fills
jobsbuffer and blocks senders
- Clean shutdown when jobs closed and workers exit
Theory 2 — Semaphore style (ad-hoc pool)
sem := make(chan struct{}, maxConcurrent)
for _, item := range items {
sem <- struct{}{} // acquire
go func(item Item) {
defer func() { <-sem }() // release
work(item)
}(item)
}
// still need WaitGroup to join!This allows up to maxConcurrent goroutines alive for work, but still creates one goroutine per item over time (each exits after work). Fixed workers reuse goroutines—often clearer for long pipelines.
Theory 3 — Pipeline stages
A pipeline is a series of stages connected by channels:
source → stage1 → stage2 → sink
Each stage:
- Receives until input closed
- Sends outputs
- Closes its output when done (usually via defer after WaitGroup if fan-out inside stage)
Stage template
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
return out
}Compose:
out := square(gen(1, 2, 3, 4))
for v := range out {
fmt.Println(v)
}Theory 4 — Errors in pools (preview)
Naive pools only pass happy-path results. Real pools need:
Result{Value, Err}structs
- Or a separate error channel (harder—ordering)
- Or errgroup (Day 27) for first-error cancel
For today, use a result struct:
type Result struct {
URL string
Err error
N int
}Collect all results; decide fail-fast vs best-effort at the aggregator.
Theory 5 — Choosing buffer sizes
| Buffer | Effect |
|---|---|
| 0 | Strong coupling; good for low latency handoff |
| small (workers) | Smooths jitter |
| huge | Hides backpressure; can OOM |
Heuristic: buffer ≈ worker count or small multiple—not “millions.”
Theory 6 — Graceful drain vs cancel
Two shutdown modes:
- Drain: stop accepting new jobs; finish in-flight; close
- Cancel: abandon in-flight soon (needs context + cooperative work—Day 24)
Pools without cancel only support drain. That is OK for batch CLIs; services need cancel.
Worked example — bounded concurrent “fetch”
Simulated fetch with sleep and optional failure:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Job struct{ URL string }
type Result struct {
URL string
OK bool
Ms int
}
func fetch(url string) Result {
ms := 20 + rand.Intn(80)
time.Sleep(time.Duration(ms) * time.Millisecond)
return Result{URL: url, OK: rand.Float32() > 0.1, Ms: ms}
}
func runPool(urls []string, workers int) []Result {
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- fetch(j.URL)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
go func() {
defer close(jobs)
for _, u := range urls {
jobs <- Job{URL: u}
}
}()
var out []Result
for r := range results {
out = append(out, r)
}
return out
}
func main() {
urls := make([]string, 50)
for i := range urls {
urls[i] = fmt.Sprintf("https://example.com/%d", i)
}
start := time.Now()
res := runPool(urls, 8)
fmt.Printf("got %d results in %s\n", len(res), time.Since(start))
fail := 0
for _, r := range res {
if !r.OK {
fail++
}
}
fmt.Println("failures", fail)
}Lab 1 — Implement a generic-shaped pool (concrete type OK)
Workspace: ~/lab/90daysofx/01-go/day23
mkdir -p ~/lab/90daysofx/01-go/day23 && cd ~/lab/90daysofx/01-go/day23
go mod init example.com/day23Requirements:
workersflag or const (try 1, 4, 16)
- Process at least 100 jobs
- Each job: hash a string or sleep + counter
- Print throughput (jobs/sec)
go run -race .
Lab 2 — Three-stage pipeline
Stages:
- gen — emit lines / ints
- transform — uppercase or square
- filter — drop some values
Each stage is a function <-chan T → <-chan U. Main only ranges the final channel. Race-clean.
Lab 3 — Unbounded vs bounded comparison
Version A: for _, item := range items { go work(item) } + WaitGroup
Version B: worker pool with workers=8
For items=5000 with 5–20ms simulated I/O:
- Compare max goroutines roughly via
runtime.NumGoroutine()sampling
- Compare elapsed time
- Journal memory feel (Activity Monitor optional)
// sample
go func() {
for range time.Tick(50 * time.Millisecond) {
fmt.Println("g", runtime.NumGoroutine())
}
}()Stop the sampler cleanly (done channel)—don’t leak the ticker goroutine.
Lab 4 — Result aggregation with errors
Return []Result including errors. Exit non-zero if any job failed after draining the pool (do not abandon workers mid-flight yet—that is Day 24/27).
go test -race ./...Common gotchas
| Gotcha | Fix |
|---|---|
Close jobs before send finished |
Defer close in sender only |
Workers blocked on full results + main not reading |
Buffer results or read concurrently |
| Starting workers after closing jobs | Start workers first |
| One huge results slice race | Single aggregator goroutine |
| Pool without join | WaitGroup workers → close results |
workers > len(jobs) worry |
Fine; idle workers exit on close |
Checkpoint
- Explain backpressure via full channels
- Fixed-size pool processes N jobs with W workers
- Multi-stage pipeline closes correctly end-to-end
- Compared unbounded vs bounded goroutine counts
- Aggregated errors without losing workers
go run -race/go test -raceclean
Commit
git add .
git commit -m "day23: worker pool + pipeline + bounded fetcher"Write three personal gotchas before continuing.
Tomorrow
Day 24 — context: cancellation, deadlines, values (sparingly), and cancelling the fetcher cleanly mid-flight.