Контекст в Go: отмена, дедлайны, values и анти-паттерны

context.Context — единый механизм отмены, дедлайнов и передачи request-scoped данных сквозь цепочку вызовов. Как отмена распространяется по дереву горутин, почему context идёт первым аргументом и не хранится в структуре, чем опасен WithValue и как не потерять отмену — на живом стенде

Горутина, которую запустили, но не сказали, когда остановиться, — это утечка, ждущая своего часа. HTTP-клиент отключился, а сервер всё ещё считает ответ; таймаут запроса истёк, а поход в базу продолжается; процесс получил сигнал завершения, а фоновый воркер об этом не знает. Всем этим сценариям нужен один и тот же механизм: способ сказать вниз по цепочке вызовов «работу можно прекращать» — и способ услышать это в любой горутине, на любой глубине. В Go этот механизм — context.Context.

Контекст решает три задачи разом: отмена (сигнал «прекращай»), дедлайны (та же отмена, но по времени) и передача request-scoped значений сквозь границы вызовов, не протаскивая их отдельным аргументом через каждый уровень. Всё это — через один интерфейс, который передаётся первым параметром и течёт по дереву горутин. Статья разбирает сам механизм: как устроена отмена, почему она распространяется вниз и не вверх, почему контекст не хранят в структуре и чем опасен WithValue.

