golang

context.Context в Go. Суть и частые ошибки

  • понедельник, 7 сентября 2026 г. в 00:00:12
https://habr.com/ru/articles/1078940/

Введение

Привет, Хабр! Сегодня поговорим о контексте: о его тонкостях и ошибках, которые возникают при его использовании.

Что такое контекст?

context.Context - это механизм для управления временем жизни операций, который появился в 2016 году в стандартной библиотеке начиная с Go 1.7. При этом то, что чаще всего называют контекстом (сам тип) является интерфейсом:

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

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

Использование контекста

В Go контекст, как правило, передаётся первым аргументом в сигнатуре функции. Это важно из соображения ответственности, которая лежит на нём, как на основном механизме взаимодействия с функциями,которые находятся выше по стеку вызовов. Как пример функции, которая использует контекст можно рассмотреть функцию из стандартной библиотеки Go, пакета database/sql, которая совершает SQL запрос (как правило SELECT или какой-либо другой) и возвращает не более одной строки:

func (db *DB) QueryRowContext(ctx context.Context, query string, args ...any) *Row 

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

Создание контекста - корневой контекст

Корневой контекст - это контекст, который невозможно отменить, так как он не содержит значений и не имеет времени завершения. Он лишь создаётся в корне вашей программы (как правило это функция main), оборачивается в другой, с уже имеющимися данными о "сроке годности" и произвольными значениями и передаётся вниз по стеку вызовов. В случае, когда контекст отменяется в main (к примеру при gracefull shutdown), отменятся и все операции, связанные с контекстом в глубине программы.

Корневой контекст задаётся с помощью двух функций:

  • context.Background()

  • context.TODO()

Эти функции дают одинаковый контекст, только важно учитывать, что TODO() используется часто при проектировании и разработке прототипов. Если рассматривать исходный код библиотеки, можно заметить, что реализованы они достаточно просто:

func Background() Context {
	return backgroundCtx{}
}

func TODO() Context {
	return todoCtx{}
}

Обе функции возвращают пустой контекст (emptyCtx), который не содержит ни времени, ни записанных значений. Разница между ними исключительно смысловая, TODO() создан для того, чтобы разработчик мог заявить о своих намерениях,что код не является "production ready" и о том, что код исключительно экспериментальный, а также для того, чтобы не думать в текущий момент, какой именно контекст ему использовать.

Как уже было сказано выше, что все производные контексты создаются от родительских. Как правило, новый контекст расширяет родительский новой (своей) информацией. Важно понимать, что каждый контекст при отмене высвобождает связанные с ним ресурсы, по этому код должен вызывать cancel сразу после завершения операций, выполняемых в этом контексте. Для создания новых контекстов в стандартной библиотеке предусмотрены ряд функций, которые будут рассмотрены ниже:

  • WithCancel

  • WithDeadline

  • WithTimeout

  • WithValue

  • WithoutCancel

Контекст WithCancel возращает проивзводный (дочерний) контекст, который указывает на родительский, но имеет новый Done. Канал Done закрывается при вызове функции отмены или при закрытии канала Done у родительского контекста. Таким образом, мы создаём новую точку, где можно будет "сбросить" контекст и остановить операцию.

Ниже представлен исходный код этой функции:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := withCancel(parent)
	return c, func() { c.cancel(true, Canceled, nil) }
}

Контекст WithDeadline возвращает производный контекст, который указывает на родительский, но с изменённым сроком завершение (не позднее d). В случае, если срок родительского контекста наступил раньше дочернего, то срок дочернего автоматически становится равен сроку родителя. Возвращаемый канал Done закрывается по истечению крайнего срока, при вызове функции отмены или при закрытии канала Done родительского контекста.

Контекст WithTimeout возвращает производный контекст, который указывает на родительский, но с жёстким лимитом времени. К примеру, если операция длиться дольше, чем вы готовы ждать, то контекст WithTimeout её автоматически отменит. По факту "под капотом" контекст WithTimeout является лишь обёрткой над WithDeadline, а различия между ними в том, что WithTimeout принимает продолжительность (time.Duration), а WithDeadline конкретную временную метку (time.Time).

Контекст WithValue возвращает производный контекст, который указывает на родительский, но добавляет свои данные. Важно использовать данные, которые действительны только для текущей области действия запроса!
Вообще WithValue часто называют антипаттерном из-за неправильного использования. В официальной документации Go чётко прописано, что WithValue предназначен только для транзитных данных, которые относятся к самому процессу выполнения запроса. Он не должен использоваться для передачи обычных параметров функций!

Вот что можно передавать:

  • ID запроса, чтобы логировать путь одного конкретного запроса.

  • Полезные данные авторизации

  • Логер

Вот что нельзя передавать:

  • Опциональные параметры функций: к примеру ID-товара.

  • Зависимости приложения: базы данных, кэш, конфигурация.

