Menu

context в Golang: отмена, таймауты и значения

Как context.Context передаёт через программу на Go отмену, дедлайны и значения уровня запроса: Background, WithCancel, WithTimeout, WithValue, ctx.Done в select и context в HTTP-серверах и клиентах.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Таймаут в десяти строках

Задача context.Context в том, чтобы сообщить коду, когда остановиться. Здесь медленная операция получает 50 мс и сдаётся, когда об этом говорит контекст:

Первый вызов завершается за 10 мс и возвращает rows <nil>. Второму нужно 200 мс, но контекст истекает на 50 мс (считая от момента создания), поэтому он возвращает context deadline exceeded.

Ничто не останавливается принудительно. В Go нет способа убить горутину снаружи. Контекст это сигнал, и код должен его проверять: делать select по ctx.Done(), проверять ctx.Err() между шагами или передавать ctx в библиотечные вызовы (http.NewRequestWithContext, db.QueryContext, exec.CommandContext), которые проверяют его за вас.

Интерфейс Context

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
МетодВозвращает
Done()канал, который закрывается, когда контекст отменён или истёк (nil для контекста, который отменить нельзя)
Err()nil, пока контекст активен, затем context.Canceled или context.DeadlineExceeded
Deadline()дедлайн и true либо ok == false, если дедлайна нет
Value(key)значение, сохранённое под key в этом контексте или предке, либо nil

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

Откуда берётся контекст

Каждое дерево контекстов начинается с корня:

  • context.Background() для main, init, тестов и настройки серверов верхнего уровня.
  • context.TODO(), когда функция должна принимать контекст, но у вызывающего кода его ещё нет. Ведёт себя точно как Background; имя служит пометкой для будущего рефакторинга.

Внутри HTTP-обработчика корень не создают. Используют r.Context(), который сервер отменяет, когда клиент отключается или обработчик возвращает управление.

WithCancel: остановка по требованию

context.WithCancel возвращает дочерний контекст и функцию cancel. Вызов cancel закрывает канал Done дочернего контекста и каналы Done всего, что от него порождено.

Отправка производителя стоит в select рядом с ctx.Done(). Именно это позволяет ему остановиться: голое out <- i заблокировалось бы навсегда, как только потребитель перестал читать, и горутина утекла бы. Последний for range nums ждёт, пока производитель закроет канал. Пока он вычитывает канал, производитель ещё может успеть отправить одно-два значения, потому что когда обе ветки его select готовы, Go выбирает одну случайно; отмена срабатывает быстро, но не мгновенно.

cancel можно безопасно вызывать больше одного раза и из любой горутины. Что-то делает только первый вызов.

WithTimeout и WithDeadline

WithTimeout(parent, d) это WithDeadline(parent, time.Now().Add(d)). Таймаут подходит для «не дольше чем», а дедлайн, когда есть абсолютное время.

Когда время прошло, Done закрывается, а Err возвращает context.DeadlineExceeded. Если раньше вызвали cancel, Err возвращает context.Canceled. Какой из вариантов, проверяйте через errors.Is, потому что библиотеки обычно оборачивают ошибку:

Всегда вызывайте cancel, даже для таймаута, который сработает сам. Пока не случится одно из этого, контекст держит таймер и место в родителе, а defer cancel() освобождает и то, и другое, как только функция вернётся. go vet сообщает о выброшенной функции отмены: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.

Дочерние контексты не переживают родителей

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

Именно это делает контексты полезными между слоями. HTTP-обработчик получает контекст, который умирает вместе с запросом; вызов базы данных на три слоя ниже порождает из него 2-секундный таймаут. Если клиент отключится через 100 мс, запрос отменится тогда же, а не через две секунды.

Всегда делайте select по ctx.Done(), когда блокируетесь

Любая горутина, которая ждёт (отправки в канал, получения, таймера), должна одновременно ждать и ctx.Done(). Для циклов, нагружающих процессор и никогда не блокирующихся, время от времени проверяйте ctx.Err():

for i, item := range items {
	if i%1000 == 0 {
		if err := ctx.Err(); err != nil {
			return err
		}
	}
	process(item)
}

Для простого ожидания используйте time.After внутри select, но если ожидание может часто отменяться, лучше таймер, который можно остановить (или таймаут контекста).

Причины отмены (Go 1.20 и 1.21)

ctx.Err() говорит только canceled или deadline exceeded. Чтобы записать причину, используйте варианты с Cause:

WithCancelCause появился в Go 1.20, WithTimeoutCause и WithDeadlineCause в Go 1.21. Err по-прежнему возвращает стандартные значения, так что существующие проверки продолжают работать; подробность даёт context.Cause.

WithValue, но экономно

context.WithValue(parent, key, value) прикрепляет одно значение. ctx.Value(key) ищет его по цепочке родителей.

