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

Зачем вообще context
HTTP-хендлер запускает запрос к базе, потом идёт в сторонний API, потом пишет в кеш. Клиент закрыл вкладку на втором шаге — а горутины продолжают работать, держат соединения с базой и тратят ресурсы. Без механизма отмены серверу остаётся только ждать, пока каждая операция завершится сама. Пакет context решает именно это: передаёт сигнал «хватит» по всей цепочке вызовов.
Три инструмента внутри пакета — отмена (WithCancel), таймаут (WithTimeout / WithDeadline) и значения (WithValue) — работают по-разному и ломаются по-разному. Ниже — конкретные приёмы для каждого, с кодом на Go 1.21+.
Отмена: WithCancel
Самый простой случай: вы хотите остановить одну или несколько горутин вручную. context.WithCancel возвращает дочерний контекст и функцию cancel. Вызвали cancel() — канал ctx.Done() закрылся, все слушатели получили сигнал.
Типичный приём: fan-out с ранней остановкой
Запускаете несколько горутин параллельно, нужен результат только первой успешной. Остальные должны остановиться, иначе они продолжат тратить CPU и сетевые соединения.
// Go 1.21+
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func fetchFirst(ctx context.Context, urls []string) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // отменяет оставшиеся горутины при выходе
type result struct {
url string
err error
}
ch := make(chan result, len(urls))
for _, u := range urls {
go func(target string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
ch <- result{url: target, err: err}
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
ch <- result{url: target, err: err}
return
}
resp.Body.Close()
ch <- result{url: target}
}(u)
}
for range urls {
r := <-ch
if r.err == nil {
return r.url, nil // cancel() сработает через defer
}
}
return "", fmt.Errorf("все запросы завершились с ошибкой")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
url, err := fetchFirst(ctx, []string{
"https://example.com",
"https://example.org",
})
if err != nil {
fmt.Println("ошибка:", err)
return
}
fmt.Println("первый ответил:", url)
}
Ключевое: defer cancel() стоит сразу после создания контекста. Если забыть — дочерний контекст утекает до завершения родительского. Линтер go vet предупредит, но только если cancel вообще не вызывается в функции.
Ошибка, которую совершают чаще всего
Горутина не слушает ctx.Done(). Вы вызвали cancel(), но внутри горутины — блокирующая операция без контекста (например, прямое чтение из канала без select). Контекст не магически прерывает код — он лишь закрывает канал. Если никто не проверяет этот канал, ничего не произойдёт.
Таймауты: WithTimeout и WithDeadline
WithTimeout — обёртка над WithDeadline. Первый принимает длительность, второй — абсолютное время. Под капотом оба ставят таймер и вызывают cancel автоматически.
Когда что использовать
| Ситуация | Инструмент | Почему |
|---|---|---|
| HTTP-хендлер, ограничение на обработку запроса | WithTimeout | Время отсчитывается от текущего момента, длительность фиксирована |
| Цепочка вызовов, общий дедлайн на всю цепочку | WithDeadline | Все звенья цепочки видят одну и ту же точку «стоп» |
| Операция внутри цепочки, которой нужен свой лимит короче общего | WithTimeout | Создаёте дочерний контекст с более коротким таймаутом |
| Ручная отмена по бизнес-логике, без привязки ко времени | WithCancel | Таймер не нужен |
Важный нюанс: дочерний контекст не может продлить дедлайн родительского. Если родитель истекает через 2 секунды, а вы создаёте WithTimeout(ctx, 10*time.Second), реальный таймаут — 2 секунды. Проверяйте через ctx.Deadline().
// Проверка оставшегося времени перед дорогой операцией
func queryExternalAPI(ctx context.Context) error {
deadline, ok := ctx.Deadline()
if ok && time.Until(deadline) < 500*time.Millisecond {
return fmt.Errorf("осталось меньше 500 мс, запрос не имеет смысла")
}
// ... выполняем запрос
return nil
}
Типичная ошибка: таймаут на каждый шаг вместо общего
Три последовательных вызова по 3 секунды каждый — суммарно до 9 секунд. Клиент давно ушёл. Правильнее: один WithTimeout на весь хендлер, дочерние контексты — только чтобы сделать отдельный шаг короче общего лимита, а не длиннее.
Контекст — это бюджет. Каждый вложенный вызов может уменьшить бюджет времени, но не увеличить. Если вы проектируете цепочку с таймаутами — считайте сверху вниз, а не снизу вверх.
Значения: WithValue
Самый спорный инструмент пакета. context.WithValue позволяет прикрепить к контексту пару ключ-значение. Читатели ниже по цепочке достают значение через ctx.Value(key).
Для чего это подходит
- Request ID и trace ID для логирования и трейсинга
- Данные аутентификации, проставленные middleware (идентификатор пользователя, роли)
- Метаданные запроса, которые нужны на всех уровнях, но не относятся к бизнес-логике функции
Для чего это не подходит
- Передача зависимостей (логгер, пул соединений, конфиг) — используйте явные аргументы или DI
- Замена параметров функции: если значение влияет на результат, оно должно быть в сигнатуре
- Передача мутабельных структур — контекст иммутабелен по дизайну, а положенная в него структура может быть изменяемой, и это рецепт гонки данных
Безопасные ключи
Строковый ключ — плохая идея: два пакета могут использовать одну и ту же строку и перезатереть значения друг друга. Стандартный приём — неэкспортируемый тип.
// пакет auth
type ctxKey struct{}
func WithUserID(ctx context.Context, uid string) context.Context {
return context.WithValue(ctx, ctxKey{}, uid)
}
func UserID(ctx context.Context) (string, bool) {
uid, ok := ctx.Value(ctxKey{}).(string)
return uid, ok
}
Тип ctxKey неэкспортируемый — другой пакет не может создать такой же ключ, коллизии исключены. Функции-обёртки скрывают детали реализации.
Сноска: иммутабельность контекста — каждый With*-вызов возвращает новый дочерний контекст, не изменяя родительский. Дерево контекстов растёт вниз: отмена родителя отменяет всех потомков, но не наоборот.
Чек-лист: пять правил работы с context
- context.Context — первый параметр функции, называется
ctx. Не храните контекст в структуре — это прямо указано в документации пакета. - Всегда вызывайте cancel. Даже если контекст с таймаутом:
defer cancel()освобождает ресурсы раньше, чем сработает таймер. - Не передавайте
nilвместо контекста. Если не знаете, какой контекст использовать, —context.TODO(). Если это верхний уровень —context.Background(). - Проверяйте
ctx.Err()после блокирующих операций. Функция может вернуть ошибку и из-за отмены, и по другой причине — различайте. - Не используйте
WithValueдля данных, которые влияют на бизнес-логику. Если значение определяет поведение функции — оно должно быть в её сигнатуре.
Что дальше
В Go 1.21 появилась функция context.WithoutCancel — создаёт копию контекста, которая не отменяется при отмене родителя. Это нужно для фоновых задач, которые должны продолжиться после ответа клиенту (запись аудита, отложенная очистка). В Go 1.21 также добавлены context.AfterFunc и context.WithDeadlineCause / context.WithTimeoutCause — они позволяют прикрепить к отмене конкретную причину и упрощают диагностику. Если вы ещё на версии ниже 1.21, уточните доступные функции в документации пакета context.
Следующий практический шаг: возьмите самый нагруженный хендлер вашего сервиса, проверьте, что каждый исходящий вызов (база, внешний API, кеш) принимает контекст из хендлера, а не context.Background(). Замените — и вы получите каскадную отмену бесплатно.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Пакет context — официальная документация Go, Go Blog: Contexts and structs
Частые вопросы
Чем отличаются context.Background() и context.TODO()?
Контекст отменён, но горутина не останавливается — почему?
Можно ли хранить context в поле структуры?
Как отличить ошибку из-за таймаута от ошибки из-за ручной отмены?
Безопасно ли передавать один контекст в несколько горутин одновременно?
Что будет, если не вызвать cancel() для контекста с таймаутом?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.