context.Context в Go: типичные ошибки и как с ними жить
За девять лет работы с Go видел почти все способы неправильно использовать context.Context. Начиная от «положу-ка я туда логгер и сессию» до «почему мой воркер не останавливается, я же передал ctx». Соберу типичные грабли в одном месте, чтобы было куда отправлять коллег после ревью.
Сразу оговорка: пост не про азы, что такое контекст. Предполагаю, ты уже видел сигнатуру func(ctx context.Context, ...) и знаешь про Done(). Будем разбирать, где и почему это всё ломается.
Ошибка 1: контекст хранится в структуре
Самый частый антипаттерн, который встречается даже в популярных библиотеках:
type Service struct {
ctx context.Context
db *sql.DB
}
func (s *Service) Process(item Item) error {
return s.db.QueryRowContext(s.ctx, "...").Scan(...)
}
Что не так. Контекст несёт жизненный цикл конкретного запроса или операции. Если ты сохраняешь его в структуру, ты привязываешь все будущие вызовы к моменту создания этой структуры. На практике это значит, что либо контекст никогда не отменится (если положили context.Background()), либо отменится в самый неудобный момент.
Документация Go прямым текстом говорит: «Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it». Это не стилистическая рекомендация, а правило, нарушение которого ломает архитектуру.
Корректно — передавать ctx первым аргументом каждого метода:
func (s *Service) Process(ctx context.Context, item Item) error {
return s.db.QueryRowContext(ctx, "...").Scan(...)
}
Ошибка 2: context.WithValue для всего подряд
Видел сервисы, где через ctx прокидывали *sql.DB, *redis.Client, конфиг, юзера, request ID, корреляционный ID и ещё пять вещей. Это превращает функцию в чёрный ящик: сигнатура чистая, но внутри она зависит от десятка ключей в контексте, которые нигде не задокументированы.
Правило: в WithValue кладём только то, что относится к текущему запросу и не должно быть в сигнатуре. Это:
- request ID для трейсинга;
- информация о юзере, если auth middleware её положил;
- tenant ID в мультитенантных приложениях;
- spans для OpenTelemetry (но обычно их кладёт сама библиотека).
Зависимости (БД, клиенты внешних API) живут не в контексте, а в структуре сервиса или принимаются явно. Конфиг — тоже.
И ещё нюанс: ключ должен быть кастомным типом, не строкой. Иначе двое разработчиков из разных пакетов случайно перетрут значения друг друга.
type ctxKey int
const userKey ctxKey = 1
ctx = context.WithValue(ctx, userKey, user)
Ошибка 3: context.Background внутри обработчика
Классика жанра в фоновой обработке:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
go func() {
ctx := context.Background()
h.sendNotification(ctx, ...) // отвалится только по таймауту клиента
}()
w.WriteHeader(http.StatusAccepted)
}
Идея понятна: HTTP-запрос отдаёт 202, фоновая работа доделывается потом. Но context.Background здесь означает «эта работа не отменится никогда, даже если процесс получит SIGTERM». На rolling update такие горутины уезжают вместе с подом, не успев доделать работу.
Правильный подход — проброс root-контекста приложения, который отменяется на shutdown:
type Handler struct {
rootCtx context.Context // ctx приложения
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
go func() {
ctx, cancel := context.WithTimeout(h.rootCtx, 30*time.Second)
defer cancel()
h.sendNotification(ctx, ...)
}()
w.WriteHeader(http.StatusAccepted)
}
Тут есть тонкость: r.Context() отменится, как только клиент закроет соединение. Использовать его для фоновой задачи нельзя. А rootCtx отменится только на shutdown сервиса. Передавать корневой контекст приложения через структуру — единственный случай, когда я держу контекст в поле. И то скрепя зубы.
Ошибка 4: игнорирование ctx.Err
Мне регулярно встречается такой код:
for _, item := range items {
select {
case <-ctx.Done():
return nil
default:
}
process(item)
}
Возврат nil при отмене контекста выглядит логичным — мы же сами решили остановиться. Но вызывающий код не знает, доделали мы работу или нет. По сигнатуре error равен nil, значит всё успешно. На самом деле — половина items не обработана.
Правильно — возвращать ctx.Err():
for _, item := range items {
if err := ctx.Err(); err != nil {
return err // context.Canceled или context.DeadlineExceeded
}
if err := process(item); err != nil {
return err
}
}
Вызывающая сторона уже сама решит, что делать с context.Canceled. Возможно, это ожидаемая ситуация и логировать её не надо. Но факт прерывания должен быть виден.
Ошибка 5: неподписанные блокирующие операции
Самый коварный случай. У тебя есть код:
func fetchData(ctx context.Context, url string) ([]byte, error) {
resp, err := http.Get(url) // ctx игнорируется
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Контекст принимается, но не используется. Любая блокировка в http.Get может длиться дольше, чем хотелось бы. Корректно:
func fetchData(ctx context.Context, url string) ([]byte, error) {
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)
}
Аналогично с БД: db.Query вместо db.QueryContext — ловушка. Эта функция блокируется на сети без возможности отмены. Я в линтере держу правило, запрещающее non-context версии.
Ошибка 6: defer cancel забыли
ctx, _ := context.WithTimeout(parent, time.Second)
result, err := doWork(ctx)
Линтер govet по умолчанию ругается на это с тегом lostcancel. Если cancel-функцию не вызвать, ресурсы внутри контекста (таймер, дочерние горутины контекстов) живут до отмены родителя. На длинно живущем root-контексте это утечка памяти.
Правильно — всегда defer cancel:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Даже если doWork отрабатывает быстрее таймаута, вызов cancel дешёв и гарантирует освобождение ресурсов.
Ошибка 7: контекст и горутины
Запустил горутину с go worker(ctx) — и пошёл дальше. Через секунду функция вернулась, ctx отменился. Воркер обнаружил отмену и тоже завершился. Только вот когда именно — никто не знает.
Если тебе важно дождаться завершения — нужен sync.WaitGroup или errgroup. ctx.Done() сигнализирует о начале остановки, а не о завершении. Эта разница на тестах часто не проявляется, а на проде даёт race condition при shutdown.
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return worker1(gctx) })
g.Go(func() error { return worker2(gctx) })
if err := g.Wait(); err != nil {
return err
}
Что в итоге
Контекст — простой по форме, сложный по применению инструмент. Несколько мнемоник, которые помогают:
- Контекст — это про отмену и таймауты, а не про передачу данных. WithValue — исключение, не правило.
- Контекст всегда первый аргумент. Если он не первый, у тебя где-то проблема со структурой кода.
- Получил ctx — пробрось его дальше во все блокирующие вызовы. Если не пробросил — поясни в комментарии, почему.
- Контекст не хранится в структурах, кроме корневого ctx приложения. И это всегда обсуждается на ревью.
Чтение исходников net/http и database/sql здорово прокачивает понимание. Эти пакеты местами выглядят сложно из-за поддержки контекста, но именно в них видно, как Go-команда сама применяет свои же правила.