Всё заземлено на живой стенд digital-cookbook/go-context/: инварианты отмены и values покрыты тестами (go test ./... зелёный), а поведение во времени — утечка горутины и распространение отмены — вынесено в запускаемые демо cmd/*. Чистый Go, только stdlib, без зависимостей. Проверено на go1.26.3 (модуль объявляет минимум go 1.25.0). Контекст внутри горутин и каналов затрагивает серия про конкурентность — здесь он разобран отдельно и глубже, как самостоятельный механизм.

Схема «Полдень»: отмена контекста — сигнал cancel от корня Background распространяется вниз по дереву (WithCancel/WithTimeout → воркеры, все «Done: context canceled»), самый ранний дедлайн выигрывает; плохой случай — утечка горутины, висящей на канале, хороший — выход по ctx.Done(); значение передаётся по пути запроса через Value(key)

В статье

Зачем нужен context

До появления context каждый пакет изобретал отмену по-своему: done chan struct{}, флаг под мьютексом, отдельный таймер. Проблема не в том, что это не работало, — а в том, что механизмы не стыковались. HTTP-хендлер не мог сказать вызванной им функции работы с базой «клиент ушёл, прекращай», если та ждала на своём chan. context.Context — это стандартизированный протокол отмены, единый для всей экосистемы: net/http, database/sql, gRPC, драйверы — все говорят на нём.

Интерфейс намеренно узкий — четыре метода:

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

context.Context — это интерфейс, а не структура: конкретные реализации (cancelCtx, timerCtx, valueCtx) внутри пакета скрыты, наружу торчит только контракт. Ключевой из четырёх методов — Done(): он возвращает канал, который закрывается в момент отмены. Закрытый канал в select немедленно готов к чтению — на этом и держится вся механика. Горутина, желающая быть отменяемой, слушает <-ctx.Done() в select наряду со своей полезной работой; как только канал закрылся, ctx.Err() расскажет, почему.

Две тонкости из контракта, которые важны на этом уровне. Первая: Done() может вернуть nil — для контекста, который никогда не отменяется (именно так у Background()/TODO()). Чтение из nil-канала блокируется навсегда, так что select с веткой case <-ctx.Done() на таком контексте просто никогда её не выберет — это корректно, а не ошибка. Вторая: закрытие канала Done асинхронноcancel() возвращает управление, не дожидаясь, пока все слушатели увидят закрытие. Поэтому нельзя предполагать, что сразу после cancel() каждая горутина уже остановилась; если нужна такая гарантия, синхронизируйтесь отдельно (sync.WaitGroup, канал завершения).

Два корневых контекста, с которых всё начинается, — context.Background() (пустой корень для main, инициализации, тестов) и context.TODO() (тот же пустой контекст-заглушка, но помечающий место, где контекст ещё предстоит пробросить). Ни один из них никогда не отменяется сам по себе — отмену добавляют производные контексты.

Три источника отмены

Отменяемый контекст создаётся из родителя одной из трёх функций. Каждая возвращает производный контекст и CancelFunc — функцию, которую обязательно нужно вызвать (обычно через defer), чтобы освободить ресурсы, даже если отмена уже произошла по другой причине.

  • context.WithCancel(parent) — ручная отмена: вызвал cancel() — контекст отменён.
  • context.WithTimeout(parent, d) — отмена через промежуток времени d.
  • context.WithDeadline(parent, t) — отмена в конкретный момент t.

Различить причину отмены позволяет ctx.Err(). Инвариант, зафиксированный тестами стенда, прост: ручная отмена даёт context.Canceled, истечение времени — context.DeadlineExceeded. Ядро отмены на стенде — функция, которая блокируется до отмены и возвращает её причину:

// WaitDone блокируется, пока контекст не будет отменён, и возвращает
// ctx.Err(): context.Canceled при ручной отмене, context.DeadlineExceeded
// при истечении дедлайна/таймаута.
func WaitDone(ctx context.Context) error {
	<-ctx.Done()
	return ctx.Err()
}

Проверять ctx.Err() нужно после того, как Done() закрылся: пока контекст жив, Err() возвращает nil. WithTimeout — это буквально сахар над WithDeadline(parent, time.Now().Add(d)), так что оба выдают один и тот же context.DeadlineExceeded. Разница только в форме задания момента: относительно «сейчас» или абсолютно.

Отмена течёт вниз по дереву

Контексты образуют дерево: каждый производный контекст помнит родителя. Отмена распространяется строго в одном направлении — вниз: отмена родителя закрывает Done() у всех потомков, но отмена ребёнка родителя не трогает. Это тоже инвариант, покрытый тестом:

// ChildInheritsCancel строит дочерний контекст от родителя и возвращает
// оба Done-канала. Отмена родителя закрывает и дочерний Done — отмена
// распространяется вниз по дереву, но не вверх.
func ChildInheritsCancel(parent context.Context) (child context.Context, cancelChild context.CancelFunc) {
	return context.WithCancel(parent)
}

Практический смысл этого виден в демо cmd/propagate: от корневого контекста запускаются три стадии обработки (fetch, decode, index), каждая — со своим дочерним контекстом. Один cancel() у корня гасит всё поддерево сразу.

func stage(ctx context.Context, name string, wg *sync.WaitGroup, log chan<- string) {
	defer wg.Done()
	<-ctx.Done()
	log <- fmt.Sprintf("  [%s] остановлена: %v", name, ctx.Err())
}

func main() {
	root, cancel := context.WithCancel(context.Background())
	// ... три стадии, каждая наследует отмену от общего корня root ...
	cancel() // одна точка отмены гасит fetch, decode, index разом
}

Именно так отмена работает в реальном сервере: у входящего HTTP-запроса есть корневой контекст, все порождённые им обращения к базе, кэшу, внешним API получают производные контексты. Отключился клиент или сработал таймаут запроса — одна отмена наверху погасила всю цепочку, и ни одна горутина не осталась висеть на бесполезной работе. Про контекст HTTP-запроса (r.Context(), отмена при разрыве соединения, ReadHeaderTimeout) — в статье про net/http.

Самый ранний дедлайн выигрывает

Вложенность дедлайнов не аддитивна и не мультипликативна: действует самый ранний дедлайн во всей цепочке. Дочерний контекст не может «продлить» родителя — если у родителя дедлайн через 2 секунды, а ребёнку задать таймаут 10 секунд, ребёнок всё равно отменится через 2. Стенд проверяет это через child.Deadline():

// FirstDeadline демонстрирует, что при вложенных дедлайнах срабатывает
// самый ранний: дочерний контекст не может «продлить» родительский.
func FirstDeadline(parent context.Context, childTimeout time.Duration) (time.Time, bool) {
	child, cancel := context.WithTimeout(parent, childTimeout)
	defer cancel()
	return child.Deadline()
}

Если родитель уже с дедлайном, а childTimeout больше оставшегося времени родителя, child.Deadline() вернёт родительский, более ранний момент. Это логично: контекст описывает верхнюю границу «сколько ещё можно работать», и потомок не вправе выдать себе больше, чем разрешено предку.

Утечка горутины без отмены

Зачем всё это на практике — лучше всего показывает то, что случается без контекста. Горутина-производитель, которая шлёт значения в канал и не знает про отмену, зависает навсегда, как только потребитель перестал читать. Небуферизованный send блокируется, пока кто-то не прочитает, — а читать уже некому. Горутина висит до конца процесса, удерживая свой стек и всё, что захватила. Демо cmd/leak ставит это рядом: «наивный» воркер против «вежливого».

// leakyWorker шлёт значения в out и не знает про отмену. Если читатель
// перестал читать, отправка в небуферизованный канал блокируется навсегда.
func leakyWorker(out chan<- int) {
	i := 0
	for {
		out <- i // некому читать → блокировка навсегда
		i++
	}
}

// politeWorker умеет останавливаться: на каждой итерации проверяет ctx.Done().
func politeWorker(ctx context.Context, out chan<- int) {
	i := 0
	for {
		select {
		case <-ctx.Done():
			return // отменили — выходим, горутина освобождается
		case out <- i:
			i++
		}
	}
}

Разница — одна ветка select. leakyWorker знает только про отправку и, лишившись читателя, виснет на out <- i. politeWorker слушает отмену наравне с отправкой: после cancel() ветка <-ctx.Done() становится готовой, воркер возвращается, горутина освобождается. Живой запуск это подтверждает: после ухода читателя leakyWorker навсегда остаётся в счётчике runtime.NumGoroutine(), а politeWorker после cancel() из него исчезает.

Здесь и лежит суть контекста как протокола «пора останавливаться». Отмена — это не команда, которая насильно убивает горутину (в Go такого нет — горутину нельзя прервать извне), а сигнал, который горутина обязана добровольно проверять. Правило простое: любая горутина, которая может заблокироваться надолго — на канале, на сети, на ожидании, — обязана иметь <-ctx.Done() в своём select. Без этого контекст бесполезен: отменять некому и нечего.

Первым аргументом, не в структуре

У контекста две жёстких конвенции, закреплённых в документации пакета, и обе — не про синтаксис, а про архитектуру.

Первое: контекст передаётся первым параметром функции, по имени ctx:

func DoSomething(ctx context.Context, arg Arg) error {
	// ...
}

Единообразие здесь ценно само по себе: где бы вы ни встретили функцию, отмена всегда на одном месте, её видно с первого взгляда, её нельзя случайно не заметить в конце длинного списка аргументов. Сам порядок «ctx первым» держится на конвенции, ревью и линтерах (revive, golangci-lint) — это стилевое правило, а не проверка компилятора. go vet про порядок аргументов не ругается; его вклад в тему контекста другой — он ловит потерянный cancel (WithCancel/WithTimeout без вызова возвращённой CancelFunc).

Второе, важнее: контекст не хранят в структуре — его пробрасывают явно через вызовы. Соблазн положить ctx полем в структуру-сервис велик, но это ошибка. Контекст описывает время жизни одной операции (одного запроса, одного вызова), а структура обычно живёт дольше — она переиспользуется между запросами. Контекст, «замороженный» в поле, либо протухнет (будет отменён от давно завершённого запроса), либо, наоборот, не даст отмене одного запроса распространиться правильно. Отмена — свойство вызова, а не объекта.

// ПЛОХО: контекст в поле структуры — живёт дольше операции, к которой относится
type Service struct {
	ctx context.Context // ← так не делают
	db  *sql.DB
}

// ХОРОШО: контекст приходит с каждым вызовом
type Service struct {
	db *sql.DB
}

func (s *Service) Fetch(ctx context.Context, id int) (*Row, error) {
	return s.db.QueryRowContext(ctx, "...", id).Scan(/* ... */)
}

