open atlas
↑ К треку
Go с нуля до senior GO · 07 · 01

Дисциплина context: отмена, дедлайны и значения, текущие вниз по цепочке вызовов

Context несёт отмену, дедлайны и request-scoped значения вниз по цепочке вызовов: ctx первым аргументом, выводите через WithTimeout и defer cancel, иначе утечёт таймер. Отмена кооперативна — код обязан проверять ctx.Done(). Values — для метаданных, никогда для зависимостей.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Нагрузочный тест перед «чёрной пятницей», p99 чекаута пробивает SLO. Платёжный провайдер тормозил, и команда подняла таймаут платёжного клиента с пяти секунд до десяти, задеплоила — и смотрела, как каждый вызов по-прежнему умирает ровно на пятой. Три инженера, два часа неверия — и кто-то проследил запрос от края: API-гейтвей заворачивал каждый входящий запрос в context.WithTimeout(ctx, 5*time.Second), а платёжный клиент выводил свой десятисекундный контекст от этого родителя. Производный контекст может сократить оставшийся дедлайн, но никогда не продлить — ребёнок попросил десять и унаследовал пять. Однострочный фикс был скучным. Дорогим оказался последовавший аудит: четыре слоя ставили каждый свой таймаут, никто не мог сказать, под каким именно умирает конкретный запрос, а go vet нашёл одиннадцать вызовов WithTimeout с выброшенным cancel — каждый тихо держал в памяти таймер и контекст, пока таймер не сработает. Одно дерево на запрос — и лучше знать его форму.

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

Одно дерево на запрос

context.Context несёт по цепочке вызовов три вещи: сигнал отмены, дедлайн и маленький набор request-scoped значений. Конвенция жёсткая намеренно — ctx идёт первым параметром каждой функции, которая делает I/O или может блокироваться, чтобы канал нигде не рвался посреди цепочки:

func (s *Server) handleCheckout(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // уже отменяется при разрыве соединения клиентом

	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel() // освобождает таймер и отцепляет ребёнка от родителя

	order, err := s.orders.Load(ctx, orderID) // ctx первым, везде
	// ...
}

WithCancel, WithTimeout и WithDeadline не мутируют контекст — они выводят ребёнка и регистрируют его в наборе детей родителя. Отмените родителя — отменится каждый потомок; ребёнок никогда не отменит родителя и не переживёт его. Именно форма дерева делает инцидент из крючка неизбежным: WithTimeout(parent, 10*time.Second) при родителе с пятью оставшимися секундами даёт эффективный дедлайн пять — всегда побеждает минимум вдоль пути к корню. Щедрый таймаут глубоко в стеке не значит ничего, если предок был скупее.

Забытый cancel — тихая утечка. WithTimeout аллоцирует time.Timer и вцепляет ребёнка в родителя; пока вы не вызовете cancel, оба остаются достижимыми — для десятиминутного таймаута на джобе, которая завершается за 200 мс, это десять минут мёртвого груза на джобу, умноженные на ваш RPS. Выведите от долгоживущего родителя через WithCancel и забудьте cancel — утечка станет вечной. defer cancel() следующей строкой — идиома, а go vet (проверка lostcancel) ловит нарушение.

Викторина

Гейтвей заворачивает запросы в контекст с 5с. Глубоко в стеке платёжный клиент делает context.WithTimeout(ctx, 10*time.Second), а провайдер отвечает за 7с. Что произойдёт?

Выводите, не храните

Анти-паттерн, ломающий скоупинг отмены, — хранение контекста в поле структуры. Контекст представляет время жизни одного вызова; структура обычно переживает много вызовов. Заморозьте контекст запроса в объекте — и каждый последующий вызов метода унаследует мёртвый или чужой скоуп отмены: работа для запроса B отменяется, когда отваливается клиент запроса A, или не отменяется вообще никогда. Правило команды Go прямолинейно: передавайте ctx параметром в каждый метод, которому он нужен; не храните. Единственное санкционированное исключение подтверждает правило — http.Request держит контекст, потому что Request и есть один вызов: создаётся и выбрасывается вместе с ним.

Отмена кооперативна

Когда вы вызываете cancel(), ничего не убивается — и это удивляет большинство инженеров в первый раз, когда они трейсят осиротевший запрос. cancel() не убивает ничего. Он закрывает канал Done контекста и заставляет ctx.Err() возвращать context.Canceled (или context.DeadlineExceeded, если сработал таймер). Всё остальное — работа вашего кода: блокирующие операции должны принимать ctx и уважать его — database/sql, net/http, net.Dialer это делают, — а CPU-bound циклы обязаны проверять ctx.Err() между итерациями, потому что никто их не прервёт. Классический прод-запах — осиротевший запрос: хендлер вернул 504, клиент давно ушёл, а SQL-запрос продолжает молотить базу, потому что кто-то вызвал единственный метод без контекста. Дисциплина отмены ровно настолько сильна, насколько кооперативен самый ленивый фрейм в стеке.

