PidokuInfra

Select, Context and Cancellation

Intermediate 55 min Difficulty 3/5 Topic 03 of 06

Prerequisites 02

The idea in one minute#

select waits on several channel operations at once and runs whichever is ready first. It is how a goroutine waits for work or a timeout or a stop signal.

context.Context is the standard way to carry that stop signal — plus a deadline and request-scoped values — down a call chain. A context forms a tree: cancelling a parent cancels every child. Each function that might block takes a ctx as its first parameter and gives up when ctx.Done() is closed.

A goroutine cannot be killed from outside. Cancellation in Go is cooperative: you ask, and well-written code listens.

An analogy#

A kitchen ticket with “cancelled” stamped on it. When a customer leaves, the waiter stamps the order; every cook working on a part of that order — the grill, the salad station, the person who went to the cellar for wine — checks the ticket at natural pauses and stops. Nobody is dragged away mid-chop. And an order stamped at the top is cancelled for everyone working under it.

A picture#

flowchart TB
  BG["context.Background()"] --> REQ["WithTimeout(2s)<br/>one incoming request"]
  REQ --> A["WithCancel<br/>retrieve documents"]
  REQ --> B["WithTimeout(500ms)<br/>call the model"]
  A --> A1["query shard 1"]
  A --> A2["query shard 2"]
  B --> B1["HTTP request<br/>net/http watches ctx.Done()"]
  X["client disconnects<br/>or 2 s pass"] -.->|"cancel"| REQ
  REQ -.->|"Done() closes for the whole subtree"| A
  REQ -.-> B
  class BG neutral
  class REQ,A,B queue
  class A1,A2,B1 compute
  class X warn

How it really works#

select#

Go
select {
case v := <-in:            // receive
    handle(v)
case out <- result:        // send
case <-ctx.Done():         // cancellation
    return ctx.Err()
case <-time.After(time.Second):   // timeout for this one wait
    return errTimeout
default:                   // optional: run if nothing else is ready (non-blocking)
}
  • If several cases are ready, one is chosen uniformly at random — so no case can starve the others, and you must not rely on order.
  • With no default, select blocks until a case is ready. With default, it never blocks.
  • A case on a nil channel is never ready: assign nil to switch a case off.
  • select {} blocks forever.
  • Internally select locks all the channels involved, checks each, and if none is ready enqueues the goroutine on all of them; the first to fire wakes it and it is removed from the rest.

context#

Go
type Context interface {
    Deadline() (time.Time, bool)
    Done() <-chan struct{}       // closed when cancelled or expired
    Err() error                  // nil, context.Canceled or context.DeadlineExceeded
    Value(key any) any
}
ConstructorCancels when
context.Background()Never. The root, used in main and tests
WithCancel(parent)You call cancel(), or the parent is cancelled
WithTimeout(parent, d) / WithDeadline(parent, t)Time runs out, cancel() is called, or the parent is cancelled
WithCancelCause(parent)As WithCancel, recording why; read it with context.Cause(ctx)
WithoutCancel(parent)Never — keeps values, drops cancellation (for work that must outlive the request)
WithValue(parent, key, v)Same as the parent; adds a value
signal.NotifyContext(parent, os.Interrupt)A signal arrives: graceful shutdown

Rules:

  1. ctx is the first parameter, named ctx. Do not store it in a struct.
  2. Always call cancel, usually defer cancel(). It releases the timer and the entry in the parent’s list of children; go vet reports a lost cancel.
  3. Check it where you block or loop. select on ctx.Done() beside channel operations; in a CPU loop, check ctx.Err() every so often.
  4. Pass it down. http.NewRequestWithContext, db.QueryContext, exec.CommandContext — the standard library stops work when the context ends.
  5. Values are for request-scoped metadata that crosses API boundaries — a trace ID, an authenticated user — not for optional parameters. Use an unexported key type so packages cannot collide.
  6. Return ctx.Err() (or wrap it) when you stop because of cancellation, so callers can test errors.Is(err, context.Canceled).

Deadlines add up#

A child’s deadline can only be earlier than its parent’s. Budgeting a request therefore composes naturally: the handler has 2 s, gives the model call 1.5 s of it, and the model client’s own 30 s default never matters. For a streaming LLM call, distinguish the time to first byte (a per-step timeout you reset) from the total (the context deadline).

Timers#

  • time.After(d) is convenient inside a select. Since Go 1.23 an unreferenced timer is collected promptly, so using it in a loop no longer leaks.
  • time.NewTimer with Reset for a reusable timeout; time.NewTicker for periodic work — and defer t.Stop().
  • context.AfterFunc(ctx, f) runs f when a context is cancelled — handy for unblocking something that does not take a context (closing a connection, for example).