Контекст WithoutCancel возвращает производный контекст, который указывает на родительский контекст и не отменяется при отмене родительского контекста. Основным смыслом является позволить дочерней задаче (к примеру фоновому сохранению логов) завершить свою работу, даже если основной запрос пользователя был отменён или упал по таймауту.
Раньше (до версии 1.21) разработчикам приходилось открывать чистый context.Background(), но при этом терялись важные данные и их приходилось переносить вручную, сейчас же эту роль играет WithoutCancel.

Пример: Отмена дочернего контекста при отмене родительского

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	// Запускаем задачу:
	go func() {
		result := starterA(ctx)
		fmt.Printf("Result: %s", result)
	}()

	// Спустя 4 секунды закрываем контекст:
	// отмена передастся дочернему контексту starterA, 
	// по этому slowWorkA прекратит работу
	go func() {
		time.Sleep(4*time.Second)
		cancel()
	}()


	// Ждём 6 секунд:
	time.Sleep(6*time.Second)
}


// Функция запускает медленную операцию
func starterA(ctx context.Context) string{
	// Контекст действует 6 секунд:
	ctx, cancel := context.WithTimeout(ctx, time.Duration(6*time.Second))
	defer cancel()
	result := slowWorkA(ctx)
	if result {
		return "Успешно!"
	}else{
		return "Ошибка!"
	}
}


// Медленная операция, выполняется 5 секунд:
func slowWorkA(ctx context.Context) bool {

	for i := 0; i < 5; i++ {
		time.Sleep(time.Second)
		if ctx.Err() != nil {
			return false
		}
	}
	
	return true
}

В этом тривиальном примере реализовано взаимодействие контекстов разного уровня для управления временем жизни операции. Код в этом случае выведет "Ошибка!" в консоль, так как вторая горутина ограничивает время жизни контекста до четырёх секунд, а значит и производный контекст примет это же время, несмотря на собственное ограничение в целых 6 секунд.

Ошибки при использовании context.Context

Как правило ошибки допускают по причине того, что этот механизм (механизм управления временем жизни операции) легко превратить в склад для параметров, а также потерять контекст во время написания кода.

Ниже указаны основные случаи ошибок:

1) Класть бизнес-параметры в Context: этот момент уже был ранее рассмотрен в WithValue, но стоит повторить, что контекст в первую очередь предназначен для транзитных данных!

2) Использование Context как dependency injection: иногда встречаются случаи, когда контекст используют для внедрения зависимостей:

ctx = context.WithValue(ctx, dbKey{}, db)
ctx = context.WithValue(ctx, loggerKey{}, logger)
ctx = context.WithValue(ctx, configKey{}, config)
ctx = context.WithValue(ctx, metricsKey{}, metrics)

А после этого:

func Foo(ctx context.Context) {
  db := ctx.Value(dbKey{}).(*sql.DB) 
  logger := ctx.Value(loggerKey{}).(*slog.Logger)
}

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

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

4) Забыть про cancel(): очередная частая ошибка при работы с контекстами. Нужно запомнить, что в Go все операции с контекстами должны быть закрыты. То есть, если вы создавали контекст, то обычно необходимо вызывать defer cancel(), даже если вы уверены, что сработает WithTimeout().
5) Проверка контекста не в том месте: как уже понятно из этой статьи, контекст - это не какая-то магическая функция в Go, она сама по себе не способна предотвратить выполнение при смерти контекста - это ваша задача. Для этого необходимо не только самостоятельно проверять контекст, но и грамотно производить проверку.

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

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

func Foo(ctx context.Context) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	
	// Здесь уже основная операция
	
	return nil
}
  • Самый понятный и логичный случай - проверка между этапами работы. Вот пример, если функция делает несколько разных операций:

func Foo(ctx context.Context) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	// Первая операция:
	loadData()
	
	if err := ctx.Err(); err != nil {
		return err
	}
	// Вторая операция:
	processData()
	
	if err := ctx.Err(); err != nil {
		return err
	}
	
	// Третья операция:
	saveData()
	
	return nil
}

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

  • Случай, если функция блокируется чем-то:

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

Здесь уже появляется работа с select. В этом случае либо дождались результата, либо Context отменился и мы перестаём ждать.

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

func getUser(ctx context.Context, id int) (*User, error) {
	return db.QueryRowContext(ctx, "...", id) // Здесь модуль сам принимает контекст
}

Здесь проверка уместна в случае, если ваша функция планирует делать ещё что-то, помимо подобного запроса:

func userProcess(ctx context.Context, id int) error {
	user, err db.QueryRowContext(ctx, "...", id) // Здесь модуль сам принимает контекст
	if err != nil{ 
		return err
	}
	
	
	if err := ctx.Err(); err != nil {
		return err
	}
	
	// Вторая операция:
	isTrue := CheckPass(user)
	if !isTrue {
		return ...
	}
	return nil
}

Заключение

Тема контекста в Go является основной для написания различных операций, которые могут быть отклонены по причине сбоя или окончания таймаута, а также для работы с gracefull shutdown - немаловажной темой при разработке различных программ, которая позволяет сделать поэтапное отключение и завершение всех операций без потерь и обрывов.

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