Context, cancellation and concurrency patterns
context.Context and deadlines, errgroup, worker pools, fan-in and fan-out, select with defaults, sync primitives, and the race detector.
Context and deadlines
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // always cancel, even on the success path
results := make([]string, 0, len(urls))
for _, u := range urls {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("timed out fetching %s: %w", u, err)
}
return nil, err
}
resp.Body.Close()
results = append(results, u)
}
return results, nil
}
// request-scoped values: keys are unexported types, never strings
type ctxKey struct{}
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}ctxis the first parameter by convention, and it is never stored in a struct or passed asnil— usecontext.Background()orcontext.TODO()at the top.- Cancellation is advisory: it closes
ctx.Done(), and it is the function's job to notice. A loop that never checks the context runs to completion regardless. - Always call the
cancelfunction, even when the work succeeded, so the timer and the parent-child link are released. WithValueis for request metadata such as a trace id or an auth principal, not for optional parameters. If the value is required for the function to work, it belongs in the signature.- Cancelling the parent cancels every derived context; cancelling a child never affects the parent.
Worker pools, fan-out and errgroup
// errgroup: a WaitGroup that stops the group on the first error
func process(ctx context.Context, jobs []Job) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // bounded concurrency: backpressure included
for _, j := range jobs {
j := j // not needed since Go 1.22, kept for clarity
g.Go(func() error {
return handle(ctx, j) // returning an error cancels the group
})
}
return g.Wait() // the first non-nil error
}
// fan-out to N workers, fan-in to one channel
func squares(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for w := 0; w < 4; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case n, ok := <-in:
if !ok {
return
}
select {
case out <- n * n:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
}
go func() { wg.Wait(); close(out) }() // exactly one closer
return out
}| Tool | Reach for it when |
|---|---|
sync.WaitGroup | You just need to wait for a fixed set of goroutines, with no error and no limit |
errgroup.Group | The tasks can fail, and one failure should stop the rest — the usual case for parallel I/O |
errgroup.SetLimit(n) | You need a worker pool without writing one; it caps goroutines and blocks the producer |
semaphore.Weighted | The limit is a resource such as bandwidth or memory, not a task count |
sync.Mutex / RWMutex | Small amounts of genuinely shared mutable state, such as a cache map |
atomic | A counter or a flag, where a lock would be heavier than the operation |
- Unbuffered channels give you a handshake and natural backpressure; buffered channels give you throughput at the cost of memory and less obvious blocking.
- Close a channel from the sender side, and only after every sender is done — a
sync.Onceor a dedicated waiter goroutine is the standard idiom. - A
selectwithdefaultis a non-blocking poll, useful for draining or for "try to hand this off, otherwise keep the item and retry later".
Shared state and the race detector
type Cache struct {
mu sync.RWMutex
m map[string][]byte
}
func (c *Cache) Get(k string) ([]byte, bool) {
c.mu.RLock() // many readers at once
defer c.mu.RUnlock()
v, ok := c.m[k]
return v, ok
}
func (c *Cache) Set(k string, v []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}
// sync.Once for lazy initialisation, safe under concurrency
var (
once sync.Once
client *http.Client
)
func httpClient() *http.Client {
once.Do(func() {
client = &http.Client{Timeout: 10 * time.Second}
})
return client
}Run every test twice before believing a concurrent design: once normally and once with go test -race -count=1 ./.... The detector reports both conflicting accesses with their stacks, which converts a rare corrupted value into a reproducible bug report.
⚠️
A data race is undefined behaviour, not a race you might win. Reading a map while another goroutine writes it can crash the process outright, and a race on a plain field can produce a value no goroutine ever wrote. If the detector reports it, fix it — do not add a sleep and hope.
FAQ
Why does errgroup cancel my other tasks?
errgroup.WithContext derives a context that is cancelled as soon as any goroutine returns an error, and Wait returns the first error. If the tasks are independent, use a plain WaitGroup and collect errors yourself.Mutex or channel?
Use a mutex to protect state that many goroutines read and write, such as a cache. Use channels when ownership of data passes from one goroutine to another, or when you need to wait, time out or select. Channels are built on locks, so neither is faster in the abstract.
Related
Building HTTP services Errors, defer and panic
Last refreshed 2026-09-18.