12 Context and Concurrency Patterns
Chapters 10 and 11 gave you goroutines, channels, and the sync primitives you need to coordinate them. This chapter adds the layer that sits on top of all of that: context.Context, the standard way to propagate cancellation and deadlines across goroutine boundaries. It also covers the patterns you will see in real Go services — worker pools, rate limiters, fan-out with error collection, and goroutine leak detection.
12.1 context.Context
Java has no direct equivalent to context.Context. The closest Java analog is a combination of Future.cancel(), ExecutorService.shutdownNow(), and a hand-rolled deadline field on a request object — all bolted together differently in every codebase. Go standardizes all of this in a single interface.
// context.Context is defined in the standard library as:
type Context interface {
Deadline() (deadline time.Time, ok bool) // returns the deadline, if any
Done() <-chan struct{} // closed when work should be cancelled
Err() error // nil, then Canceled or DeadlineExceeded
Value(key any) any // returns the value associated with key, or nil
}Every long-running or network-bound function in idiomatic Go accepts a context.Context as its first parameter. When the context is cancelled — because a deadline expired, a timeout elapsed, or the caller called a cancel function — Done() is closed and Err() returns a non-nil error. Your function is expected to notice this and return promptly.
Tip: context.Context is not just for HTTP handlers. Use it everywhere work can be cancelled: database queries, RPC calls, file I/O, and long computations.
12.1.1 The Root Contexts
Every context tree starts with one of two roots:
ctx := context.Background() // the default root; never cancelled, no deadline, no values
ctx := context.TODO() // placeholder for "I haven't wired up a context yet"context.Background() is for main, top-level servers, and test helpers. context.TODO() is a compile-time signal that you know a context should be here but have not plumbed it through yet. Treat context.TODO() like a // TODO comment — it should not survive into production code.
12.2 Cancellation, Deadlines, and Timeouts
The three constructors that add cancellation to a context are context.WithCancel, context.WithDeadline, and context.WithTimeout. Each returns a derived context and a cancel function. Always call the cancel function, even if the operation finishes before the deadline. Failing to call cancel keeps the derived context (and its timer) alive until the deadline fires or the parent is cancelled, pinning everything the context references.
// WithCancel returns a copy of parent whose Done channel is closed when cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// WithDeadline returns a copy of parent with a deadline set to d.
func WithDeadline(parent Context, d time.Time) (ctx Context, cancel CancelFunc)
// WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
func WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc)Here is a function that fetches a song’s lyrics from a slow API, with a two-second timeout:
package main
import (
"context"
"fmt"
"time"
)
// fetchLyrics simulates a slow network call; it respects ctx cancellation.
func fetchLyrics(ctx context.Context, song string) (string, error) {
done := make(chan string, 1)
go func() {
time.Sleep(3 * time.Second) // simulate slow work
done <- "I can't keep loving you the way I do"
}()
select {
case lyrics := <-done:
return lyrics, nil
case <-ctx.Done():
return "", ctx.Err() // context.DeadlineExceeded
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // always call cancel
lyrics, err := fetchLyrics(ctx, "Gouryella")
if err != nil {
fmt.Println("timed out:", err) // timed out: context deadline exceeded
return
}
fmt.Println(lyrics)
} When the timeout fires, ctx.Done() is closed and ctx.Err() returns context.DeadlineExceeded. If cancel() is called before the timeout, ctx.Err() returns context.Canceled. Your code can distinguish the two with errors.Is:
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
fmt.Println("ran out of time")
}
if errors.Is(ctx.Err(), context.Canceled) {
fmt.Println("caller gave up")
}
Trap: Do not store the cancel function and call it lazily. Use defer cancel() immediately after the WithTimeout / WithCancel call. If you forget, the runtime cannot release resources associated with the context until the parent is cancelled or the program exits.
12.2.1 Checking Cancellation Inside a Loop
Goroutines doing CPU-bound work need to poll the context rather than waiting on a channel:
func processTracks(ctx context.Context, tracks []string) error {
for _, t := range tracks {
select {
case <-ctx.Done():
return ctx.Err() // bail out early
default:
}
// do real work with t
fmt.Println("processing:", t)
}
return nil
}The select with a default branch is non-blocking: it drains Done if it is already closed but does not block if it is still open. Chapter 10 covered select in detail.
12.3 context.WithValue
context.WithValue attaches a key-value pair to a context that child goroutines can retrieve with ctx.Value(key). This is intended for request-scoped metadata — things like a trace ID or an authenticated user — not for passing optional function parameters.
func WithValue(parent Context, key, val any) ContextThe value is retrieved by calling Value(key) on any derived context. If no value is found at the current level, Go walks up the context tree until it finds one or reaches the root.
12.3.1 Use Unexported Key Types
If you use a plain string as a key, any package in the call tree can accidentally shadow your value by using the same string key. The idiomatic fix is to define a package-private type for your keys. Because the type is unexported, no other package can construct a value of that type, so collisions are impossible.
package requestmeta
// traceKey is unexported; no other package can create a value of this type.
type traceKey struct{}
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceKey{}, id)
}
func TraceID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceKey{}).(string)
return id, ok
}Contrast this with the anti-pattern:
// ANTI-PATTERN: string keys can collide across packages
ctx = context.WithValue(ctx, "traceID", "abc-123")
ctx = context.WithValue(ctx, "traceID", "xyz-999") // silently shadows the first one!
Trap: Never use a built-in type (string, int, etc.) as a context key. Linters such as staticcheck (check SA1029) flag this with should not use built-in type string as key for value — note that plain go vet does not catch it, so do not rely on the standard tool alone here. Always define an unexported struct type for context keys.
12.4 Context as First Parameter
Go has a universal convention: if a function accepts a context, it is always the first parameter and it is always named ctx. [ctx-for-context]
// idiomatic
func SearchSongs(ctx context.Context, query string) ([]Song, error)
// wrong: context buried in the middle
func SearchSongs(query string, ctx context.Context) ([]Song, error)This convention applies throughout the standard library, all major frameworks, and community packages. Do not put a context inside a struct (except when constructing a long-lived object like an HTTP server); pass it explicitly on each call.
Trap: Do not store a context.Context in a struct field and use it later. Contexts are request-scoped. A stored context will be cancelled at unpredictable times. Pass the context explicitly to every function that needs it.
The convention is so consistent that when you see a function signature like func Foo(ctx context.Context, ...) you immediately know it can be cancelled, timed out, and carries request metadata. Java has no equivalent signal at the call site.
12.5 errgroup — Fan-Out with Error Collection
Chapter 11 showed sync.WaitGroup for fan-out. WaitGroup works, but it cannot collect errors from goroutines. The golang.org/x/sync/errgroup package solves both problems: it waits for a group of goroutines to finish and returns the first non-nil error any of them produced. errgroup.WithContext returns a derived context that the group cancels as soon as any goroutine returns an error.
import "golang.org/x/sync/errgroup"
// Group is created with errgroup.WithContext.
// g.Go(f) launches f in a goroutine; g.Wait() blocks until all goroutines finish.
// g.Wait() returns the first non-nil error returned by any goroutine.
func WithContext(ctx context.Context) (*Group, context.Context)
func (g *Group) Go(f func() error)
func (g *Group) Wait() errorHere is a fan-out that fetches three song titles concurrently and cancels all of them if any fails:
package main
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
// fetchTitle simulates fetching a song title; slow returns an error.
func fetchTitle(ctx context.Context, id int) (string, error) {
titles := []string{"Gouryella", "Flaming June", "Saltwater"}
select {
case <-time.After(time.Duration(id+1) * 100 * time.Millisecond):
return titles[id%len(titles)], nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx := context.Background()
g, ctx := errgroup.WithContext(ctx)
results := make([]string, 3)
for i := 0; i < 3; i++ {
i := i // capture loop variable; unnecessary on Go 1.22+ (per-iteration scope)
g.Go(func() error {
title, err := fetchTitle(ctx, i)
if err != nil {
return err
}
results[i] = title
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("error:", err)
return
}
for _, t := range results {
fmt.Println(t)
}
} When any goroutine in the group returns a non-nil error, errgroup cancels the shared context. Goroutines that are still running will see ctx.Done() closed and should return promptly. g.Wait() blocks until all goroutines have returned, then returns the first error.
Tip: errgroup is the idiomatic replacement for sync.WaitGroup whenever goroutines can fail. If none of them can fail, sync.WaitGroup is fine.
12.6 Goroutine Leak Detection
A goroutine leak is a goroutine that starts but never exits. Leaks accumulate over the lifetime of a server: each request might leave one goroutine behind, and after thousands of requests you have thousands of idle goroutines consuming memory and scheduler time. Each leaked goroutine also keeps every value it has closed over alive, preventing GC from reclaiming that memory. [leaked-goroutine-grows-memory]
The most common cause is a goroutine blocked on a channel send or receive with no path to exit:
// BUG: this goroutine leaks if the caller stops listening on results.
func streamHits(results chan<- string) {
for {
results <- "Gamemaster" // blocks forever if nobody reads
}
}Every goroutine must have a clear exit path. [goroutine-must-exit] The idiomatic exits are:
- the function returns naturally,
- a
donechannel is closed, - the context passed in is cancelled.
The go.uber.org/goleak package detects leaked goroutines in tests:
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // fails the test suite if any goroutine leaks
}
func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // fails this test if a goroutine leaks
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
select {
case <-ctx.Done(): // goroutine exits when context is cancelled
}
}()
cancel() // signal the goroutine to exit
<-done // wait for it to finish before VerifyNone runs
}goleak.VerifyTestMain(m) runs m.Run() itself, checks for leaked goroutines after the suite completes, and then calls os.Exit with the test exit code. Because it calls os.Exit for you, it must be the only thing your TestMain does — do not call m.Run() or os.Exit yourself, or you will exit twice and skip the leak check. goleak.VerifyNone(t) checks for leaks at the end of a single test function.
Tip: Run goleak.VerifyTestMain in every package that uses goroutines. It is cheap, catches real bugs, and forces you to wire up context cancellation correctly.
Trap: A goroutine that loops forever without a ctx.Done() or done channel check is always a potential leak. Even if your current tests do not expose it, a future caller that cancels the operation will leave it running. Keep goroutine lifetimes simple enough that the exit paths are obvious at a glance. [obvious-goroutine-lifetimes]
12.7 GOMAXPROCS
Go’s scheduler multiplexes goroutines onto OS threads. GOMAXPROCS controls how many OS threads the scheduler uses simultaneously. By default it is set to the number of logical CPUs available to the process (i.e., runtime.NumCPU()).
You can read or change it at runtime:
import "runtime"
n := runtime.GOMAXPROCS(0) // 0 means "don't change it; just return the current value"
fmt.Println("GOMAXPROCS:", n)
runtime.GOMAXPROCS(4) // limit to 4 OS threadsYou can also set it via an environment variable before starting the process:
GOMAXPROCS=4 ./myserver In container environments (Docker, Kubernetes), this used to be a famous trap: through Go 1.24, the default GOMAXPROCS read the host CPU count, not the container CPU limit, so a container limited to 2 vCPUs on a 32-core host started with GOMAXPROCS=32. Since Go 1.25 the runtime is container-aware on Linux: the default considers the cgroup CPU bandwidth limit and even updates periodically if the limit changes. The historical workaround, go.uber.org/automaxprocs, is only needed for programs built with Go 1.24 or earlier (or with GODEBUG=containermaxprocs=0):
import _ "go.uber.org/automaxprocs" // pre-1.25: sets GOMAXPROCS from cgroup quota
Tip: On Go 1.25+ the default GOMAXPROCS is correct on bare metal and in containers. Reach for automaxprocs or an explicit GOMAXPROCS in your deployment manifest only when you must support older toolchains.
12.8 Worker Pool
Spawning one goroutine per task is fine when tasks are cheap and bounded, but unbounded goroutine creation can exhaust memory or overwhelm a downstream service. A worker pool fixes the number of concurrent goroutines: a fixed set of N workers pull tasks off a shared jobs channel and push outcomes onto a results channel. This is the Go answer to Java’s ExecutorService with a fixed thread pool, but built from channels and a sync.WaitGroup instead of a framework.
The pattern has three moving parts: the producer sends jobs and closes the jobs channel, the workers range over jobs until it is closed, and a closer goroutine waits for all workers to finish and then closes the results channel so the consumer’s range terminates.
package main
import (
"fmt"
"sort"
"sync"
)
// worker pulls jobs until the jobs channel is closed, then returns.
func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for n := range jobs { // exits when jobs is closed and drained
results <- fmt.Sprintf("worker %d squared %d = %d", id, n, n*n)
}
}
func main() {
const numWorkers = 3
jobs := make(chan int)
results := make(chan string)
var wg sync.WaitGroup
for id := 1; id <= numWorkers; id++ {
wg.Add(1)
go worker(id, jobs, results, &wg)
}
// producer: send all jobs, then close so workers can exit.
go func() {
for n := 1; n <= 7; n++ {
jobs <- n
}
close(jobs)
}()
// closer: once every worker has returned, close results.
go func() {
wg.Wait()
close(results)
}()
var out []string
for r := range results { // drains until results is closed
out = append(out, r)
}
sort.Strings(out) // worker order is nondeterministic; sort for a stable display
for _, r := range out {
fmt.Println(r)
}
}The key invariant: the producer closes jobs, which lets each worker’s range loop return; the WaitGroup tracks those returns; and the closer goroutine closes results only after the last worker is done. Closing results is what lets main’s range terminate. If you forget to close results, main blocks forever on the final receive — a classic deadlock.
Trap: Do not close results from inside a worker. With N workers you would close it N times, and closing an already-closed channel panics. Close it exactly once, from a goroutine that waits on the WaitGroup.
Wut: The worker order in the output is nondeterministic — whichever worker the scheduler wakes first grabs the next job. With three workers and seven instant tasks, even the split is nondeterministic — one eager worker may grab five of the seven jobs. Only when every task takes the same non-trivial time does the distribution settle near 3/2/2.
12.9 Rate Limiting
Sometimes the problem is not too few workers but too many requests. Rate limiting caps how often an operation runs — protecting a downstream API, a database, or your own service from a thundering herd. Go does not need a library for the common cases; a ticker or a buffered channel is enough.
The simplest throttle uses time.NewTicker, which sends the current time on its channel at a fixed interval. Receiving from the ticker channel before each operation paces the loop to one operation per tick.
// NewTicker returns a Ticker that sends the time on its C channel every d.
func NewTicker(d Duration) *Ticker
func (t *Ticker) Stop() // stops the ticker; the channel is not closedpackage main
import (
"fmt"
"time"
)
func main() {
requests := []string{"As It Was", "Vampire", "Anti-Hero", "Houdini"}
limiter := time.NewTicker(200 * time.Millisecond)
defer limiter.Stop() // release the ticker's resources
for _, req := range requests {
<-limiter.C // wait for the next tick before proceeding
fmt.Println("serving", req)
}
}This serves at most one request every 200 ms. There is also time.Tick, a convenience wrapper that returns just the channel — it has no Stop, which used to make it a leak machine.
Tip: Before Go 1.23, every time.Tick call leaked its ticker: there is no Stop, and unreferenced tickers were never garbage collected. Since Go 1.23 the GC reclaims unreferenced tickers, so time.Tick is safe; time.NewTicker plus defer t.Stop() is still the explicit, version-proof habit and releases the timer immediately instead of waiting for the GC.
A plain ticker throttles to a steady rate but allows no bursts. When you want to permit a short burst and then settle to a steady rate, use a token bucket: a buffered channel pre-filled with tokens, refilled on a ticker. Each operation takes a token (blocking if the bucket is empty); a background goroutine drops a token in on every tick.
package main
import (
"fmt"
"time"
)
func main() {
const burst = 3
tokens := make(chan struct{}, burst)
for range burst {
tokens <- struct{}{} // start full: allow an initial burst of 3
}
refill := time.NewTicker(200 * time.Millisecond)
defer refill.Stop()
go func() {
for range refill.C {
select {
case tokens <- struct{}{}: // add a token if there is room
default: // bucket full; drop this token
}
}
}()
for i := 1; i <= 5; i++ {
<-tokens // take a token, blocking if none are available
fmt.Println("request", i, "at", time.Now().Format("15:04:05.000"))
}
}The first three requests fire immediately (draining the initial burst), then the remaining two are paced at 200 ms each as the refiller tops the bucket back up. The select with a default in the refiller is what enforces the cap: if the bucket is already full, the new token is discarded rather than blocking.
12.10 Try It
Type this in and run it. It threads a request ID through a context with WithValue, wraps that context with a WithTimeout, and plays a short playlist where each track respects cancellation. Two tracks finish before the deadline; the third runs out of time.
package main
import (
"context"
"errors"
"fmt"
"time"
)
type reqIDKey struct{}
func withReqID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, reqIDKey{}, id)
}
func reqID(ctx context.Context) string {
if id, ok := ctx.Value(reqIDKey{}).(string); ok {
return id
}
return "unknown"
}
func play(ctx context.Context, track string) error {
select {
case <-time.After(150 * time.Millisecond):
fmt.Printf("[%s] played %q\n", reqID(ctx), track)
return nil
case <-ctx.Done():
return fmt.Errorf("[%s] %q aborted: %w", reqID(ctx), track, ctx.Err())
}
}
func main() {
ctx := withReqID(context.Background(), "req-2026")
ctx, cancel := context.WithTimeout(ctx, 400*time.Millisecond)
defer cancel()
tracks := []string{"As It Was", "Vampire", "Anti-Hero"}
for _, t := range tracks {
if err := play(ctx, t); err != nil {
fmt.Println(err)
if errors.Is(err, context.DeadlineExceeded) {
break
}
}
}
}The deterministic output is the first two tracks playing, then "Anti-Hero" aborted: context deadline exceeded.
Try these modifications:
- Bump the timeout to
600 * time.Millisecondand confirm all three tracks play. - Replace
WithTimeoutwithWithCanceland callcancel()from a separate goroutine after 250 ms; watchctx.Err()switch fromDeadlineExceededtoCanceled. - Fetch the request ID with a plain string key (
ctx.Value("req")) and observe that it returnsnil— the key type matters, not just the underlying value.
12.11 Key Points
context.Contextcarries cancellation, deadlines, and request-scoped values across goroutine boundaries; Java has no direct equivalent.- The three context constructors are
WithCancel(manual cancel),WithDeadline(absolute time), andWithTimeout(relative duration); alwaysdefer cancel(). - Use unexported struct types as context keys to prevent collisions; never use strings or other built-in types as keys.
- The universal convention is
func Foo(ctx context.Context, ...)— context is always the first parameter, always namedctx, never stored in a struct. golang.org/x/sync/errgroupis the idiomatic fan-out tool when goroutines can fail; it collects the first error and cancels the shared context.- A worker pool fixes concurrency at
Ngoroutines ranging over a shared jobs channel; the producer closesjobs, aWaitGrouptracks the workers, and a closer goroutine closesresultsexactly once after they finish. - Rate limiting needs no library: pace a loop with
time.NewTickerfor a steady rate, or use a buffered channel as a token bucket to allow a burst before settling;defer t.Stop()to release the timer promptly. - Every goroutine must have an exit path; use
go.uber.org/goleakin tests to catch leaks automatically. GOMAXPROCSdefaults to the number of logical CPUs, and since Go 1.25 it also respects container cgroup CPU limits;go.uber.org/automaxprocsis only needed on older toolchains.
12.12 Exercises
Think about it: In Java, cancelling an in-flight operation typically means calling
Future.cancel(true)or interrupting a thread viaThread.interrupt(). Describe how Go’scontext.Contextmodel differs from Java’s thread-interrupt approach. What are the advantages of passing a context explicitly rather than relying on a thread-level interrupt mechanism? Consider what happens when a Java thread is blocked in a third-party library that does not handleInterruptedException, compared to how a Go function using a context-aware library would behave.What does this print?
package main import ( "context" "fmt" "time" ) func work(ctx context.Context, label string) { select { case <-time.After(500 * time.Millisecond): fmt.Println(label, "done") case <-ctx.Done(): fmt.Println(label, "cancelled:", ctx.Err()) } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() go work(ctx, "Flaming June") go work(ctx, "Saltwater") time.Sleep(400 * time.Millisecond) fmt.Println("main done") }Calculation: You run a worker pool with
workers = 3and feed it a slice of 7 tasks. Each task takes exactly 100 ms. Assuming no overhead and perfect parallelism, how many milliseconds does the pool take to complete all 7 tasks? Show your work: how many rounds of 3 concurrent workers are needed and what does each round contribute?Where is the bug?
package main import ( "context" "fmt" "time" ) func fetchData(url string) <-chan string { ch := make(chan string) go func() { time.Sleep(2 * time.Second) ch <- "result for " + url }() return ch } func main() { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() ch := fetchData("https://example.com/songs") select { case result := <-ch: fmt.Println(result) case <-ctx.Done(): fmt.Println("timed out") } }Write a program: Implement a function
fanOutFetch(ctx context.Context, songs []string) ([]string, error)that useserrgroupto fetch all song titles concurrently. Simulate each fetch with atime.Sleepof a random duration between 50 and 150 ms (usemath/rand). If any fetch takes longer than 300 ms total (enforced by a timeout on the context passed tofanOutFetch), the entire operation should be cancelled and an error returned. Print either all results in order or the cancellation error.What does this print?
package main import ( "context" "fmt" ) type ctxKey string func main() { const userKey ctxKey = "user" ctx := context.Background() ctx = context.WithValue(ctx, userKey, "ana") ctx = context.WithValue(ctx, ctxKey("user"), "beto") fmt.Println(ctx.Value(userKey)) fmt.Println(ctx.Value("user")) }