Context.Context в Go: правильная реализация отмены, таймаутов и передачи значений

Context в Go — это управление потоком выполнения, а не хранилище.

Содержимое страницы

Go context.Context настолько прост, что им легко пользоваться неправильно — и в этом как раз и проблема.

Большинство разработчиков на Go быстро осваивают поверхностные правила: передавать context как первый аргумент, проверять ctx.Done(), использовать context.WithTimeout и никогда не передавать nil.

func DoSomething(ctx context.Context) error {
	// ...
}

Эти правила полезны, но они описывают лишь простую часть. В продакшен-сервисах context — это не просто конвенция для параметров, а плоскость управления (control plane) временем жизни запроса.

Go context: отмена распространяется по цепочке вызовов

Context сообщает работе, когда ей нужно остановиться, сколько у неё осталось времени, какой путь отмены был пройден и какие значения, привязанные к запросу, должны пересечь границы API. При правильном использовании он предотвращает утечки горутин, избегает бесполезной работы, распространяет сроки выполнения (deadlines) и облегчает остановку сервисов. При неправильном использовании он превращается в мешок скрытых зависимостей, псевдоглобальных переменных, забытых таймаутов, утечек таймеров и запутанного поведения отмены.

Чуть более субъективная позиция такова: используйте context для отмены, дедлайнов и метаданных, привязанных к запросу, но не используйте его как контейнер зависимостей.

Для чего предназначен context

У пакета context три основные задачи: отмена, дедлайны и таймауты, а также значения, привязанные к запросу. Эти три задачи покрывают всё, для чего он был создан.

Context должен отвечать на такие вопросы:

Отменён ли этот запрос?
Сколько времени осталось на эту операцию?
Какой идентификатор запроса (request ID) нужно прикрепить к логам?
Какой аутентифицированный пользователь связан с этим запросом?

Context не должен отвечать на такие вопросы:

Где находится моё соединение с базой данных?
Где находится мой логгер?
Где находится моя конфигурация?
Какая реализация сервиса должна использоваться?

Это зависимости — передавайте их явно через параметры функций (см. Инъекция зависимостей в Go с паттернами для чистого решения этого вопроса). Context нужен для времени жизни запроса и метаданных запроса, а не для связывания приложения.

Базовая структура context

Базовый интерфейс небольшой:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

Важные части:

  • Done() закрывается, когда context отменён или истёк его дедлайн.
  • Err() объясняет, почему context завершился.
  • Deadline() сообщает, есть ли у context дедлайн.
  • Value() хранит данные, привязанные к запросу.

Большинство кода не реализует этот интерфейс. Оно просто получает context и передаёт его дальше.

Первое правило: передавайте context явно

Для функций, выполняющих работу, привязанную к запросу или отменяемую, передавайте context первым параметром — это стандартная конвенция в Go и то, чего ожидает каждая библиотека и инструмент в экосистеме:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Делайте это для функций, которые могут:

  • Вызывать базу данных
  • Вызывать другой сервис
  • Ожидать очереди
  • Запускать фоновую работу
  • Блокироваться на вводе-выводе (I/O)
  • Использовать таймаут
  • Нуждаться в значениях, привязанных к запросу
  • Нуждаться в отмене

Не добавляйте context в маленькие чистые функции, которым он не нужен.

Вот так нормально:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

Не каждой функции нужен context. Добавление context везде делает код шумным.

Не храните context в структурах

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

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

Вместо этого делайте так:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Context принадлежит запросу, операции или задаче, тогда как структура сервиса обычно живёт гораздо дольше любого отдельного запроса. Смешение этих циклов жизни делает отмену неоднозначной и затрудняет рассуждения о том, к какой операции относится context.

Существуют редкие исключения для типов, которые действительно представляют цикл жизни одной операции, но они настолько редки, что правило по умолчанию должно быть простым:

Передавайте context. Не храните его.

Не передавайте nil context

Никогда не передавайте nil в качестве context.

Плохо:

err := svc.DoWork(nil)

