Production Concurrency: Patterns Beyond Hello Goroutine
A go func() in a tutorial proves the runtime works.
Search across all documentation pages
A go func() in a tutorial proves the runtime works.
A production service must bound parallelism, propagate cancellation, shed load under pressure, and shut down without leaving goroutines blocked forever.
This page is the conceptual anchor for Advanced Concurrency.
Advanced Concurrency Basics collects runnable snippets; sibling articles cover worker pools, pipelines, rate limiting, errgroup, atomics, leak prevention, and review habits.
Think of a Go service as a traffic controller for work.
Requests arrive faster than a database can answer if every request spawns its own unbounded goroutine tree.
Production code introduces capacity: a fixed number of workers, a semaphore limiting in-flight calls, or a buffered channel that blocks producers when consumers fall behind.
Cancellation is the second pillar.
context.Context threads a stop signal from the HTTP handler through every downstream call.
When the client disconnects or a deadline fires, in-flight work should exit promptly instead of holding connections open.
The third pillar is composition.
Simple patterns combine into larger systems:
These are not competing religions.
A single handler might use a semaphore for outbound HTTP, errgroup for parallel fetches, and a worker pool for background retries.
Client request
|
v
HTTP handler (ctx from r.Context())
|
+----+----+
| |
v v
errgroup semaphore-limited
parallel downstream calls
fetches
| |
+----+----+
v
merge / respond
|
on ctx.Done() -> stop spawning, drain or abandon
Worker pools cap CPU-bound or I/O-bound parallelism at a number you choose - often tied to connection pool size or CPU count.
Jobs enter a channel; workers range over it until the channel closes or context cancels.
Pipelines separate concerns: parsing, validation, enrichment, and persistence can each run in its own stage with bounded buffers between them.
Backpressure appears naturally when an upstream stage blocks on send because the downstream buffer is full.
errgroup coordinates a batch of goroutines with shared cancellation: the first error cancels siblings and returns one combined error to the caller.
Pair it with context.WithCancel derived from the request context.
Rate limiting (token bucket, golang.org/x/time/rate, or a weighted semaphore) protects shared resources.
Graceful shutdown listens for SIGTERM, stops accepting new work, waits for in-flight requests with a timeout, then closes worker channels so goroutines exit.
| Pattern | Strength | Weakness | Best Fit |
|---|---|---|---|
Unbounded go per task | Simplest code | No backpressure; OOM risk | Prototypes only |
| Worker pool | Predictable parallelism | Queue sizing tuning | CPU/IO pools, job processors |
| Pipeline stages | Clear dataflow | More moving parts | ETL, log processing |
| errgroup | First-error cancel | Less control over partial results | Parallel fetches |
| Semaphore / rate limit | Protects downstream | Adds latency under load | DB/API gateways |
HTTP servers in net/http already run each request in its own goroutine.
The advanced work is inside the handler: limit concurrent calls to Postgres, Redis, or a partner API.
Match pool size to SetMaxOpenConns on database/sql so you do not queue goroutines behind an exhausted pool.
For streaming RPC or WebSockets, one long-lived goroutine per connection is normal.
Still bound auxiliary work (background refresh, fan-out enrichment) with semaphores.
Observability closes the loop: track goroutine count (runtime.NumGoroutine), in-flight requests, queue depth, and time blocked on semaphores.
Spikes without traffic growth often signal a leak or deadlock.
Run load tests with -race in CI for packages that mutate shared state.
Kubernetes sends SIGTERM before SIGKILL.
Your main should call http.Server.Shutdown with a deadline, cancel a root context, close job channels, and Wait() on worker groups.
Document ownership: who closes which channel, who waits on whom.
Integration with context is mandatory for production handlers.
Pass r.Context() into http.NewRequestWithContext, gRPC calls, and db.QueryContext.
See context Package for the cancellation model.
sync.Mutex or atomics; channels are for passing ownership, not every shared byte.sync.WaitGroup is still right when errors are handled per-goroutine and you do not need automatic cancel-on-error.ctx.Done() as well; close alone does not unblock sends waiting on a full buffer.A bounded semaphore or small worker pool on outbound I/O.
It is the smallest change that prevents unbounded goroutines when traffic spikes.
GOMAXPROCS caps OS threads running Go code.
Worker pools cap application-level tasks - often lower than CPU count for I/O or matching external connection limits.
Channels when producers and consumers are goroutines passing work items.
A mutex plus slice when you need indexed priority, inspection, or single-goroutine drain logic.
It gives one goroutine per request, not a pool for your internal work.
You still bound parallel database or HTTP client calls inside handlers.
A slow consumer causes a blocking send on a channel or a failed Acquire on a semaphore, which slows producers instead of buffering unbounded work in memory.
errgroup returns the first error and can cancel a derived context.
WaitGroup only waits; error handling stays manual.
No - use a separate context cancelled on process shutdown.
Request context ends when the client disconnects, which is wrong for fire-and-forget audit logs unless you explicitly detach.
Start workers, cancel context, close inputs, assert Wait() returns within a timeout and goroutine count returns to baseline.
Only when work is independent and downstream capacity supports N parallel calls.
Otherwise fan-out amplifies throttling and errors.
Monotonically rising runtime.NumGoroutine after load stops, plus growing heap and open FD counts.
Yes - a pipeline stage can use errgroup internally for parallel steps within one stage.
Run go test -race on packages with shared mutable state.
It does not detect deadlocks or logical races on channels.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 16, 2026