Работа с конкурентностью в Go быстрее превращается в головную боль, если не задать четкие правила для жизни горутин и запросов. Пакет context — именно тот инструмент, который помогает привязать операции к жизненному циклу, отменять их и передавать сквозные параметры без глобальных переменных.
В этой статье я разберу, как правильно использовать контексты, какие бывают подводные камни и как не допустить утечек ресурсов. Примеры ориентированы на реальные случаи из разработки серверов и фоновых заданий.
Зачем нужен context в Go
Частая задача в сетевом приложении — прерывать длинную операцию, если клиент отключился или вышло время ожидания. Контекст дает централизованный механизм отмены и дедлайнов, который можно передавать по цепочке вызовов.
Кроме отмены, контекст служит контейнером для однотипных значений, связанных с запросом — идентификаторы пользователя, trace-id и др. Это упрощает трассировку и логику отмены, когда множество компонентов должны среагировать одновременно.
Виды контекстов и что они делают
В стандартной библиотеке есть несколько базовых функций для создания контекстов: Background, TODO, WithCancel, WithTimeout, WithDeadline, WithValue. Понимание отличий между ними сокращает количество ошибок при проектировании.
Ниже — компактная таблица с назначением каждого типа.
| Функция | Назначение |
|---|---|
| context.Background() | Корневой контекст для запуска приложения или тестов; не отменяемый. |
| context.TODO() | Временная заглушка, когда еще не определено, какой контекст нужен. |
| context.WithCancel(parent) | Возвращает контекст и функцию отмены; отмена инициируется вручную. |
| context.WithTimeout(parent, d) | Автоматически отменяет контекст через заданный интервал. |
| context.WithDeadline(parent, t) | Отменяет контекст по конкретному времени. |
| context.WithValue(parent, key, val) | Передаёт значения по дереву вызовов; не для конфигурации. |
Правильная отмена операций: с чего начать
Главное правило — каждый контекст, созданный с помощью WithCancel/WithTimeout/WithDeadline, должен быть отменён вызывающей стороной. Иначе вы получите утечку горутин и ресурсов, особенно если дочерние операции ожидают сигнал об отмене.
Типичный шаблон: создать контекст в обработчике, запустить горутину, и обязательно defer cancel() сразу после создания. Это гарантирует вызов отмены при выходе из функции, даже если возникла ошибка.
ctx, cancel := context.WithCancel(parent)
defer cancel()
go func(ctx context.Context) {
select {
case <-ctx.Done():
// чистим ресурсы
return
case result := <-workCh:
// обрабатываем результат
}
}(ctx)
В этом примере дочерняя горутина слушает канал отмены и корректно завершает работу. Такой подход предотвращает «висящие» горутины, которые продолжают занимать память и держать открытые соединения.
WithTimeout и WithDeadline: управление временем
В отличие от ручной отмены, WithTimeout автоматически отменит контекст по истечении интервала. Это удобно для сетевых вызовов и операций, где нужно ограничить максимальное время выполнения.
С WithDeadline вы задаёте конкретную точку времени. Оба механизма каскадируют: отмена родителя приводит к отмене всех дочерних контекстов.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// таймаут или другая ошибка
}
Важно учитывать, что таймауты часто должны устанавливаться на уровне обработчика запроса, а не глубоко в библиотеке, чтобы избежать конфликтов и неоднозначных поведений. Если библиотека сама устанавливает таймаут, документируйте это явно.
Контекст и хранение значений: где осторожность обязательна
WithValue удобен для передачи данных, связанных с обработкой запроса, но его легко использовать неправильно. Контекст не предназначен для хранения больших структур или для замены параметров функции.
Рекомендации просты: ключи должны быть типизированными и приватными для пакета, а значения — небольшими и иммутабельными. Никогда не храните в контексте mutable-объекты, которые требуют синхронизации.
type ctxKey string
const requestIDKey ctxKey = "reqID"
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), requestIDKey, "id-123")
process(ctx)
}
func process(ctx context.Context) {
id := ctx.Value(requestIDKey).(string)
// используем id в логах
}
Такой подход упрощает передачу metadata через слои приложения. Но нельзя полагаться на WithValue как на замену параметрам: если функция явно требует параметр, лучше передать его как аргумент.
Типичные ошибки и как их избежать
Ошибка номер один — не вызывать cancel(). Часто это приводит к висячим таймерам и горутинам. Простое правило: сразу defer cancel() после создания контекста.
Другой частый промах — хранение контекста в структурах на длительный срок. Контекст должен жить только столько, сколько требуется для текущего запроса или операции. Сохранение его в глобальном состоянии ломает логику отмены.
- Не используйте context.Background() как замену параметру; предпочтительно получать контекст сверху, из запроса или контроллера.
- Не храните большие данные в Контексте, особенно mutable-объекты.
- Не полагайтесь на WithValue для передачи конфигурации; это для request-scoped данных.
Поведение при каскадной отмене
Когда родительский контекст отменяется, дочерние контексты получают Done() и Err() со значением context.Canceled или context.DeadlineExceeded. Это удобная модель для композиций, когда одно событие должно остановить целый набор задач.
В многослойных системах полезно явственно проверять Err() и возвращать детализированные ошибки вверх по стеку для логов и мониторинга. Это помогает понять, что именно стало причиной остановки.
Практические паттерны использования
Для HTTP-сервисов принято использовать context из http.Request: он автоматически закрывается при завершении запроса. Это позволяет дочерним операциям, как запросы к БД или вызовы внешних сервисов, получать сигнал отмены вместе с клиентским разъединением.
В фоновых задачах я обычно создаю контекст от корневого background и прокидываю через контроллер, где указываю WithTimeout, чтобы каждая отдельная задача имела ограничение времени. Так легче контролировать повторные запуска и отладку.
Пример: обертывание вызова к БД с таймаутом
Ниже пример функции, которая устанавливает таймаут для конкретного запроса к базе и корректно закрывает контекст. Такой паттерн помогает избежать зависших транзакций в случае проблем с сетью.
func queryWithTimeout(ctx context.Context, db *sql.DB, q string) (*sql.Rows, error) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return db.QueryContext(ctx, q)
}
Этот подход дает контроль над каждым запросом и упрощает мониторинг: в метриках легко отличить долгие вызовы от системных ошибок.
Мой опыт: когда context спас проект
Однажды мы столкнулись с проблемой: при массовых запросах к внешнему API несколько горутин оставались «висеть», удерживая пул соединений и память. После аудита оказалось, что в цепочке вызовов никто не отменял контексты при ошибках.
Добавив defer cancel() и установив разумные таймауты на критических этапах, мы снизили число зависаний и быстрее обнаруживали проблемные участки в логах. Практически мгновенно улучшился отклик сервиса и уменьшилось потребление ресурсов.
Контрольный список при работе с context
Небольшой чеклист поможет соблюдать лучшие практики при разработке и кодревью. Я сам использую его при проверке pull request’ов.
- Каждый WithCancel/WithTimeout/WithDeadline сопровождается defer cancel().
- Контекст не сохраняется в глобальном состоянии и не используется как поле структуры для длительного хранения.
- Ключи для WithValue типизированы и приватны для пакета.
- Таймауты выставляются на уровне обслуживания запроса, не «глубоко» в библиотеке без документации.
- Ошибки от ctx.Err() логируются с контекстной информацией для диагностики.
Концентрация на этих простых правилах помогает избежать большинства проблем и сделать поведение сервиса предсказуемым под нагрузкой.
Понимание и аккуратное применение контекста экономит время при отладке и повышает надежность системы. Приводите отмену и дедлайны в порядок, и ваши горутины начнут вести себя так же предсказуемо, как и остальная часть кода.