Используйте context.Background(), когда существующего context нет:

err := svc.DoWork(context.Background())

В тестах, по возможности, используйте context теста:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

Nil context может вызвать панику, если код вызывает методы на нём. Background context явностен и безопасен.

Background, TODO и контексты запросов

Существуют три основных стартовых пункта.

context.Background

Используйте context.Background() на верхнем уровне программы, когда родительский context отсутствует, — это корневой context, от которого производятся все дочерние контексты:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

или:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

Используйте context.TODO(), если вы знаете, что context должен быть использован, но ещё не решили, какой именно.

ctx := context.TODO()

Это полезно во время миграции, но не должно становиться постоянным, если существует реальный context.

Контекст запроса

В HTTP-серверах используйте контекст запроса:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

Контекст запроса отменяется, когда закрывается соединение клиента, отменяется запрос или сервер завершает обработку запроса.

Для веб-сервисов это обычно тот context, который следует передавать вниз в прикладной код.

Отмена с помощью context.WithCancel

Используйте context.WithCancel, если вы хотите явно остановить работу.

ctx, cancel := context.WithCancel(parent)
defer cancel()

Возвращаемая функция cancel отменяет дочерний context и освобождает связанные с ним ресурсы. Всегда вызывайте её, когда работа будет выполнена, — даже если context в итоге истечёт по таймауту, ранний вызов cancel позволяет избежать удержания ресурсов дольше необходимого.

Пример:

func RunWorker(parent context.Context) error {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	done := make(chan error, 1)

	go func() {
		done <- doBackgroundWork(ctx)
	}()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case err := <-done:
		return err
	}
}

Паттерн прост:

  • Создайте дочерний context.
  • Используйте defer для cancel.
  • Передайте дочерний context работе, которая должна остановиться вместе.
  • Наблюдайте за ctx.Done().

Таймауты с помощью context.WithTimeout

Используйте context.WithTimeout, когда у операции есть максимальная длительность.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Пример с HTTP-клиентом:

func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

Это делает таймаут частью операции, а не скрытым глобальным параметром.

Всегда вызывайте cancel

Когда вы вызываете WithCancel, WithTimeout или WithDeadline, всегда вызывайте возвращаемую функцию cancel — это важно для корректности.

Хорошо:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

Плохо:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

Невызов cancel может удерживать таймеры и дочерние контексты активными дольше, чем нужно.

Каналы таймеров также являются частью контракта: начиная с Go 1.27 каналы, стоящие за time.After и time.NewTimer, всегда буферизованны (unbuffered), и старый вариант отключения asynctimerchan больше не компилируется со значением, действовавшим до 1.23. Гайд по обновлению до Go 1.27 упоминает эту настройку наряду с другими удалёнными значениями GODEBUG, которые ломают сборку.

Дедлайны против таймаутов

Таймаут относительный:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Дедлайн абсолютный:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

Большинство прикладного кода использует таймауты. Дедлайны полезны, когда у запроса есть фиксированное время окончания, которое должно быть общим для нескольких операций. Например, если у запроса осталось 900 миллисекунд, не давайте каждому下游ному вызову свежий таймаут в 1 секунду; вместо этого распространяйте оставшийся бюджет.

Бюджеты таймаутов между слоями сервиса

Частая ошибка — слепое наслоение таймаутов.

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	_ = service.DoWork(ctx)
}

func (s *Service) DoWork(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	return s.repo.Query(ctx)
}

Это выглядит безобидно, но скрывает реальный бюджет. Слой сервиса обычно должен уважать дедлайн вызывающей стороны, а не сбрасывать таймер до того же значения.

Лучший паттерн:

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	if err := service.DoWork(ctx); err != nil {
		// обработать ошибку
		return
	}
}

Затем внутри сервиса:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

Добавляйте дочерний таймаут только тогда, когда подоперация нуждается в меньшем бюджете:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

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

Проверяйте ctx.Err(), чтобы отличить отмену от таймаута

