Модуль: Concurrency · Уровень: Senior

TL;DR#

context.Context переносит сигнал отмены, дедлайн и request-scoped значения сквозь границы вызовов и горутин. Корни — Background()/TODO(); производные — WithCancel/WithTimeout/WithDeadline/WithValue. Отмена распространяется по дереву контекстов вниз; всегда вызывайте cancel() (обычно через defer), чтобы не текли ресурсы.

Теория#

Иерархия и корни#

  • context.Background() — пустой корень для main, init, тестов; никогда не отменяется.
  • context.TODO() — заглушка, когда непонятно, какой контекст передать; семантически = Background, но сигнализирует «надо доделать».
  • Производные образуют дерево: отмена/дедлайн родителя каскадно отменяет детей.
ctx, cancel := context.WithCancel(parent)
defer cancel() // ОБЯЗАТЕЛЬНО: освобождает горутину/таймер и удаляет из дерева

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

ctx, cancel := context.WithDeadline(parent, time.Now().Add(time.Second))
defer cancel()

ctx := context.WithValue(parent, key, val) // без cancel

Интерфейс#

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}      // закрывается при отмене/дедлайне
    Err() error                 // nil | Canceled | DeadlineExceeded
    Value(key any) any
}

Done() возвращает канал, который закрывается (broadcast) при отмене. Err() после закрытия даёт причину: context.Canceled или context.DeadlineExceeded.

Под капотом#

  • cancelCtx хранит done (lazily созданный канал), children map[canceler]struct{}, err. cancel() закрывает done, проставляет err, рекурсивно отменяет детей и отсоединяет себя от родителя (removeChild), чтобы не было утечки в дереве.
  • timerCtx — это cancelCtx + time.Timer, который вызовет cancel(DeadlineExceeded) по дедлайну.
  • valueCtx — связный список: Value(key) идёт вверх по цепочке родителей, сравнивая ключи. O(глубины), поэтому много значений — антипаттерн.
  • Propagation отмены: при создании дочернего cancelCtx рантайм проверяет, отменяем ли родитель; если да — регистрируется в children, иначе при необходимости запускает горутину propagateCancel, слушающую parent.Done().

Распространение#

Контекст передают первым аргументом ctx context.Context во все функции, делающие I/O, RPC, долгую работу. Каждая горутина в дереве запроса получает (производный) ctx и обязана реагировать на ctx.Done().

func handle(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
    return callRPC(ctx)
}

func worker(ctx context.Context, in <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return // отмена / дедлайн / завершение
        case job := <-in:
            process(ctx, job)
        }
    }
}

Значения (WithValue) — best practices#

  • Только request-scoped данные: trace/request ID, аутентификация, дедлайны логирования. Не для передачи опциональных параметров функции.
  • Ключ — неэкспортируемый тип, чтобы избежать коллизий:
type ctxKey int
const userKey ctxKey = 0
ctx = context.WithValue(ctx, userKey, user)
u, ok := ctx.Value(userKey).(*User)

Go 1.21+: WithoutCancel, AfterFunc, WithDeadlineCause, Cause#

  • context.WithoutCancel(ctx) — наследует значения, но игнорирует отмену родителя (для фоновых задач, переживающих запрос).
  • context.AfterFunc(ctx, f) — вызывает f в своей горутине при отмене ctx; возвращает stop-функцию.
  • context.WithTimeoutCause/WithDeadlineCause + context.Cause(ctx) — даёт явную причину отмены вместо общего DeadlineExceeded.
  • context.WithCancelCausecancel(err) с произвольной причиной, доступной через Cause.

Подводные камни / gotchas#

  • Не вызвали cancel() → утечка: для WithCancel остаётся регистрация в дереве/горутина propagate; для WithTimeout — живой таймер до дедлайна. go vet ловит часть случаев (lostcancel).
  • Хранение Context в структуре — антипаттерн (кроме явных исключений). Передавайте как аргумент.
  • context.WithValue для бизнес-параметров — типонебезопасно и непрозрачно; используйте явные аргументы.
  • Строковые/встроенные ключи в WithValue → коллизии между пакетами. Только приватные типы.
  • Игнорирование ctx.Done() в долгой работе делает отмену бесполезной — операция всё равно доработает до конца.
  • Передача nil Context — паника/UB; используйте TODO().
  • ctx.Err() без проверки Done в редких случаях гонко: правильно — читать после получения из Done() либо доверять select.
  • Дедлайн короче родительского работает; длиннее — игнорируется (родительский дедлайн всё равно отменит раньше).

Вопросы на собеседовании#

В: Зачем нужен context? О: Для сквозной передачи сигнала отмены, дедлайна и request-scoped значений через границы горутин и вызовов. Это стандартный способ скоординированно завершать дерево операций (RPC, БД, фоновые задачи) при таймауте, отмене клиентом или shutdown.

В: Разница Background и TODO? О: Семантически идентичны (пустой неотменяемый корень). Background — для входных точек (main, тесты, верх стека); TODO — маркер «контекст ещё не проброшен/неясен», помогает статическому анализу и ревью.

В: Что возвращает Done() и когда срабатывает? О: Канал <-chan struct{}, который закрывается при отмене или дедлайне (broadcast всем слушателям). После закрытия Err() возвращает Canceled или DeadlineExceeded.

В: Почему обязательно вызывать cancel? О: cancel закрывает done, отменяет детей и отвязывает контекст от родителя; для timer-контекстов останавливает таймер. Без него — утечка горутины/таймера и рост дерева контекстов. go vet предупреждает о потерянном cancel.

В: Как распространяется отмена по дереву? О: При отмене узла закрывается его done, затем рекурсивно отменяются все зарегистрированные дети. Дочерний cancelCtx либо регистрируется напрямую в родителе, либо (если родитель не «родной» cancelCtx) обслуживается горутиной propagateCancel, слушающей parent.Done().

В: Как правильно класть значения? О: Только request-scoped данные, с приватным типом ключа, чтобы исключить коллизии. Не использовать для передачи обычных аргументов. Доступ — type assertion с проверкой ok. Помнить про O(глубины) при чтении.

В: Как сделать фоновую задачу, переживающую запрос? О: context.WithoutCancel(ctx) сохранит значения (trace id и т.п.), но отвяжет от отмены родителя; либо новый Background() с переносом нужных значений. Для запуска кода по отмене — context.AfterFunc.

В: Чем отличается Canceled от DeadlineExceeded и что такое Cause? О: Canceled — явный вызов cancel; DeadlineExceeded — истёк дедлайн. С 1.21 WithCancelCause/WithTimeoutCause позволяют задать произвольную причину, читаемую context.Cause(ctx), при этом Err() остаётся стандартным.

На что копают на senior+#

  • Устройство cancelCtx/timerCtx/valueCtx, lazy-создание done, removeChild и почему это предотвращает утечку дерева.
  • propagateCancel: когда запускается отдельная горутина-наблюдатель и её стоимость.
  • Семантика Cause/WithoutCancel/AfterFunc (1.21) и их применение.
  • Корректная обработка отмены в библиотечном коде (не «глотать» ctx.Err, прокидывать дедлайн в нижележащие вызовы).
  • Взаимодействие context с errgroup, http.Server shutdown, database/sql (ctx в каждом запросе).
  • Почему хранение ctx в структуре ломает контракт и когда это всё же допустимо (например, в http.Request).