5.2 KiB
Cancellation, Timeouts & Deadlines
Cancellation
context.WithCancel returns a derived context and a cancel function. When cancel() is called, the context's Done() channel is closed, signaling all listeners to stop.
func processItems(ctx context.Context, items []Item) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // always defer cancel to free resources
errCh := make(chan error, len(items))
for _, item := range items {
go func(item Item) {
errCh <- processOne(ctx, item)
}(item)
}
for range items {
if err := <-errCh; err != nil {
cancel() // cancel remaining goroutines on first error
return fmt.Errorf("processing items: %w", err)
}
}
return nil
}
Why defer cancel() matters
Every WithCancel, WithTimeout, and WithDeadline creates cancellation state; timeout/deadline contexts also use timer resources. cancel() MUST be called on all control-flow paths unless the function explicitly returns or transfers ownership of both the context and cancel function. In ordinary scoped work, defer cancel immediately.
// ✗ Bad — cancel is never called, resources leak
func fetch(ctx context.Context) error {
ctx, _ = context.WithTimeout(ctx, 5*time.Second)
return doWork(ctx)
}
// ✓ Good — scoped work, defer cancel immediately
func fetch(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return doWork(ctx)
}
Timeouts and Deadlines
context.WithTimeout — relative duration
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return s.repo.FindByID(ctx, id)
}
context.WithDeadline — absolute point in time
func (s *BatchService) ProcessBatch(ctx context.Context, batch Batch) error {
// The batch must complete by its SLA deadline
ctx, cancel := context.WithDeadline(ctx, batch.SLADeadline)
defer cancel()
for _, item := range batch.Items {
if err := s.process(ctx, item); err != nil {
return fmt.Errorf("processing batch item %s: %w", item.ID, err)
}
}
return nil
}
Nested timeouts take the shorter deadline
If a parent context has a 5s timeout and you create a child with 10s, the child still expires at 5s. The shorter deadline always wins.
// Parent has 2s timeout — child's 10s is effectively ignored
parentCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
defer childCancel()
// childCtx expires after 2s, not 10s
Listening for Cancellation
The select pattern
Use ctx.Done() in a select statement to react to cancellation alongside other work:
func poll(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
case <-ticker.C:
if err := doWork(ctx); err != nil {
return fmt.Errorf("polling: %w", err)
}
}
}
}
Checking cancellation in loops
For CPU-bound work, periodically check ctx.Err():
func processLargeDataset(ctx context.Context, items []Item) error {
for i, item := range items {
if ctx.Err() != nil {
return fmt.Errorf("processing interrupted after %d/%d items: %w", i, len(items), ctx.Err())
}
process(item)
}
return nil
}
context.AfterFunc (Go 1.21+)
Registers a callback that runs in its own goroutine when the context is cancelled. Useful for cleanup without blocking the main flow.
func watchResource(ctx context.Context, res *Resource) {
stop := context.AfterFunc(ctx, func() {
// Runs in a new goroutine when ctx is cancelled
res.Release()
})
// If you no longer need the callback, cancel it:
// stop() returns true if the callback was successfully cancelled
_ = stop
}
context.WithoutCancel (Go 1.21+)
Creates a child context that is not cancelled when the parent is. Use this for background work that must continue after the request completes — like async logging, audit trails, or enqueuing follow-up tasks.
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
order, err := h.orderService.Create(ctx, req)
if err != nil {
// handle error
return
}
// Audit log must complete even if the client disconnects.
// WithoutCancel preserves context values (trace_id) but detaches cancellation.
auditCtx := context.WithoutCancel(ctx)
go h.auditService.LogOrderCreated(auditCtx, order)
w.WriteHeader(http.StatusCreated)
}
Without WithoutCancel, you'd have to choose between ctx (which gets cancelled when the handler returns, killing your background work) and context.Background() (which loses trace_id and other values). WithoutCancel gives you the best of both: values are preserved, but cancellation is detached.