Редкое исключение — короткоживущие структуры, представляющие саму операцию (например, объект-«запрос», создаваемый и умирающий в пределах одного вызова). Но по умолчанию правило категорично: ctx идёт через параметры, а не через поля. Подробный разбор этого — в заметке Go blog: Contexts and structs.

WithValue: правильно и неправильно

context.WithValue(parent, key, val) кладёт в контекст пару ключ-значение, доступную вниз по дереву через ctx.Value(key). Это самая спорная возможность контекста — потому что ей легко злоупотребить. Сначала — как правильно.

Ключ должен быть неэкспортируемого типа. Value принимает any в качестве ключа и сравнивает ключи по равенству. Контексты иммутабельны: WithValue не меняет родителя, а создаёт дочерний контекст поверх него. Поэтому если ключом сделать строку "userID", чужой пакет со своим WithValue(ctx, "userID", …) не перезаписывает ваше значение в родителе — он затеняет его в своей ветке дерева: ctx.Value("userID") ниже по цепочке вернёт чужое, и вы этого не заметите — коллизия, которую компилятор не поймает. Защита — завести приватный тип ключа: у чужого пакета будет свой тип, пусть даже с тем же именем, и значения не пересекутся.

// ctxKey — неэкспортируемый тип ключа. Так значение из этого пакета нельзя
// перезаписать из чужого пакета (у него будет свой тип с тем же именем).
type ctxKey int