In a server#

net/http gives every request a context (r.Context()) that is cancelled when the client disconnects or the handler returns. For an LLM service this is the most valuable cancellation there is: a user who closes the tab should stop consuming GPU. Thread r.Context() all the way into the generation loop and check it between tokens.

Code#

Go
// cancel.go — select, timeouts, and a context cancelling a whole tree of work.
package main

import (
	"context"
	"errors"
	"fmt"
	"runtime"
	"sync"
	"time"
)

// generate streams tokens until done or cancelled. It checks ctx between tokens.
func generate(ctx context.Context, n int, perToken time.Duration, out chan<- string) error {
	defer close(out)
	for i := 0; i < n; i++ {
		select {
		case <-time.After(perToken): // "compute" one token
		case <-ctx.Done():
			return context.Cause(ctx)
		}
		select {
		case out <- fmt.Sprintf("tok%d", i):
		case <-ctx.Done(): // the consumer went away while we were trying to send
			return context.Cause(ctx)
		}
	}
	return nil
}

// fanOut queries several "shards"; the first error cancels the rest.
func fanOut(ctx context.Context, delays []time.Duration, failAt int) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)
	var wg sync.WaitGroup
	for i, d := range delays {
		wg.Add(1)
		go func() {
			defer wg.Done()
			select {
			case <-time.After(d):
				if i == failAt {
					cancel(fmt.Errorf("shard %d failed", i))
					return
				}
				fmt.Printf("  shard %d finished\n", i)
			case <-ctx.Done():
				fmt.Printf("  shard %d stopped early: %v\n", i, context.Cause(ctx))
			}
		}()
	}
	wg.Wait()
	return context.Cause(ctx)
}

func main() {
	// 1. A deadline stops a stream part-way.
	ctx, cancel := context.WithTimeout(context.Background(), 55*time.Millisecond)
	out := make(chan string)
	errc := make(chan error, 1)
	go func() { errc <- generate(ctx, 100, 10*time.Millisecond, out) }()
	n := 0
	for range out {
		n++
	}
	err := <-errc
	cancel()
	fmt.Printf("stream: got %d tokens, then: %v (deadline exceeded? %v)\n", n, err, errors.Is(err, context.DeadlineExceeded))

	// 2. The consumer leaves; the producer notices and exits instead of leaking.
	ctx, cancel = context.WithCancel(context.Background())
	out = make(chan string)
	go func() { errc <- generate(ctx, 100, time.Millisecond, out) }()
	<-out
	<-out
	cancel() // "the client disconnected"
	fmt.Println("after client disconnect:", <-errc)

	// 3. One failure cancels its siblings.
	fmt.Println("fan-out:")
	err = fanOut(context.Background(), []time.Duration{5 * time.Millisecond, 20 * time.Millisecond, 200 * time.Millisecond}, 1)
	fmt.Println("  result:", err)

	// 4. A child cannot outlive its parent's deadline.
	parent, cancelP := context.WithTimeout(context.Background(), 20*time.Millisecond)
	child, cancelC := context.WithTimeout(parent, time.Hour)
	dl, _ := child.Deadline()
	fmt.Printf("child asked for 1h; effective deadline is in %v\n", time.Until(dl).Round(time.Millisecond))
	cancelC()
	cancelP()

	time.Sleep(10 * time.Millisecond)
	fmt.Println("goroutines still running:", runtime.NumGoroutine(), "(only main: nothing leaked)")
}

Remember this#

  • select waits on many channel operations; ready cases are chosen at random; default makes it non-blocking.
  • Cancellation is cooperative. A context is a tree: cancel a parent and its whole subtree stops.
  • ctx first, always defer cancel(), check Done() wherever you block or loop.
  • In a server, the request context ends when the client disconnects — carry it into the work.

Try it#

  1. Run cancel.go. In generate, delete the second select and send with a plain out <- .... Which scenario now leaks a goroutine, and why?
  2. Add a per-token timeout to the consumer: give up if no token arrives within 30 ms, even though the overall deadline has not passed.
  3. Write an HTTP handler that streams numbers once per 100 ms and stops when r.Context() is done. Disconnect with curl and confirm the handler exits.

Check yourself#

  1. What does select do when two cases are ready?
  2. Why must cancel always be called?
  3. What should a function return when it stops because its context was cancelled?

↑↓ navigate↵ openesc close