Представь сервис, который обрабатывает HTTP-запрос. Внутри он лезет в БД, дёргает
пару соседних сервисов, считает что-то в фоновых горутинах. И вот клиент закрыл
вкладку. Или истёк таймаут. Вся эта работа стала бессмысленной — но она всё ещё
крутится, жжёт CPU, держит соединения. Нужен способ дёрнуть за одну верёвочку
и сказать всему дереву работы: «всё, расходимся».
Эта верёвочка — context.Context. Он закрывает три задачи, которые всплывают в
любом сервисе: отмену (остановить работу досрочно), дедлайны и таймауты
(не ждать дольше N секунд), и передачу request-scoped значений (request ID,
trace, данные авторизации) через границы вызовов.
В этой главе разберём, как контексты складываются в дерево отмены, как сигнал
течёт по нему через Done(), почему cancel нужно вызывать всегда, и где
WithValue помогает, а где это запах кода.
Прежде чем нырять в механику, зафиксируем конвенцию, которую соблюдает весь
экосистемный код. Context — это первый параметр функции, его имя ctx, тип
context.Context:
func FetchUser(ctx context.Context, id int) (*User, error) { ... }
Несколько правил, за нарушение которых ругается и линтер, и ревьюер:
Не передавай nil как контекст. Если контекста ещё нет (заглушка, прототип) —
бери context.TODO(). Он ничего не делает, но честно сигналит «здесь будет
настоящий ctx».
Не клади context в структуру. Контекст живёт в пределах одного вызова/запроса,
а поля структуры переживают его. Прокидывай ctx через аргументы.
Не храни его «на потом». Контекст — это про текущую операцию.
Контексты образуют дерево. В корне — context.Background(), пустой контекст,
который никогда не отменяется сам по себе. От любого контекста можно породить
дочерний:
ctx, cancel := context.WithCancel(parent)ctx, cancel := context.WithTimeout(parent, 5*time.Second)ctx, cancel := context.WithDeadline(parent, deadline)defer cancel() // об этом — ниже, но запомни сразу: cancel вызывают ВСЕГДА
Главное свойство дерева, ради которого всё и затевалось: отмена родителя
отменяет всех потомков, но не наоборот.
Отменили parent → каскадом закрылись Done() всех его детей, внуков, правнуков.
Отменили ребёнка → родитель и его братья живут как ни в чём не бывало.
Это ровно тот же принцип «закрытие канала как broadcast» из главы
каналы, только аккуратно организованный в иерархию. Один
закрытый канал будит всех, кто слушает; одно дерево отмены гасит всё поддерево.
Кстати, WithTimeout — это просто удобная обёртка над WithDeadline:
WithTimeout(parent, d) эквивалентно WithDeadline(parent, time.Now().Add(d)).
Когда дедлайн наступает, контекст отменяется автоматически — будто кто-то вызвал
cancel за тебя.
Как код узнаёт, что его отменили? Через ctx.Done() — он возвращает канал,
который закрывается в момент отмены. Закрытый канал всегда готов к приёму, так
что <-ctx.Done() мгновенно разблокируется. Классический способ сделать операцию
отменяемой — ветка в select:
func worker(ctx context.Context, in <-chan Job) error { for { select { case <-ctx.Done(): return ctx.Err() // Canceled или DeadlineExceeded case job, ok := <-in: if !ok { return nil } process(job) } }}
После отмены ctx.Err() говорит причину:
context.Canceled — кто-то вызвал cancel().
context.DeadlineExceeded — истёк таймаут/дедлайн.
А ctx.Deadline() отдаёт время дедлайна (если он задан) — полезно, чтобы понять,
сколько ещё можно ждать. Важный нюанс: пока твой код не проверяетctx.Done(),
отмена для него ничего не значит. Долгий цикл, который ни разу не заглянул в
Done(), отменить нельзя — он досчитает до конца.
Посмотрим на отмену в действии. Главная горутина отменяет контекст, рабочая ловит
сигнал и завершается:
package mainimport ( "context" "fmt" "sync")func main() { ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() for { select { case <-ctx.Done(): fmt.Println("остановлен:", ctx.Err()) return default: // полезная работа, пока нас не отменили } } }() cancel() // дёргаем верёвочку wg.Wait() fmt.Println("главная функция завершилась чисто")}
Здесь мы вызываем cancel() сразу, и горутина при первой же проверке Done()
видит закрытый канал и выходит. wg.Wait() гарантирует, что main не закончится
раньше, чем рабочая горутина напечатает свою строку.
Пустой default тут — лишь для наглядности: он показывает, что цикл крутится и
проверяет Done() на каждой итерации. В реальном коде так не пишут — пустой
default в бесконечном цикле жжёт 100% CPU; в ветке default делают порцию
работы либо ждут на канале, а не гоняют цикл вхолостую.
Часто отмену не нужно делать руками — достаточно сказать «жди не дольше N». Это
WithTimeout. Контекст сам отменится по истечении срока, а ctx.Err() вернёт
DeadlineExceeded. Сравним две горутины: одна успевает «ответить» до таймаута,
другая нет.
package mainimport ( "context" "fmt" "sync" "time")// ждёт либо «ответа» из канала, либо отмены контекстаfunc call(ctx context.Context, reply <-chan string) string { select { case r := <-reply: return "ответ: " + r case <-ctx.Done(): return "сорвалось: " + ctx.Err().Error() }}func main() { var wg sync.WaitGroup // быстрый: ответ уже лежит в буфере ctx1, c1 := context.WithTimeout(context.Background(), time.Second) defer c1() fast := make(chan string, 1) fast <- "ok" // медленный: ответа нет, таймаут уже истёк ctx2, c2 := context.WithTimeout(context.Background(), -time.Second) defer c2() slow := make(chan string) // никто не пишет wg.Add(2) go func() { defer wg.Done(); fmt.Println("быстрый ->", call(ctx1, fast)) }() go func() { defer wg.Done(); fmt.Println("медленный ->", call(ctx2, slow)) }() wg.Wait()}
Маленькая хитрость для детерминированного вывода в браузере: второму контексту мы
задали уже истёкший дедлайн (-time.Second), поэтому его Done() закрыт сразу.
В реальном коде таймаут, конечно, положительный — но суть та же: select
выбирает либо реальный результат, либо срабатывание дедлайна.
Самая частая ошибка новичков — забыть cancel. Все три порождающие функции
(WithCancel, WithTimeout, WithDeadline) возвращают функцию cancel, и её
нужно вызвать всегда. Идиома — defer cancel() сразу после создания:
ctx, cancel := context.WithTimeout(ctx, time.Second)defer cancel() // даже если doWork успел — cancel освобождает таймерres, err := doWork(ctx)
Что будет, если забыть:
Дочерний контекст останется привязан к родителю до тех пор, пока не отменится
родитель. Для WithTimeout/WithDeadline под капотом работает таймер — это
утечка памяти и не погашенного до срабатывания таймера.
go vet и линтеры предупредят о «lost cancel» — это не косметика, это реальный
баг.
Может смутить: «а если операция успешно завершилась — зачем ещё cancel?». Затем,
что cancel помимо отмены ещё и отвязывает контекст от родителя и гасит
таймер. Повторный вызов cancel безопасен и идемпотентен, так что defer cancel()
прекрасно уживается с ранним успешным выходом — лишним он не будет.
context.WithValue несёт request-scoped данные — то, что относится к
конкретному запросу и должно быть видно на всех слоях: request ID, trace span,
данные аутентификации. Ключ обязательно делают неэкспортируемым типом, чтобы
пакеты случайно не перетёрли значения друг друга:
package mainimport ( "context" "fmt")type ctxKey string // в реальном коде ключ неэкспортируемый; здесь упрощённоconst reqIDKey ctxKey = "reqID"func withReqID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, reqIDKey, id)}func logLine(ctx context.Context, msg string) { id, ok := ctx.Value(reqIDKey).(string) if !ok { id = "<нет>" } fmt.Printf("[req=%s] %s\n", id, msg)}func main() { ctx := withReqID(context.Background(), "abc-123") logLine(ctx, "начали обработку") logLine(ctx, "сходили в БД") plain := context.Background() logLine(plain, "запрос без id")}
Обрати внимание: значение прошло через две вложенные функции, и нигде не пришлось
тащить reqID отдельным параметром. Это и есть «через границы вызовов».
И правила, нарушение которых — антипаттерн:
Только request-scoped данные. Не превращай WithValue в свалку для опциональных
параметров функции. Конфиги, зависимости, логгеры — это явные аргументы или
поля структуры сервиса, а не значения в контексте.
Ключ — неэкспортируемый тип, не голая строка. Строковый ключ "id" из чужого
пакета может затереть твой.
Если данные влияют на бизнес-логику и должны быть в сигнатуре — пусть будут в
сигнатуре. «Бизнес-аргумент, спрятанный в WithValue» — классический запах кода:
компилятор о нём не знает, IDE не подскажет, а nil вылезет в рантайме.
Где дерево отмены раскрывается во всю мощь — это корректное завершение сервиса.
signal.NotifyContext связывает сигналы ОС (Ctrl+C, SIGTERM) с отменой
контекста:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)defer stop()// ... запускаем сервер с этим ctx ...<-ctx.Done() // ждём сигналаsrv.Shutdown(ctx) // начинаем мягко гасить
Один отменённый корень каскадом останавливает все обработчики, фоновые задачи и
исходящие вызовы — то самое «дёрнуть за верёвочку», только теперь верёвочку дёргает
сама операционная система.
Этот пример со signal/os/net/http нельзя запустить в браузере (нет
сетевого стека и ОС-сигналов), поэтому он дан статичным блоком. Механика отмены,
которую он демонстрирует, ровно та же, что в запускаемых примерах выше.
Контекст часто живёт рядом с настоящим параллелизмом: десятки горутин делят один
ctx, гонятся за общими данными, конкурируют реплики. Настоящего параллелизма и
race-детектора в браузерном песочнике нет — yaegi исполняет горутины
однопоточно. Поэтому пример «гонка реплик» ниже дан статичным блоком, без запуска,
с разбором — запускать его в плейграунде смысла нет, гонку ты там не увидишь.
// Гонка реплик: бьём в несколько источников, берём первый успешный ответ.// Когда один ответил — отменяем остальных через общий ctx.func firstSuccess(ctx context.Context, replicas []Replica) (Result, error) { ctx, cancel := context.WithCancel(ctx) defer cancel() // отменит «проигравшие» горутины results := make(chan Result, len(replicas)) for _, r := range replicas { go func(r Replica) { if res, err := r.Query(ctx); err == nil { select { case results <- res: case <-ctx.Done(): // нас уже опередили — тихо уходим } } }(r) } select { case res := <-results: return res, nil // defer cancel() погасит остальных case <-ctx.Done(): return Result{}, ctx.Err() }}
Это context-аналог or-channel из главы каналы: первый
успешный результат «выигрывает», defer cancel() гасит проигравших, а буфер на
len(replicas) не даёт опоздавшим горутинам залипнуть на отправке. На реальной
машине под go test -race ты бы проверил, что после отмены никто не дёргается;
в браузере этой проверки нет.
Забыли defer cancel(). Утечка контекста, а для таймаутов — ещё и горутины
таймера. Самый частый баг; go vet его ловит, не игнорируй предупреждение.
Не проверяют ctx.Done() в долгом цикле. Отмена есть, а толку нет —
операция не реагирует. Долгие циклы обязаны периодически заглядывать в Done().
Хранят context в структуре или передают nil. Оба — антипаттерны. ctx идёт
аргументом; если его пока нет — context.TODO().
Кладут зависимости в WithValue вместо явных аргументов. Логгер, конфиг,
пул соединений — это не request-scoped данные.
Игнорируют ctx.Err() и возвращают свою выдуманную ошибку, теряя причину
отмены. Отдавай наверх именно ctx.Err() — вызывающий хочет знать, это Canceled
или DeadlineExceeded.
Держи в голове картинку: context — это дерево сигналов отмены с прикреплёнными
дедлайнами и значениями. Отмена течёт сверху вниз через закрытие Done()-каналов.
Каждая порождающая функция выдаёт тебе cancel, которым ты обязан воспользоваться.
Думай о контексте как об «области видимости запроса»: пока он жив — работаем;
отменился — сворачиваемся везде и сразу.
Context — стержень топика 4 (13–15): отменяемые операции, таймауты, гонки реплик
(задача 15 «First Successful» — тот самый context-аналог
or-channel из задачи 01). В топике 7 (23–32) context пронизывает
сервисные конструкции — worker pool'ы из паттернов, пайплайны,
graceful shutdown. А проверка «не утекли ли горутины после отмены» — прямой мост к
главе утечки и гонки.