Когда context завершается, ctx.Err() возвращает причину.

Обычно это одно из:

context.Canceled
context.DeadlineExceeded

Пример:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

Это позволяет вызывающим сторонам различать отмену и таймаут, и это различие важно на практике. Отменённый запрос часто означает, что клиент отключился, тогда как ошибка превышения дедлайна обычно означает, что ваш сервис был слишком медленным — их не следует всегда логилировать, повторять или сообщать одинаково.

Используйте context.Cause для лучших причин отмены

Современный Go также поддерживает отмену с причиной (cause-aware cancellation).

Полезные функции включают:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

Обычный ctx.Err() сообщает о широкой причине: отменён или превышен дедлайн.

context.Cause(ctx) может сообщить более конкретную причину.

Пример:

var ErrShutdown = errors.New("сервер завершает работу")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// Поступил сигнал завершения.
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

Используйте отмену с причиной, когда причина важна для вызывающих сторон, логов или поведения очистки, и избегайте её там, где обычного ctx.Err() достаточно, — дополнительные детали оправданы только тогда, когда диагностика действительно этого требует.

Пример HTTP-сервера

Обычный HTTP-обработчик должен начинаться с r.Context(). Для полного разбора структурирования HTTP-сервисов на Go см. Создание REST API в Go. Gin, Echo и Fiber обвязывают context.Context внутри своего собственного типа контекста запроса, а различия между стеками сравниваются в Веб-фреймворки для Go в 2026: сравнение net/http, chi, Gin, Echo, Fiber.

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		id := r.PathValue("id")

		user, err := svc.GetUser(ctx, id)
		if err != nil {
			writeError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

Сервис должен принимать и распространять context:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Репозиторий должен использовать методы базы данных, поддерживающие context:

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		return nil, err
	}

	return &user, nil
}

Важна цепочка — каждый слой передаёт один и тот же context дальше:

flowchart TD A[Контекст HTTP-запроса] --> B[Обработчик] B --> C[Сервис] C --> D[Репозиторий] D --> E[Запрос к БД]

Не разрывайте цепочку, создавая context.Background() посередине.

Ошибка с context.Background(): разрыв цепочки отмены

Это частый баг:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

Это отбрасывает всю информацию об отмене и дедлайнах от вызывающей стороны. Если клиент отключается, запрос к базе данных продолжает выполняться. Если запрос истекает по таймауту, работа ниже по цепочке может всё ещё находиться в процессе. Если сервер завершает работу, этот код полностью игнорирует это. Замена полученного context на context.Background() внутри бизнес-логики почти всегда неверно.

Используйте тот context, который вам дали:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Используйте context.Background() только на границе, где нет родительского context.

Пример HTTP-клиента

Для исходящих HTTP-запросов привяжите context к запросу.

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

Не делайте так:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

Это создаёт запрос без контекста операции.

Также избегайте надежды только на http.Client.Timeout. Он может быть полезен как ограничитель безопасности, но контексты запросов дают лучшую распространяемость по цепочке вызовов.

Частый паттерн:

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

Используйте это, когда вызов нижестоящего API имеет конкретный бюджет внутри более крупного запроса.

Пример с базой данных

У большинства API баз данных на Go есть методы, поддерживающие context. Для более широкого взгляда на то, как библиотеки доступа к данным Go обрабатывают context — включая GORM, Ent, Bun и sqlc — см. Сравнение Go ORM для PostgreSQL.

Используйте их.

Хорошо:

rows, err := db.QueryContext(ctx, query, args...)

Хорошо:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

Хорошо:

result, err := db.ExecContext(ctx, query, args...)

Плохо:

rows, err := db.Query(query, args...)

Формы с поддержкой context позволяют операциям базы данных останавливаться, когда запрос отменяется или истекает по таймауту, что особенно важно для медленных запросов, перегруженных баз данных и API, ориентированных на пользователя, где латентность напрямую влияет на пользовательский опыт.

Транзакции и context

Транзакции требуют осторожного обращения с context.