const (
	requestIDKey ctxKey = iota
	userIDKey
)

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

// RequestID достаёт идентификатор. Второй результат — признак наличия:
// значение может отсутствовать, и это надо проверять, а не паниковать.
func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey).(string)
	return id, ok
}

Две детали, обе важны. Ключи requestIDKey/userIDKey — константы приватного типа ctxKey, недоступного снаружи пакета; коллизия невозможна по построению. И геттер возвращает (value, ok) через type assertion с двумя результатами: значения в контексте может не быть, и тогда ok == false — это надо обработать, а не ловить панику на голом .(string). Оборачивать Value в типизированные WithRequestID/RequestID — тоже часть паттерна: наружу торчат типобезопасные функции, а не сырые ключи.

Чего в values класть нельзя. Документация формулирует границу так: WithValue — только для request-scoped данных, проходящих через процессы и API, а не для передачи опциональных параметров функций. Практический критерий: если без этого значения функция не может корректно работать — это обязательный аргумент, и он должен быть в сигнатуре явно, а не спрятан в контексте. Хорошие кандидаты для values — сквозные метаданные запроса, которые не хочется протаскивать через каждый уровень: requestID, trace/span для распределённой трассировки, идентификатор аутентифицированного пользователя, локаль. Именно так request_id пробрасывается в логи через ContextHandler в slog — обработчик достаёт значение из контекста сам, не требуя его отдельным аргументом на каждом вызове логгера.

Причина строгости — в цене. Значения в контексте нетипизированы (any на входе и выходе), не видны в сигнатуре, ищутся линейным проходом по цепочке valueCtx. Всё, что положено в контекст, теряет проверку типов на этапе компиляции и становится неявной зависимостью. Для метаданных запроса это приемлемая плата; для бизнес-параметров функции — нет.

WithCancelCause: почему отменили

Обычная отмена отвечает на вопрос «отменён ли контекст», но не «почему именно». ctx.Err() вернёт context.Canceled — и для планового завершения, и для аварийного, они неразличимы. context.WithCancelCause (Go 1.20+) закрывает этот пробел: CancelFunc теперь принимает ошибку-причину, а достать её можно через context.Cause(ctx).

var ErrShutdown = errors.New("плановая остановка")

// CancelWithCause отменяет контекст с явной причиной. ctx.Err() при этом
// остаётся context.Canceled, а context.Cause(ctx) вернёт переданную причину —
// удобно отличать «отменили из-за X» от «отменили из-за Y».
func CancelWithCause(parent context.Context, cause error) (err, causeErr error) {
	ctx, cancel := context.WithCancelCause(parent)
	cancel(cause)
	<-ctx.Done()
	return ctx.Err(), context.Cause(ctx)
}

Тонкость, зафиксированная тестом стенда: ctx.Err() остаётся context.Canceled — совместимость со старым кодом не ломается, любой, кто проверял Err(), продолжает работать. А context.Cause(ctx) возвращает переданную причину (ErrShutdown в примере). Два слоя: Err() — грубый «отменён/по таймауту», Cause() — точный «почему». Для контекста, отменённого по дедлайну, Cause() вернёт context.DeadlineExceeded; для отменённого через cancel(nil) — тот же context.Canceled, что и Err(). Полезно, когда в одном месте несколько причин отмены и по логам нужно понять, какая сработала.

Из той же современной эпохи пакета — context.AfterFunc(ctx, f) (Go 1.21+): регистрирует функцию f, которую рантайм вызовет в отдельной горутине, как только ctx будет отменён или истечёт. Это идиома «сделать что-то при отмене» без ручной горутины, блокирующейся на <-ctx.Done(): раньше под каждый такой callback заводили горутину со select { case <-ctx.Done(): ... }, теперь достаточно одной строки. AfterFunc возвращает stop func() bool — вызов отменяет регистрацию, если отмена ещё не случилась (true — успели снять до срабатывания). Удобно для отмены-триггера: закрыть соединение, остановить дочернюю задачу, освободить ресурс ровно в момент отмены родителя.

