gopherpath
0%

context: cancellation is an argument

35 min · lesson 3 of 4 · #concurrency · #context

Go
func (s *Service) Charge(ctx context.Context, id string, amount Money) error

ctx is the first parameter of almost every function that does I/O. It carries a deadline, a cancellation signal, and (rarely) request-scoped values. It is an argument because Go has no thread-local storage and no interruption mechanism — cancellation must be explicit in the call graph.

Go
package main

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

func slowWork(ctx context.Context, d time.Duration) (string, error) {
	select {
	case <-time.After(d):
		return "finished", nil
	case <-ctx.Done(): // deadline exceeded, or parent cancelled
		return "", ctx.Err() // context.DeadlineExceeded / context.Canceled
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer cancel() // ALWAYS: releases the timer and unblocks children

	if _, err := slowWork(ctx, 100*time.Millisecond); err != nil {
		fmt.Println("call failed:", err)
		fmt.Println("is deadline:", errors.Is(err, context.DeadlineExceeded))
	}

	ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second)
	defer cancel2()
	out, err := slowWork(ctx2, 5*time.Millisecond)
	fmt.Println(out, err)
}
Java
// Java has no context object: deadlines live on the client (HttpClient timeout),
// cancellation lives on the Future, and thread-local "context" is a static hazard.
var future = executor.submit(() -> slowWork());
try { future.get(30, TimeUnit.MILLISECONDS); }
catch (TimeoutException e) { future.cancel(true); }   // interrupt flag, cooperative at best

// Structured concurrency (JEP 453 preview) is the closest idea:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var t = scope.fork(() -> slowWork()); scope.joinUntil(Instant.now().plusMillis(30)); }
TypeScript
// AbortController is the direct analogue and is spreading through Node/browser APIs
const c = new AbortController();
setTimeout(() => c.abort(), 30);
await fetch(url, { signal: c.signal });   // rejects with AbortError

The four rules

  1. First parameter, named ctx. Not a struct field, not a global, not last. func Do(ctx context.Context, ...).
  2. cancel is called, always. defer cancel() even when the work succeeded — otherwise the timer and the child goroutines live until the deadline.
  3. Never store a context in a struct. If a type needs one, it needs the value passed in per call. The exception is short-lived request structs inside a handler — and even then, pass it.
  4. Values are for request-scoped metadata only (request ID, auth subject, trace ID). Not dependencies, not config, not "just one more thing": context.WithValue is untyped and every read can fail.
Go
// The typed-key pattern: unexported key type prevents collisions across packages
type ctxKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, ctxKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(ctxKey{}).(string)
	return id, ok
}

Graceful shutdown is context's real payoff

Go
func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("serve", "err", err)
		}
	}()

	<-ctx.Done() // SIGTERM or Ctrl-C
	slog.Info("shutting down")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil { // stop accepting, drain in-flight
		slog.Error("shutdown", "err", err)
	}
}
Java
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    server.stop(15);   // graceful: stop accepting, wait for requests
}));
// Spring Boot: server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase

Both stop accepting and drain; the difference is that in Go the signal is the cancellation of every in-flight request's context, so handlers that respect ctx stop work immediately instead of finishing a doomed job.

Trap: http.Request already carries a context. r.Context() is cancelled when the client disconnects — passing it to a database call means a slow client aborts your query, which is usually what you want and occasionally a surprise in background jobs. Background work belongs to a context you own, not the request's.

Proof checkpoint

  • Wrap a 100 ms operation in a 30 ms deadline and print ctx.Err(); then do it two levels deep and prove the error survives with errors.Is.
  • Add defer cancel() to a loop that creates timeouts — then remove it and watch goroutines accumulate.
  • Write a signal.NotifyContext shutdown that drains a 2-second handler without cutting it off.
stored in this browser only