Транзакция обычно должна начинаться с контекста операции:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

Затем используйте тот же context для операций транзакции:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

Будьте осторожны с таймаутами вокруг транзакций. Если context отменяется до Commit, транзакция может быть откатана. Это может быть тем, что вам нужно, но это должно быть осознанным решением.

Для длинных транзакций лучшим ответом обычно является не более длительный таймаут, а более короткая транзакция, которая выполняет меньше работы на единицу.

Фоновые воркеры и context

Фоновые воркеры должны получать context, представляющий их время жизни.

Пример:

type Worker struct {
	logger *slog.Logger
}

func (w *Worker) Run(ctx context.Context) error {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()

		case <-ticker.C:
			if err := w.doOnce(ctx); err != nil {
				w.logger.Error("итерация воркера не удалась", "err", err)
			}
		}
	}
}

Этот воркер корректно останавливается, когда context отменяется, и его тикер правильно очищается через defer ticker.Stop(). В main вы создадите корневой context, привязанный к сигналам ОС:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	worker := &Worker{logger: slog.Default()}

	if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("воркер остановлен", "err", err)
	}
}

Это правильный использование context: он описывает время жизни процесса работы, и когда ОС отправляет сигнал, всё дерево горутин, разделяющих этот context, остановится вместе.

Предотвращение утечек горутин с помощью отмены context

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

Context помогает предотвратить это.

Плохо:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

У этой горутины нет пути остановки.

Лучше:

func StartWorker(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				doWork()
			}
		}
	}()
}

Любая горутина, которая находится в цикле, почти всегда должна иметь путь отмены.

Это не означает, что каждая горутина должна получать context напрямую, но система должна иметь чёткий способ её остановить.

context.AfterFunc

context.AfterFunc запускает функцию после отмены context.

Он может быть полезен для очистки, разблокировки операций или моста между API, которые не поддерживают context нативно.

Пример:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// Проснуться или выполнить очистку, если необходимо.
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

Используйте AfterFunc осторожно, — он запускает логику при отмене, что может сделать поток управления сложнее для отслеживания. Для большинства прикладного кода обычный select на ctx.Done() яснее и проще для рассуждений. AfterFunc наиболее ценен, когда нужно адаптировать отмену context к API, которое не принимает context.

context.WithoutCancel

context.WithoutCancel создаёт context, который не отменяется, когда отменяется родитель.

Это полезно, но легко им злоупотребить.

Пример использования:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Обработать запрос...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "запрос завершён")
		}()
	}
}

Идея в том, что запись аудита может потребоваться немного времени, даже после того, как context запроса отменён. Это должно быть редким и осознанным решением, — не используйте WithoutCancel как способ избежать обращения с отменой. Используйте его только тогда, когда дочерняя работа действительно должна пережить отмену родителя, и всегда добавляйте новый таймаут: context, который игнорирует отмену, но не несёт дедлайна, может легко создать фоновые утечки горутин.

Значения context, сделанные правильно

Значения context предназначены для данных, привязанных к запросу, которые пересекают границы API.

Хорошие примеры:

  • идентификатор запроса
  • идентификатор трассировки
  • идентификатор аутентифицированного пользователя
  • идентификатор арендатора (tenant ID)
  • локаль
  • субъект безопасности
  • мета-данные корреляции

Плохие примеры:

  • соединение с базой данных
  • логгер как скрытая зависимость
  • флаги функций для обычного потока управления
  • необязательные параметры функций
  • конфигурация
  • клиенты сервисов

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

Используйте типизированные ключи для значений context

Не используйте обычные строки в качестве ключей context.

Плохо:

ctx = context.WithValue(ctx, "userID", "123")

Это может столкнуться с другими пакетами.

Используйте неэкспортируемый пользовательский тип ключа:

type userIDKey struct{}

func WithUserID(ctx context.Context, userID string) context.Context {
	return context.WithValue(ctx, userIDKey{}, userID)
}