Правила для значений:

  • Используйте для ключей неэкспортированный тип, никогда не обычную string. Два пакета, которые оба используют "user", перезапишут друг друга. (go vet этого не ловит, а staticcheck ловит.)
  • Оборачивайте доступ в типизированные функции-помощники вроде WithRequestID и RequestID, чтобы вызывающий код никогда не видел ни any, ни ключ.
  • Храните только данные уровня запроса, которые проходят через API: trace ID и ID запросов, аутентифицированного пользователя, логгер. Никогда не храните необязательные параметры, дескрипторы баз данных или конфигурацию. Им место в аргументах функций или полях структур, где их проверит компилятор и увидят читатели.
  • Поиск идёт по цепочке по одному родителю, поэтому каждое добавленное значение удлиняет поиск остальных на шаг.

Соглашения

  • ctx context.Context это первый параметр любой функции, которая выполняет ввод-вывод, блокируется или вызывает что-то подобное: func Fetch(ctx context.Context, url string) error.
  • Не храните контекст в структуре. Передавайте его в каждый вызов метода. Контекст принадлежит одной операции, а структура обычно живёт дольше. (Исключение: тип, представляющий одну операцию, например http.Request.)
  • Никогда не передавайте nil в качестве контекста. Если ничего лучше нет, используйте context.TODO().
  • Возвращайте ctx.Err() или оборачивайте его через %w, когда останавливаетесь из-за контекста, чтобы вызывающий код мог отличить таймаут от настоящего сбоя.

Контекст в HTTP-серверах и клиентах

Серверная сторона: r.Context() отменяется, когда клиент отключается, когда обработчик возвращает управление или когда сбрасывается поток HTTP/2. Клиентская сторона: http.NewRequestWithContext заставляет запрос учитывать таймаут или отмену. Эта программа прогоняет обе стороны через httptest:

Клиент сдаётся на 50 мс и закрывает соединение. Сервер это замечает, контекст его запроса отменяется, и обработчик останавливается, а не тратит ещё 450 миллисекунд на отчёт, который никто не прочитает. В настоящем обработчике вы передаёте r.Context() вниз в каждый вызов базы данных и HTTP, и все они останавливаются вместе.

Другие помощники (Go 1.21)

  • context.WithoutCancel(ctx) возвращает контекст с теми же значениями, который не отменяется вместе с ctx. Используйте его для работы, которая должна завершиться после окончания запроса, например записи журнала аудита.
  • context.AfterFunc(ctx, f) запускает f в отдельной горутине, как только ctx завершён, и возвращает функцию stop, чтобы снять регистрацию.

Частые ошибки

  • Не вызван cancel. Всегда defer cancel() сразу после WithCancel, WithTimeout или WithDeadline.
  • Горутина, которая игнорирует ctx. Если она блокируется без select по ctx.Done(), отмена ничего не даёт, и горутина утекает.
  • Новый context.Background() глубоко внутри цепочки вызовов. Он обрывает связь с дедлайном и отменой вызывающего кода. Передавайте тот ctx, который получили.
  • Сравнение ошибок через ==. Используйте errors.Is(err, context.DeadlineExceeded); большинство библиотек её оборачивают.
  • WithValue для зависимостей. Дескриптор базы данных, спрятанный в контексте, это параметр, который компилятор уже не может проверить.
  • Ожидание мгновенной отмены. Код замечает её только при следующей проверке. Длинный цикл без проверок продолжает работать.

Часто задаваемые вопросы

Для чего нужен context в Go?

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

Чем context.Background отличается от context.TODO?

Оба возвращают пустой контекст, который никогда не отменяется и не имеет ни дедлайна, ни значений. Ведут они себя одинаково. Background() это корень для main, тестов и настройки верхнего уровня. TODO() отмечает место, куда должен передаваться настоящий контекст, но у окружающего кода его пока нет, и благодаря этому такое место легко найти потом.

Зачем вызывать cancel после context.WithTimeout?

WithTimeout, WithDeadline и WithCancel регистрируют новый контекст у родителя и могут запустить таймер. Вызов cancel освобождает эти ресурсы сразу, как только вы закончили, а не когда сработает таймаут или отменится родитель. Пишите defer cancel() сразу после создания; go vet предупреждает, если функция отмены выброшена.

Что означает «context deadline exceeded» в Go?

Это текст context.DeadlineExceeded, ошибки, которую возвращает ctx.Err(), когда дедлайн контекста прошёл. Функции, которые учитывают контекст, например HTTP-клиенты и драйверы баз данных, возвращают её (часто обёрнутой), когда у них заканчивается время. Проверяйте её через errors.Is(err, context.DeadlineExceeded).

Стоит ли передавать параметры через context.WithValue?

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

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