Работа с конкурентностью в 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() логируются с контекстной информацией для диагностики.

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

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