Горутина, которую запустили, но не сказали, когда остановиться, — это утечка, ждущая своего часа. HTTP-клиент отключился, а сервер всё ещё считает ответ; таймаут запроса истёк, а поход в базу продолжается; процесс получил сигнал завершения, а фоновый воркер об этом не знает. Всем этим сценариям нужен один и тот же механизм: способ сказать вниз по цепочке вызовов «работу можно прекращать» — и способ услышать это в любой горутине, на любой глубине. В Go этот механизм — context.Context.
Контекст решает три задачи разом: отмена (сигнал «прекращай»), дедлайны (та же отмена, но по времени) и передача request-scoped значений сквозь границы вызовов, не протаскивая их отдельным аргументом через каждый уровень. Всё это — через один интерфейс, который передаётся первым параметром и течёт по дереву горутин. Статья разбирает сам механизм: как устроена отмена, почему она распространяется вниз и не вверх, почему контекст не хранят в структуре и чем опасен WithValue.
Всё заземлено на живой стенд digital-cookbook/go-context/: инварианты отмены и values покрыты тестами (go test ./... зелёный), а поведение во времени — утечка горутины и распространение отмены — вынесено в запускаемые демо cmd/*. Чистый Go, только stdlib, без зависимостей. Проверено на go1.26.3 (модуль объявляет минимум go 1.25.0). Контекст внутри горутин и каналов затрагивает серия про конкурентность — здесь он разобран отдельно и глубже, как самостоятельный механизм.
В статье
- Зачем нужен context
- Три источника отмены
- Отмена течёт вниз по дереву
- Утечка горутины без отмены
- Первым аргументом, не в структуре
- WithValue: правильно и неправильно
- WithCancelCause: почему отменили
- Анти-паттерны списком
- Демо и версии
- Документация и первоисточники
Зачем нужен 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). Инварианты (CanceledvsDeadlineExceeded, вниз-не-вверх, самый ранний дедлайн,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, показанный на более крупном примере.
Комментарии