lenec ru

← все посты

context.Context в Go: типичные ошибки и как с ними жить

17K

За девять лет работы с 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-команда сама применяет свои же правила.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.