context: cancellation is an argument
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.
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 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)); }
// 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
- First parameter, named
ctx. Not a struct field, not a global, not last.func Do(ctx context.Context, ...). cancelis called, always.defer cancel()even when the work succeeded — otherwise the timer and the child goroutines live until the deadline.- 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.
- Values are for request-scoped metadata only (request ID, auth subject, trace ID). Not dependencies, not config,
not "just one more thing":
context.WithValueis untyped and every read can fail.
// 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
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)
}
}
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.Requestalready 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 witherrors.Is. - Add
defer cancel()to a loop that creates timeouts — then remove it and watch goroutines accumulate. - Write a
signal.NotifyContextshutdown that drains a 2-second handler without cutting it off.