context в Go: рабочие приёмы отмены и таймаутов
Когда context.Context передавать явно, куда нельзя класть значения и почему context.Background() внутри библиотеки — почти всегда ошибка. Разбор с проверкой контракта отмены.

HTTP-хендлер получает запрос, ходит в базу, дёргает внешний сервис и пишет ответ. Клиент отваливается на середине — а горутина продолжает жечь соединение с базой и держать транзакцию. Через час таких висящих запросов пул соединений исчерпан, и сервис отдаёт 500 всем подряд. Лечится это одним: context.Context, протянутым от входящего запроса до последнего вызова, который умеет отменяться. Ниже — где его брать, как передавать и что кладут в контекст напрасно. Актуально для Go 1.21–1.22, поведение context стабильно с 1.7.
Контракт, который часто нарушают
Стандартная библиотека формулирует правила прямо в документации пакета, и нарушают их регулярно. Главных три:
- Контекст передаётся первым аргументом и называется
ctx. Не хранится в структуре, не кладётся в поле. - Не передавайте nil. Если не знаете, какой контекст использовать, —
context.TODO(), а неnilи неcontext.Background()наугад. - В контекст кладут только request-scoped данные, проходящие через границы процессов: идентификатор запроса, данные аутентификации. Не опциональные параметры функции.
Нарушение первого правила — самое дорогое. Как только контекст оказывается полем структуры, вы теряете возможность отменить одну операцию, не задев другие, и код перестаёт читаться: непонятно, какой запрос отменит этот ctx.
Контекст — это не сумка для параметров. Это провод отмены. Если по нему нельзя дёрнуть Cancel и остановить всю цепочку вызовов ниже, вы используете его не по назначению.
Откуда брать корневой контекст
Есть ровно два законных источника корня, и путать их с местом использования нельзя.
context.Background()— корень дляmain, инициализации, тестов верхнего уровня. Один раз на входе в программу.context.TODO()— заглушка, когда контекст ещё некуда взять, но сигнатуру уже пишете. Отличается от Background только тем, что статические анализаторы видят: тут работа не закончена.
В HTTP-хендлере корень уже создан за вас — это r.Context(). Он отменяется, когда клиент разрывает соединение или сервер закрывается. Создавать внутри хендлера свой Background() — значит оторвать всю цепочку от жизненного цикла запроса. Висящие горутины из первого абзаца берутся именно отсюда.
func handler(w http.ResponseWriter, r *http.Request) {
// Берём контекст запроса, а не Background().
// Он отменится сам, если клиент уйдёт.
ctx := r.Context()
user, err := fetchUser(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
func fetchUser(ctx context.Context, id string) (*User, error) {
// ctx проброшен в QueryContext — запрос к БД отменится
// вместе с HTTP-запросом.
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id)
var u User
if err := row.Scan(&u.Name); err != nil {
return nil, err
}
return &u, nil
}
Таймауты и дедлайны: где ставить и кто отменяет
Самая частая ошибка с таймаутом — забыть cancel. WithTimeout и WithCancel возвращают функцию отмены, и её нужно вызвать всегда, даже если операция успела завершиться. Иначе течёт таймер и связанные с ним ресурсы. Стандартный приём — defer cancel() сразу после создания.
func callExternal(ctx context.Context, url string) ([]byte, error) {
// Ограничиваем внешний вызов тремя секундами
// поверх уже имеющегося дедлайна из ctx.
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // вызывается и при успехе, и при ошибке
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err // при таймауте здесь будет ошибка контекста
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Дедлайны наследуются: WithTimeout берёт минимум из своего значения и того, что уже стоит в родительском контексте. Если наверху осталась одна секунда, а вы просите три — операцию отменят через секунду. Это правильно, но об этом забывают и удивляются, почему таймаут не сработал так, как задан.
go vet ловит потерянный cancel
Забытый cancel — не абстрактный риск. Встроенный go vet с проверкой lostcancel находит случаи, когда функция отмены не сохраняется и не вызывается. Держите go vet ./... в CI — это дешевле, чем искать утечку таймеров под нагрузкой.
context.WithValue: где предел разумного
Значения в контексте — механизм, о который спотыкаются чаще всего. Он работает, но у него есть цена, которую редко проговаривают.
- Тип ключа. Ключ должен быть неэкспортируемым типом вашего пакета, а не строкой. Строковый ключ из чужого пакета может совпасть с вашим, и одно значение затрёт другое молча.
- Поиск линейный. Каждый
WithValueоборачивает предыдущий контекст. Извлечение значения идёт по цепочке вверх. Двадцать вложенных значений — двадцать сравнений на каждыйValue(). - Компилятор не проверяет тип.
ctx.Value(key)возвращаетany. Ошибётесь с типом при извлечении — получите панику в рантайме, а не ошибку сборки.
// Ключ — приватный тип, чтобы исключить коллизии.
type ctxKey int
const requestIDKey ctxKey = iota
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// Извлечение прячем за типизированной функцией,
// а не заставляем каждый вызов делать приведение типа вручную.
func requestIDFrom(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey).(string)
return id, ok
}
Три способа передать данные — что выбрать
Прежде чем класть что-то в контекст, ответьте: это данные запроса, проходящие сквозь слои, или это параметр функции. Второе передают явным аргументом.
| Способ | Что выигрываете | Чем платите | Когда уместно |
|---|---|---|---|
| Явный аргумент функции | Проверка типов, читаемость сигнатуры | Длинные сигнатуры при глубокой вложенности | Обязательные параметры бизнес-логики |
| context.WithValue | Не засоряет сигнатуры промежуточных слоёв | Нет проверки типов, линейный поиск, скрытая зависимость | request ID, данные auth, трейсинг |
| Поле структуры (сервис/хендлер) | Живёт весь жизненный цикл объекта | Не привязано к запросу, не отменяется | Конфиг, зависимости — но не сам ctx |
Критерий простой: если без значения функция не может выполнить работу — это аргумент. Если значение сквозное и нужно на всех уровнях для логов и трейсинга, но не влияет на саму логику, — кандидат в контекст.
Что ломается на практике
Типичные поломки, которые видны только под нагрузкой или в редких путях:
- Контекст в поле структуры. Долгоживущий сервис хранит
ctxот первого запроса. Первый запрос завершился, контекст отменён — все последующие операции падают сcontext canceled, хотя запросы живые. - Проверка ctx.Err() вместо реакции на канал. В долгих циклах нужно слушать
<-ctx.Done()или периодически проверятьctx.Err(). Если ни того, ни другого — отмена ничего не остановит, горутина доработает до конца. - Проглоченная ошибка контекста. После отмены операция возвращает ошибку, а код логирует её как обычный сбой БД. В мониторинге всплеск «ошибок базы», хотя это просто ушедшие клиенты. Отличайте:
errors.Is(err, context.Canceled)иcontext.DeadlineExceeded. - context.Background() в библиотеке. Библиотека игнорирует переданный контекст и создаёт свой. Вызывающий не может отменить операцию — и узнаёт об этом, только когда она виснет.
Что настроить дальше
Включите в CI связку go vet ./... и линтер contextcheck (в составе golangci-lint) — он ловит функции, которые принимают контекст, но не пробрасывают его дальше, и создание нового контекста там, где надо наследовать. Для сервисов под нагрузкой добавьте в дашборд отдельный счётчик context.Canceled и context.DeadlineExceeded, отделённый от прочих ошибок: их рост показывает, что клиенты уходят или таймауты стоят слишком жёстко. Отмена долгих фоновых задач при graceful shutdown — отдельная тема, здесь разбирали только цепочку запроса.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Package context — Go standard library documentation, Go Blog: Contexts and structs
Частые вопросы
А если у меня Go старее 1.21 — приёмы сработают?
Почему нельзя хранить контекст в поле структуры, если так короче?
Отмена не срабатывает — горутина продолжает работать. Что не так?
Обязательно ли вызывать cancel, если операция и так завершилась успешно?
Как отличить отмену клиентом от реального сбоя в логах?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.