Важный край из контракта: stop() не ждёт завершения f. Если stop() вернул false — значит, f уже стартовала (или отработала), и снять её вызовом уже нельзя; stop() при этом не блокируется до её конца. То есть stop() синхронизирует только регистрацию, но не саму работу callback’а. Если после stop() нужно быть уверенным, что f точно не выполняется прямо сейчас, дожидайтесь этого отдельным механизмом (канал, который f закрывает на выходе, или WaitGroup).

Анти-паттерны списком

Свод того, чего с контекстом делать не следует, — каждый пункт разобран выше, здесь собран для проверки по месту.

  • Хранить контекст в поле структуры. Контекст — время жизни операции, структура живёт дольше. ctx идёт через параметры вызова, не через поля объекта. Исключение — короткоживущие структуры-«операции».
  • Передавать nil вместо контекста. Функции, ожидающей context.Context, никогда не передают nil — это паника при первом же ctx.Done()/ctx.Err(). Если контекста ещё нет, передают context.TODO(); для корня — context.Background().
  • Игнорировать возвращённый cancel. WithCancel/WithTimeout/WithDeadline возвращают CancelFunc, который нужно вызвать всегда — обычно defer cancel() сразу после создания. Не вызвать — оставить внутренние ресурсы контекста (таймер, горутину-наблюдатель) висеть до отмены родителя; go vet предупреждает про потерянный cancel.
  • Класть в WithValue то, что должно быть явным аргументом. Values — только для request-scoped метаданных (requestID, trace, auth), проходящих сквозь API. Обязательный параметр функции — в сигнатуру, а не в контекст: иначе теряется проверка типов и зависимость становится невидимой.
  • Экспортируемый или встроенный тип ключа WithValue. Строка или публичный тип ключа = риск коллизии между пакетами, которую компилятор не поймает. Ключ — всегда неэкспортируемый тип.
  • Горутина без <-ctx.Done(). Если долго блокирующаяся горутина не слушает отмену, контекст ей бесполезен — она утечёт, как leakyWorker. Отмена работает только там, где её проверяют.

Демо и версии

  • Код: живой стенд digital-cookbook/go-context/ — подкаталоги cancel/ (отмена, дедлайны, WithCancelCause) и values/ (request-scoped значения) с тестами корректности (go test ./... зелёный), плюс два запускаемых демо в cmd/*. Код в тексте — выжимки.
  • Как воспроизвести: go run ./cmd/leak — утечка горутины без отмены против корректной остановки по ctx.Done(); go run ./cmd/propagate — три стадии на общем корне, один cancel() гасит всё дерево (все печатают context canceled). Инварианты (Canceled vs DeadlineExceeded, вниз-не-вверх, самый ранний дедлайн, Cause) закреплены тестами.
  • Версии: проверено на go1.26.3; модуль объявляет минимум go 1.25.0. Зависимостей нет — только context, sync, time, errors, fmt, runtime из stdlib. WithCancelCause/context.Cause требуют Go 1.20+.
  • В отличие от статьи про память и escape-анализ, здесь почти нет числовых утверждений: контекст — про поведение (кто кого отменяет и когда), а не про наносекунды. Единственное наблюдаемое «число» — счётчик runtime.NumGoroutine() в демо утечки, и оно детерминировано по смыслу, а не по значению.
  • Смежные статьи: конкурентность: горутины и каналы (контекст внутри select), net/http (r.Context(), отмена клиента, ReadHeaderTimeout), slog (ContextHandler, проброс request_id), интерфейсы Go (context.Context — это интерфейс).

Документация и первоисточники

  • pkg.go.dev/context — референс пакета: интерфейс Context, WithCancel/WithTimeout/WithDeadline/WithCancelCause, Cause, конвенции использования (первый аргумент, не в структуре, WithValue только для request-scoped данных).
  • Go blog: Go Concurrency Patterns — Context — исходная статья, объясняющая мотивацию и устройство пакета на примере отмены дерева горутин.
  • Go blog: Contexts and structs — почему контекст пробрасывают явным аргументом, а не хранят в полях структуры, и редкие исключения из правила.
  • Go blog: Pipelines and cancellation — паттерн отмены через закрываемый канал (done/ctx.Done()) в конвейерах горутин и защита от утечки: тот самый механизм <-ctx.Done() в select, показанный на более крупном примере.

Обсуждение в Telegram

Присоединиться →

Комментарии