func UserIDFromContext(ctx context.Context) (string, bool) {
	userID, ok := ctx.Value(userIDKey{}).(string)
	return userID, ok
}

Этот паттерн обеспечивает безопасность типов на границе пакета, избегает столкновений ключей с другими пакетами и сохраняет чистым API поверхности context с типизированными функциями доступа.

Не используйте значения context для необязательных параметров

Это плохо:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

Это скрывает контракт функции.

Предпочтительнее явные параметры:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

Значения context не должны заменять аргументы функций. Скрытый ввод делает код сложнее для понимания, тестирования и ревью, — и любой, читающий сигнатуру функции, не будет знать, что параметр вообще существует.

Логирование и context

Существуют два общих подхода к логированию с context. Примеры ниже используют пакет log/slog в Go, — для более глубокого погружения в структурированное логирование с slog в продакшен-сервисах см. Структурированное логирование в Go с slog.

Подход 1: Извлекайте значения и прикрепляйте их к логам

func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
	if requestID, ok := RequestIDFromContext(ctx); ok {
		logger = logger.With("request_id", requestID)
	}

	logger.Info(msg)
}

Это сохраняет логгер явным как правильную зависимость и использует context только для значений, привязанных к запросу, которые легитимно должны пересекать границы API.

Подход 2: Храните логгер в context

Некоторые кодовые базы хранят логгер в context.

Это может быть удобно, но я не рекомендую это как вариант по умолчанию. Это превращает context в контейнер зависимостей.

Моё предпочтение:

  • Передавайте зависимости логгера явно.
  • Храните идентификаторы трассировки и идентификаторы запросов в context.
  • Добавляйте эти значения в логи на границах или в промежуточном ПО.

Это сохраняет зависимости видимыми.

Context и трассировка

Трассировка является одним из сильнейших случаев использования значений context, и это действительно хорошее соответствие. OpenTelemetry и похожие системы используют context для распространения прослеживаемых сегментов (trace spans) через вызовы функций и границы процессов, потому что данные трассировки — это именно тот тип метаданных, привязанных к запросу, который context был создан нести.

Типичный паттерн выглядит так:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

Context несёт активный сегмент трассировки, и репозиторий может создать дочерний сегмент из него. Каждый слой добавляет свой сегмент без явной передачи объектов трассировщика, — context делает эту работу прозрачно по всему дереву вызовов.

Обработка ошибок с context

Когда операция останавливается из-за отмены context, сохраняйте эту информацию. Паттерны здесь дополняют более широкие стратегии проектирования ошибок, рассмотренные в Архитектура обработки ошибок в Go.

Пример:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// Клиент отменил или вызывающая сторона остановила работу.
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// Таймаут.
		return err
	}

	return err
}

Не обёртывайте ошибки context бездумным образом, скрывая их.

Обёртка с %w сохраняет errors.Is, так что вызывающие стороны всё ещё могут обнаруживать отмену или таймаут:

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

Полная замена ошибки отбрасывает эту информацию и ломает любого вызывающего, который проверяет конкретные типы ошибок context:

if err != nil {
	return errors.New("query user failed")
}

Маппинг ошибок context на HTTP-ответы

Ошибки context часто маппятся на разные HTTP-результаты.

Пример:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// Клиент, вероятно, ушёл.
		// Некоторые системы логгируют это как закрытый клиентом запрос.
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)
		return

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

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

Context в промежуточном ПО (middleware)

HTTP-промежуточное ПО — это обычное место для добавления значений, привязанных к запросу.

Пример middleware для идентификатора запроса:

type requestIDKey struct{}

func WithRequestID(ctx context.Context, requestID string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, requestID)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	requestID, ok := ctx.Value(requestIDKey{}).(string)
	return requestID, ok
}

func RequestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestID := r.Header.Get("X-Request-ID")
		if requestID == "" {
			requestID = newRequestID()
		}

		ctx := WithRequestID(r.Context(), requestID)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Это хорошее использование context. Идентификатор запроса принадлежит запросу, он должен проходить через всю цепочку вызовов, и прикрепление его к логам и трассировкам на каждом слое — это именно тот тип сквозной проблемы наблюдаемости, который значения context созданы поддерживать.

