Goroutines (Lesson 4) and channels (Lesson 5) are the primitives. In practice you combine them into a small number of recurring patterns — and if you learn these four or five, you can build almost any concurrent backend logic. This lesson is those patterns, ending with the one that every production Go service is built around: context for cancellation and deadlines.
The Worker Pool
The most common pattern. You have many jobs to do and you could spawn a goroutine per job — but the goroutines are not the constraint; the work they do is. Each job might open a database connection, call an API with a rate limit, or use a chunk of memory. Spawn a million goroutines each grabbing a DB connection and you will exhaust the database instantly (Lesson 10). So you bound the concurrency: start a fixed number of workers, N, and feed them from a shared channel.
The shape in code:
jobs := make(chan Job, 100)
results := make(chan Result, 100)
// start N workers
for i := 0; i < N; i++ {
go func() {
for job := range jobs { // each worker pulls until jobs is closed
results <- process(job)
}
}()
}
// feed jobs, then close so workers' range loops end
for _, j := range allJobs {
jobs <- j
}
close(jobs)N is the concurrency ceiling you control — often GOMAXPROCS for CPU-bound work, or a number tuned to what the downstream resource (DB pool size, API rate limit) can take. All workers share one jobs channel, so each pulls the next available job with no coordination code from you — the channel distributes the work. You get parallelism with a limit, which is exactly what you want when the work touches finite resources.
Related shapes are variations on this:
- Fan-out: many worker goroutines reading from one channel (that’s the pool above — one source, many consumers).
- Fan-in: the reverse — merge the outputs of many goroutines into one channel for a single consumer to process.
- Pipeline: a chain of stages, each a goroutine, connected by channels — stage 1 reads input and sends to stage 2’s channel, stage 2 transforms and sends to stage 3, and so on. Each stage runs concurrently, like a factory line, and channels provide the backpressure between stages automatically.
Waiting for Goroutines to Finish
A goroutine runs in the background; main or your handler often needs to wait until a batch of them is done. Two tools:
sync.WaitGroup— a counter.wg.Add(n)before starting n goroutines,wg.Done()(usuallydeferred) inside each as it finishes, andwg.Wait()blocks until the counter hits zero. This is the standard “launch a batch, wait for all of them” primitive:
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
handle(it)
}(it)
}
wg.Wait() // returns once every goroutine has called Donegolang.org/x/sync/errgroup— like a WaitGroup, but it also collects the first error and can cancel the rest via a context. When you fan out work and want “run these concurrently, wait for all, and fail fast if any errors,”errgroupis the clean choice.
(Note the go func(it Item){...}(it) above passes it as an argument. In older Go, a loop variable captured by a closure was shared across iterations — a classic bug. Go 1.22 changed loop variables to be per-iteration, fixing it, but passing explicitly is still clear and safe.)
context: Cancellation and Deadlines
Now the pattern that every Go backend is built around. A server handles a request by fanning out work — a DB query, a cache lookup, two API calls — often across several goroutines. What happens when the client disconnects, or the request exceeds its time budget? You want all that work to stop, immediately, so you are not burning CPU and holding connections for a response nobody will read. That coordination is what context.Context provides.
A context.Context carries two things through a call tree: a cancellation signal and an optional deadline. The conventions:
- It is the first argument of almost every function that does I/O or significant work:
func (s *Service) GetOrder(ctx context.Context, id int) (Order, error). You thread it down through every layer. - You derive child contexts. The HTTP server gives you a request context (
r.Context()); you wrap it with a timeout:ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second); defer cancel(). Now everything below has a 2-second budget. ctx.Done()is a channel that closes when the context is cancelled — because the deadline passed, the client disconnected, or someone calledcancel(). Every goroutine and every I/O call that holds the context watchesctx.Done()(often viaselect, Lesson 5) and bails out when it closes.
The effect is that one cancellation fans out: the timeout fires, ctx.Done() closes, and the DB query (via QueryContext), the HTTP calls (via http.NewRequestWithContext), and your own loops (checking ctx.Err()) all stop together. No orphaned goroutines grinding away on work whose answer is already unwanted. This is how a Go service sheds load gracefully under pressure — slow or abandoned requests free their resources instead of piling up into a death spiral.
Two rules keep context usage clean:
- Pass it down the call stack; never store it in a struct. Context is per-request and flows with the call. Stashing one in a long-lived struct field defeats its purpose and causes subtle bugs.
- Always use the
...Contextvariants of library calls —db.QueryContext,db.ExecContext,http.NewRequestWithContext— so cancellation actually reaches the I/O. A plaindb.Queryignores the context and will keep running after the client has gone.
context ties together everything from these concurrency lessons: goroutines doing the work, channels (ctx.Done()) signalling cancellation, and a clean way to bound and stop a request’s entire fan-out. It is not optional knowledge — it is in the signature of virtually every method in a real Go backend, and the HTTP and database lessons (8 and 10) use it throughout.
Pitfalls to Avoid
- One goroutine per job against a finite resource. Spawning unbounded goroutines that each grab a DB connection or hit a rate-limited API overwhelms the resource. Use a worker pool to cap concurrency at what the downstream can take.
- Forgetting to
closethe jobs channel. Workersrangeing overjobsblock forever if it’s never closed — the pool hangs andwg.Wait()never returns. Close the channel once all jobs are sent. WaitGroupmisuse. Callingwg.Addinside the goroutine (racey —Waitmay run first), or forgettingdefer wg.Done()on an error path (counter never reaches zero,Waithangs). Add beforego,defer Donefirst thing inside.- Not calling
cancel().context.WithTimeout/WithCancelreturn acancelfunc you must call (usuallydefer cancel()) — skipping it leaks the context’s resources (a timer, a goroutine) until the deadline. Alwaysdefer cancel(). - Ignoring the context. Taking a
ctxbut callingdb.Queryinstead ofdb.QueryContext, or never checkingctx.Err()in a long loop. Then cancellation does nothing and work runs on after the client left. Thread the context into every I/O call. - Storing context in a struct, or passing
nil. Context is meant to flow as an argument per request. Don’t cache it in a field; don’t passnil(usecontext.Background()at the top,context.TODO()as a temporary placeholder).
Quick Recap
- Worker pool: N workers pull from one
jobschannel and push to oneresultschannel — bound concurrency at N because the work (DB conns, rate limits, memory), not the goroutines, is the constraint. Fan-out/fan-in and pipelines are variations. - Wait for goroutines with
sync.WaitGroup(Add/Done/Wait); useerrgroupwhen you also want the first error and fail-fast cancellation. context.Contextis the pattern every Go backend lives by: the first argument of nearly every I/O function, threading a cancellation signal + deadline through the whole request. When the client leaves or a timeout fires,ctx.Done()closes and all the fanned-out work stops together — graceful load-shedding, no orphaned work.- Pass context down, never store it; always use the
...Contextcall variants; alwaysdefer cancel().
Next lesson: when you do share memory, how to do it safely — mutex vs atomic vs channel, with measurements, and the race detector that catches your mistakes.