Files
teamai-test/.teamai/skills/common/golang-context/references/cancellation.md
T

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.