Context в тестах

В тестах избегайте слепого использования context.Background().

Предпочитайте t.Context(), если работа принадлежит времени жизни теста:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

Для поведения таймаутов тестируйте с реальным таймаутом только если таймаут малый и осмысленный.

Для конкурентного и зависимого от времени кода рассмотрите использование testing/synctest, — Тестирование конкурентного кода Go с synctest подробно рассматривает этот инструмент:

func TestTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
		defer cancel()

		time.Sleep(30 * time.Second)

		if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
			t.Fatalf("got %v, want deadline exceeded", ctx.Err())
		}
	})
}

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

Context и errgroup

Для групп горутин, которые должны отменяться вместе, errgroup часто является хорошим выбором.

errgroup координирует отмену, но сам по себе ничего не ограничивает: без ограничения цикл из тысячи элементов запускает тысячу горутин. Ограничение ёмкости — SetLimit, каналы семафоров, пулы воркеров и форма утечки, которая переживает Wait, — рассматривается в Ограничение конкурентности в Go.

Пример:

func FetchAll(ctx context.Context, ids []string, client *Client) error {
	g, ctx := errgroup.WithContext(ctx)

	for _, id := range ids {
		id := id

		g.Go(func() error {
			_, err := client.Fetch(ctx, id)
			return err
		})
	}

	return g.Wait()
}

Если одна горутина возвращает ошибку, context группы отменяется, и другие горутины, которые уважают ctx.Done(), могут остановиться раньше. Это гораздо чище, чем ручное управление множеством горутин, каналов и путей отмены. Ключевая фраза здесь — “уважайте context”, — errgroup не может остановить работу, которая игнорирует ctx.Done().

Корректное завершение работы (Graceful Shutdown)

Context является центральным для корректного завершения работы.

Типичная настройка сервера включает:

  • корневой context, отменяемый сигналами ОС
  • HTTP-сервер
  • фоновые воркеры
  • таймаут завершения
  • логику очистки

Пример:

func main() {
	root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	server := &http.Server{
		Addr:    ":8080",
		Handler: routes(),
	}

	go func() {
		<-root.Done()

		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		if err := server.Shutdown(shutdownCtx); err != nil {
			slog.Error("ошибка завершения сервера", "err", err)
		}
	}()

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("сервер не удалось запустить", "err", err)
		os.Exit(1)
	}
}

Обратите внимание, что context завершения не является тем же, что и корневой context, — корневой уже отменён, когда поступает сигнал ОС. Отдельный context с таймаутом даёт процессу завершения ограниченное количество времени для дренажа (обрабатывания оставшихся) запросов перед принудительным выходом, что является тонким, но важным различием, делающим корректное завершение работающим.

Общие антипаттерны

Антипаттерн 1: Использование context как контейнера зависимостей

Плохо:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

Передавайте зависимости явно.

Антипаттерн 2: Создание context.Background внутри бизнес-логики

Плохо:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

Это ломает распространение отмены.

Антипаттерн 3: Забывание вызвать cancel

Плохо:

ctx, _ := context.WithTimeout(parent, time.Second)

Хорошо:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

Антипаттерн 4: Размещение необязательных параметров в context

Плохо:

ctx = context.WithValue(ctx, "includeDeleted", true)

Используйте явные структуры опций.

Антипаттерн 5: Передача context слишком глубоко в чистый код

Плохо:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

Чистые вычисления не нуждаются в context, если они не являются длинными или отменяемыми.

Антипаттерн 6: Игнорирование отмены в циклах

Плохо:

for item := range items {
	process(item)
}

Лучше:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

Антипаттерн 7: Глотание ошибок context

Плохо:

if err != nil {
	return errors.New("operation failed")
}

Хорошо:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

Сохраняйте ошибки отмены и дедлайнов.

Практический чек-лист по context

