Skip to content
JavaGo
Part IV · Concurrency

Context

context.Context carries cancellation, deadlines and request-scoped values through every call. It replaces interruption, timeouts and ThreadLocal.

Section 18 of 31 8 min read

In Java, cancellation, timeouts and request-scoped data travel through several mechanisms: Thread.interrupt(), Future.cancel(), per-library timeout settings, and ThreadLocal/MDC for request data. Go has no thread-locals, because goroutines have no identity you can attach data to. Instead there is one explicit value, context.Context, passed as the first argument to every function that blocks, does I/O or calls another service.

What a context carries

A cancellation signal: ctx.Done() returns a channel that is closed when the work should stop, and ctx.Err() says why.

An optional deadline: a point in time after which the context cancels itself.

Request-scoped values: trace IDs, the authenticated principal. Narrow and deliberate, not a general-purpose bag.

Contexts form a tree. Deriving a child adds a deadline or a value; cancelling a parent cancels every child beneath it, but never the other way round.

Where contexts come from

root contexts and inherited ones
// The root: never cancelled, no deadline, no values.
// Create it at the top of the call tree: main, tests, jobs.
ctx := context.Background()

// A placeholder while refactoring: "a real context should
// be passed in here, I haven't wired it up yet".
ctx = context.TODO()

// In an HTTP handler: cancelled when the client disconnects
// or when the handler returns.
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    ...
}

// In a test (Go 1.24+): cancelled when the test finishes
func TestGet(t *testing.T) {
    ctx := t.Context()
    ...
}

Cancelling work

Java — Future.cancel()
Future<Report> f = executor.submit(() -> build(id));

// Later: best-effort interrupt of the task's thread.
// Libraries that don't check interruption keep running.
f.cancel(true);
Go — context.WithCancel
ctx, cancel := context.WithCancel(parent)
defer cancel() // always: releases resources on every path

go func() {
    report, err := build(ctx, id) // build passes ctx on down
    ...
}()

// Later: every goroutine and call using ctx sees Done() close
cancel()

Each derived context has a parent

The timeout fires

Fig. 18.1Every derived context has a parent, so a request's contexts form a tree under the root. Cancelling one, here because its timeout fires, closes Done() for it and everything derived from it, while its parent and siblings carry on. (WithoutCancel, below, is the deliberate exception.)

Timeouts and deadlines

WithTimeout: a deadline relative to now
func (s *Store) Find(ctx context.Context, id string) (*Message, error) {
    // Give this query at most 2s, and less if the caller's
    // own deadline is sooner: a child never outlives its parent.
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    row := s.db.QueryRowContext(ctx, "SELECT body FROM messages WHERE id = $1", id)
    var m Message
    if err := row.Scan(&m.Body); err != nil {
        // context.DeadlineExceeded if our 2s elapsed,
        // context.Canceled if the caller gave up first
        return nil, fmt.Errorf("find message %s: %w", id, err)
    }
    return &m, nil
}

// WithDeadline takes an absolute time instead:
//   ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
Java — timeouts per call site
CompletableFuture<Result> f = fetchData(id)
    .orTimeout(5, TimeUnit.SECONDS);

// The future fails after 5s, but the JDBC query and HTTP
// call inside fetchData keep running: each library needs
// its own timeout configured separately.
Go — one deadline for the whole request
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()

msg, err := svc.Find(ctx, id)
// When the 5s budget runs out:
//   1. the database driver aborts the query
//   2. outgoing HTTP calls using ctx are cancelled
//   3. Find returns an error wrapping context.DeadlineExceeded
//   4. the handler can respond 504 Gateway Timeout

Checking for cancellation in your own loops

long CPU-bound work should check ctx periodically
func processBatch(ctx context.Context, items []Item) error {
    for _, item := range items {
        // Cheap, non-blocking check between units of work
        if err := ctx.Err(); err != nil {
            return err
        }
        if err := processItem(ctx, item); err != nil {
            return err
        }
    }
    return nil
}

Work that must outlive the request

Sometimes a handler starts work that should finish even after the response is sent or the client disconnects, such as writing an audit record. r.Context() is cancelled as soon as the handler returns, so that work must detach from it. context.WithoutCancel (Go 1.21+) returns a context that keeps the parent’s values (trace IDs and so on) but is never cancelled and has no deadline. Give that work its own timeout so it cannot run forever.

detach from the request, but keep its values
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    order, err := h.orders.Create(r.Context(), parseOrder(r))
    if err != nil {
        http.Error(w, "could not create order", http.StatusInternalServerError)
        return
    }

    // Survives the handler returning; still carries trace IDs.
    auditCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
    go func() {
        defer cancel()
        if err := h.audit.Record(auditCtx, order); err != nil {
            slog.ErrorContext(auditCtx, "audit failed", "order", order.ID, "err", err)
        }
    }()

    w.WriteHeader(http.StatusCreated)
}

Request-scoped values

Java — MDC / ThreadLocal
// Tied to the current thread
MDC.put("requestId", requestId);
try {
    chain.doFilter(req, res);
} finally {
    MDC.remove("requestId"); // or it leaks to the next request
}

String id = MDC.get("requestId"); // anywhere on this thread
Go — context.WithValue
// An unexported key type: no other package can collide with it
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
}

// Middleware: ctx := WithRequestID(r.Context(), newID())
//             next.ServeHTTP(w, r.WithContext(ctx))

Conventions

ctx is the first parameter, and never a struct field
// Correct: ctx first, named ctx
func (s *Service) FindByID(ctx context.Context, id string) (*Message, error)

// Wrong: a context stored in a struct outlives the call it was for
type Service struct {
    ctx context.Context // don't
}

// Also wrong: passing nil. Use context.TODO() if you have nothing better.

Context vs ThreadLocal

ThreadLocal works in Spring MVC because one thread handles a request from start to finish. It breaks as soon as work hops threads: async executors, reactive pipelines, and, with virtual threads, it becomes costly at scale. Java 25’s ScopedValue is the JDK’s answer. Go chose explicit passing from the start: the data travels with the logical operation, visible in every signature, and the same ctx carries cancellation.

↑ ↓ to navigateEnter to openEsc to close