Почему это работает

Почему кооперативно, а не принудительно? Убийство горутины на произвольной инструкции оставило бы захваченные мьютексы, полузаписанные буферы и сломанные инварианты — ровно тот хаос, который сделал отмену потоков печально известной в других рантаймах. Go выбрал честный контракт: отмена — это broadcast (закрытый канал, на который может селектиться каждый потомок), а каждая функция сама решает, где ей безопасно остановиться. Цена — дисциплина: код, который не проверяет ctx.Done(), — это код, который нельзя отменить, и никакой фреймворк не починит это снаружи.

Values: метаданные, а не зависимости

Прежде чем тянуться к context.Value, спросите себя: это метаданные запроса, которым нужно пересечь API-границу, — или зависимость, которая должна жить в конструкторе? От ответа зависит, получится ли у вас устойчивый код или невидимая мина. context.Value отвечает на один узкий вопрос: какие request-scoped факты должны пересекать границы API, не спроектированные их нести? Trace id, request id, аутентифицированный принципал — данные, описывающие этот запрос, которые middleware прикрепляет для слоёв, которых не видит. Паттерн типизированного ключа исключает коллизии:

type principalKey struct{} // неэкспортируемый: другой пакет не столкнётся

func WithPrincipal(ctx context.Context, p Principal) context.Context {
	return context.WithValue(ctx, principalKey{}, p)
}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(principalKey{}).(Principal)
	return p, ok
}

Чему там не место: зависимостям. Хэндл базы, конфиг, API-клиент в context.Value — это невидимый, нетипизированный, не проверяемый компилятором параметр: сигнатура функции декларирует независимость, а тело падает на nil-ассершене, когда какой-то вызывающий забыл магическое заклинание. Зависимости живут в полях структур, прошитых при конструировании; к тому же поиск — это проход по связному списку вверх по дереву: нормально для трёх значений, неверно как универсальное хранилище.

Отцепление: работа, которая должна пережить запрос

Иногда отмена запроса не должна отменять работу: аудит-запись попытки платежа обязана быть записана, даже если клиент отвалился на середине — особенно тогда. context.WithoutCancel(ctx) (Go 1.21) выводит контекст, который сохраняет значения родителя — ваш trace id всё ещё течёт в аудит-запись, — но игнорирует его отмену и дедлайн. Дайте отцепленной работе собственную границу, чтобы она не жила вечно:

// Аудит-запись переживает запрос, но не процесс: свой таймаут, значения родителя.
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
audit.Record(auditCtx, event)
Викторина

Аудит-запись должна быть записана даже при разрыве клиента и обязана нести trace id запроса. Какой контекст передать писателю аудита?

Вспомните перед уходом
  1. 01
    Объясни, почему дочерний WithTimeout(ctx, 10s) под 5-секундным родителем падает на пятой секунде, и чего стоит забытый cancel().
  2. 02
    Сформулируй правило для context.Value и для хранения контекста в структурах — с режимом отказа, который каждое правило предотвращает.
Итог

Каждый запрос владеет одним деревом контекстов. Корень приходит на край — r.Context() уже отменяется при разрыве клиента, — и каждый слой выводит детей через WithCancel, WithTimeout или WithDeadline, передавая ctx первым аргументом, чтобы канал нигде не рвался. Выведение только сокращает: ребёнок, просящий десять секунд под пятисекундным родителем, получает пять — поэтому таймауты, поднятые глубоко в стеке, молча не делают ничего. Каждое выведение возвращает функцию cancel, которая обязана выполниться — defer cancel() — иначе таймер и дочерний контекст остаются пришпиленными к родителю; проверка lostcancel в vet существует потому, что прод-кучи стабильно заполнялись мёртвыми timerCtx. Отмена кооперативна: cancel() закрывает Done и выставляет Err, а код обязан селектиться на Done или проверять Err между единицами работы — слой, игнорирующий ctx, молотит для ушедших клиентов: паттерн осиротевшего запроса, который каждая команда БД однажды видит на графике. Контексты — параметры, никогда не поля структур: объект переживает вызовы, и замороженный контекст втаскивает время жизни одного запроса в другой. Value — для метаданных запроса за типизированными неэкспортируемыми ключами — trace id, принципал, — но никогда для зависимостей: им место в конструкторах, где их видит компилятор. А когда работа должна пережить запрос — аудит, биллинг-события — context.WithoutCancel сохраняет метаданные, отбрасывает отмену и заслуживает собственного дедлайна, чтобы «отцепленное» не стало бессмертным. Теперь, когда запрос умирает раньше ожидаемого таймаута, ты знаешь: проверь родителя — эффективный дедлайн всегда равен минимуму вдоль пути к корню.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.