Используйте этот чек-лист для бэкенд-кода на Go.

Сигнатуры функций

  • Context — первый параметр.
  • Context не хранится в долгоживущих структурах.
  • Context не передаётся в чистые вспомогательные функции, если это не нужно.
  • Nil context никогда не используется.

Отмена

  • Длинные циклы проверяют ctx.Done().
  • У горутин есть путь остановки.
  • Время жизни воркеров привязано к родительскому context.
  • Отмена context распространяется на нижестоящие вызовы.

Таймауты

  • Внешние таймауты запроса устанавливаются на границе.
  • Таймауты подопераций меньше внешнего бюджета.
  • Функции cancel всегда вызываются.
  • Таймауты не накладываются слепо на каждом слое.

Значения

  • Значения context привязаны к запросу.
  • Ключи используют пользовательские типы, а не обычные строки.
  • Зависимости не хранятся в context.
  • Необязательные параметры не хранятся в context.

Ошибки

  • context.Canceled и context.DeadlineExceeded сохраняются.
  • Ошибки context корректно маппятся на границах API.
  • Отмена с причиной используется только тогда, когда причина важна.

Тесты

  • Тесты используют t.Context(), где уместно.
  • Тесты таймаутов избегают медленных реальных снов.
  • Конкурентное поведение таймаутов тестируется с testing/synctest, когда это полезно.
  • Утечки горутин проверяются путём обеспечения существования путей остановки.

Как аудитировать использование context в кодовой базе Go

Ищите эти паттерны:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

Затем задавайте вопросы:

  • Используется ли context.Background() только на верхнеуровневых границах?
  • Всегда ли вызываются функции cancel?
  • Размещены ли таймауты на разумных границах?
  • Действительно ли значения context привязаны к запросу?
  • Скрыты ли зависимости в значениях context?
  • Можно ли остановить горутины?
  • Сохраняются ли ошибки context?

Это хорошая привычка при ревью кода, потому что многие ошибки с context — это не ошибки синтаксиса, а ошибки времени жизни, которые проявляются только при отмене, нагрузке или условиях завершения.

Мои субъективные правила

Эти правила скучны, но они работают.

Правило 1: Context — это поток управления

Используйте context для управления отменой, дедлайнами и метаданными запроса.

Не используйте его для контрабанды зависимостей.

Правило 2: Вызывающая сторона владеет бюджетом

Функция обычно должна уважать полученный context.

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

Правило 3: Background принадлежит на границе

Используйте context.Background() в main, тестах и настраивании верхнего уровня.

Не используйте его внутри методов сервисов и репозиториев для убега от отмены.

Правило 4: Значения должны быть скучными

Идентификатор запроса, идентификатор трассировки, идентификатор пользователя и идентификатор арендатора принадлежат в context. Соединения с базой данных, логгеры, структуры конфигурации и клиенты сервисов не принадлежат — это зависимости, и их следует передавать явно.

Правило 5: Каждая горутина нуждается во времени жизни

Если горутина запускается, вы должны точно знать, как она останавливается. Context часто является правильным ответом, и если это не context, то должен быть какой-то другой чёткий механизм, — канал, примитив синхронизации или явный сигнал.

Финальные мысли

context.Context не сложен, потому что API большой, — API мал. Он сложен, потому что представляет время жизни, а время жизни — это архитектура. Каждое решение о том, где течёт context, откуда он производится и где он останавливается, является решением о том, как ваш сервис обрабатывает сбои, нагрузку и завершение.

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

Практический вывод прост:

Передавайте context вниз.
Не храните его.
Не заменяйте явные параметры значениями.
Уважайте отмену.
Используйте таймауты на границах.
Всегда вызывайте cancel.

Это правильный Go context.

Эта статья является частью кластера Архитектура приложений в продакшене, который охватывает структуру кода, доступ к данным, паттерны интеграции и архитектуру тестирования для продакшен-систем на Go и Python.

Источники

Подписаться

Получайте новые материалы про системы, инфраструктуру и AI engineering.