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

context.Context это стандартный механизм Go для управления временем жизни операций. Через него можно отменить уже запущенную работу, ограничить её таймаутом и пронести сквозь стек вызовов значения уровня запроса. В рабочем Go-коде контекст встречается повсюду: его принимают первым аргументом HTTP-обработчики, запросы к базе, вызовы внешних API. При этом в обучающих материалах тема часто подаётся скомканно. У новичков остаётся ощущение «передаю ctx, потому что так требует сигнатура». Разберём, какую задачу контекст решает на самом деле и как им пользоваться. На каждый случай будет работающий пример.

Это один из разборов в серии для начинающих. Общий маршрут изучения языка собран в статье «Go для начинающих: дорожная карта».

Какую задачу решает context

В Go нет способа принудительно остановить горутину снаружи. Запустив go doWork(), вы не можете её «убить». Горутина завершится только тогда, когда сама вернётся из функции. Это осознанное решение авторов языка. Принудительное завершение в произвольной точке оставляло бы программу в непредсказуемом состоянии: полузаписанные данные, незакрытые соединения, захваченные мьютексы.

Но потребность останавливать работу извне реальна. Типичный сценарий: на ваш сервис пришёл HTTP-запрос, обработчик пошёл в базу и в два внешних API, а клиент тем временем закрыл вкладку. Соединение разорвано, результат никому не нужен. Но без механизма отмены все три операции продолжат выполняться и тратить ресурсы. На нагруженном сервисе такие «осиротевшие» операции складываются в заметную паразитную нагрузку.

Раз принудительно остановить горутину нельзя, остаётся кооперативный подход. Вызывающий код даёт сигнал «результат больше не нужен», а работающий сам решает, когда остановиться и прибрать за собой. Контекст и есть стандартный канал для такого сигнала. Он передаётся по цепочке вызовов, и любое звено может проверить: не отменили ли нас сверху?

Интерфейс Context

context.Context это интерфейс из четырёх методов:

type Context interface {
    Deadline() (deadline time.Time, ok bool) // когда контекст истечёт (если дедлайн задан)
    Done() <-chan struct{}                   // канал, который закроется при отмене
    Err() error                              // причина отмены или nil, если её не было
    Value(key any) any                       // значение по ключу
}

Центральный метод здесь Done(). Он возвращает канал, из которого нельзя ничего прочитать, пока контекст жив. В момент отмены канал закрывается. Закрытие канала в Go будит всех, кто на нём ждёт, поэтому один контекст может сигнализировать сотням горутин. Метод Err() дополняет картину. Пока контекст жив, он возвращает nil. После отмены он возвращает ошибку с причиной: context.Canceled или context.DeadlineExceeded.

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

Корневые контексты: Background и TODO

Дерево контекстов начинается с корня. Их два:

ctx := context.Background() // основной корневой контекст
ctx := context.TODO()       // заглушка: «контекст здесь нужен, но пока непонятно какой»

Оба пустые: никогда не отменяются, не имеют дедлайна и не несут значений. Разница только в семантике. Background используется там, где цепочка вызовов действительно начинается: в main, в инициализации, в тестах. TODO это пометка для рефакторинга: «сюда нужно дотянуть настоящий контекст от вызывающего кода, но я ещё этого не сделал». Статические анализаторы и коллеги на ревью воспринимают TODO как технический долг.

На практике корневой контекст вы создаёте редко. В типичном сервисе он уже есть: HTTP-сервер кладёт контекст в каждый запрос, gRPC передаёт его в каждый метод. Вам остаётся принять его и передать дальше.

Отмена: context.WithCancel

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

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("воркер %d: останавливаюсь (%v)\n", id, ctx.Err())
			return
		default:
			fmt.Printf("воркер %d: работаю\n", id)
			time.Sleep(300 * time.Millisecond)
		}
	}
}

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

	for i := 1; i <= 3; i++ {
		go worker(ctx, i)
	}

	time.Sleep(time.Second)
	cancel() // сигнал всем воркерам: сворачиваемся

	time.Sleep(400 * time.Millisecond) // даём воркерам время напечатать прощание
}

Запуск:

$ go run main.go
воркер 3: работаю
воркер 1: работаю
воркер 2: работаю
...
воркер 1: останавливаюсь (context canceled)
воркер 2: останавливаюсь (context canceled)
воркер 3: останавливаюсь (context canceled)

Обратите внимание на структуру воркера: на каждой итерации цикла он заглядывает в ctx.Done() через select. Это и есть кооперативность: никто воркера не убивал. Он сам проверил сигнал и корректно вышел. Если бы внутри цикла были открытые файлы или транзакции, перед return было бы время их закрыть.

Несколько слов про саму функцию cancel. Вызывать её обязательно: даже если контекст «и так завершится», cancel освобождает ресурсы, которые рантайм держит под него. Идиома: defer cancel() сразу после создания. Повторные вызовы безопасны, сработает только первый. Действует отмена на всё поддерево: контексты, порождённые от отменённого, отменяются вместе с ним.

Таймауты: WithTimeout и WithDeadline

На практике ручная отмена встречается реже, чем ограничение операции по времени. Для этого есть context.WithTimeout:

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"time"
)

func main() {
	// На весь запрос — не больше двух секунд
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://httpbin.org/delay/5", nil)
	if err != nil {
		panic(err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			fmt.Println("не уложились в таймаут:", err)
			return
		}
		panic(err)
	}
	defer resp.Body.Close()

	fmt.Println("статус:", resp.Status)
}

Сервер в примере отвечает через 5 секунд, а контекст разрешает только 2. Запрос будет прерван:

$ go run main.go
не уложились в таймаут: Get "https://httpbin.org/delay/5": context deadline exceeded

Здесь видно главное удобство контекста: HTTP-клиент стандартной библиотеки сам следит за ctx.Done() и сам разрывает соединение по истечении времени. Вам не нужно писать ни select, ни таймеры. Достаточно передать контекст. Так же ведут себя драйверы баз данных, gRPC-клиенты и почти все приличные библиотеки.

WithDeadline отличается от WithTimeout только формой. Первый принимает абсолютный момент времени, второй — длительность от «сейчас». WithTimeout(ctx, d) буквально определён как WithDeadline(ctx, time.Now().Add(d)). Дедлайн удобен, когда лимит уже задан снаружи. Например, апстрим сообщил, до какого момента он готов ждать ответ.

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

Ошибку таймаута проверяют через errors.Is(err, context.DeadlineExceeded), ручную отмену через errors.Is(err, context.Canceled). Почему именно errors.Is, а не сравнение ==, я разбирал в статье про идиоматичную обработку ошибок: ошибка по пути наверх могла быть обёрнута.

Причина отмены: WithCancelCause

Стандартные context.Canceled и context.DeadlineExceeded отвечают на вопрос «что случилось», но не «почему». В Go 1.20 появился context.WithCancelCause. Его функция отмены принимает ошибку-причину:

ctx, cancel := context.WithCancelCause(context.Background())

cancel(errors.New("превышен лимит повторных попыток"))

fmt.Println(ctx.Err())          // context canceled — стандартный ответ
fmt.Println(context.Cause(ctx)) // превышен лимит повторных попыток — настоящая причина

ctx.Err() по-прежнему возвращает context.Canceled, чтобы не сломать существующий код. Настоящая причина достаётся функцией context.Cause. В Go 1.21 семейство дополнили WithDeadlineCause и WithTimeoutCause. Там же появились две полезные функции. context.WithoutCancel создаёт потомка, который переживает отмену родителя: это пригодится для записи логов и метрик после отмены запроса. context.AfterFunc выполняет колбэк, когда контекст отменится.

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

Передача значений: WithValue

У контекста есть вторая способность, независимая от отмены. Он умеет нести значения через стек вызовов:

package main

import (
	"context"
	"fmt"
)

// Собственный тип ключа защищает от коллизий между пакетами
type requestIDKey struct{}

func handle(ctx context.Context) {
	process(ctx)
}

func process(ctx context.Context) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	if !ok {
		id = "unknown"
	}
	fmt.Println("обрабатываю запрос", id)
}

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey{}, "req-42")
	handle(ctx)
}

Функция process получила request ID, хотя промежуточная handle про него ничего не знает. Значение «проехало» внутри контекста. Два правила, без которых WithValue быстро превращается в источник боли:

Если коротко, WithValue это про метаданные запроса, а не способ протащить аргумент, который лень добавить в сигнатуру.

Где контекст встречается на практике

Контекст понимает вся экосистема, и в этом его главная ценность. Вот где вы столкнётесь с ним в первом же рабочем проекте.

В HTTP-сервере контекст уже есть у каждого входящего запроса. Он отменяется, когда клиент разорвал соединение (а в HTTP/2 ещё и при явной отмене запроса):

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // готовый контекст запроса
	data, err := fetchFromDB(ctx)
	// если клиент отвалился — fetchFromDB прервётся сам
	...
}

В HTTP-клиенте это http.NewRequestWithContext из примера с таймаутом выше. Про то, что дальше происходит с соединениями клиента, читайте в статье про переиспользование соединений в http.Client.

У всех методов database/sql есть Context-варианты: QueryContext, ExecContext, BeginTx. Долгий запрос прервётся вместе с контекстом, а не повиснет навсегда. Сравнение драйверов PostgreSQL, которые это поддерживают, есть в статье про pgx, database/sql и sqlx.

errgroup.WithContext создаёт контекст, который отменяется при первой ошибке в группе горутин. Остальные узнают об этом и не делают лишнюю работу. Подробный разбор в статье про errgroup.

Наконец, graceful shutdown целиком построен на контекстах: signal.NotifyContext превращает SIGTERM в отмену контекста, а server.Shutdown(ctx) ограничивает время на завершение текущих запросов. Целиком сценарий разобран в статье про graceful shutdown.

Правила работы с контекстом

Эти конвенции описаны в документации пакета и проверяются на код-ревью почти в любой команде:

  1. Контекст идёт первым аргументом функции, имя всегда ctx. Сигнатура func Fetch(ctx context.Context, id string) error читается однозначно во всём Go-коде мира.
  2. Не храните контекст в полях структуры. Передавайте его явно в каждую функцию, которой он нужен. Контекст привязан к конкретному запросу, а структура обычно живёт дольше. Известное исключение http.Request авторы стандартной библиотеки прямо называют вынужденным: контекст добавляли в уже существующий API.
  3. Не передавайте nil-контекст. Если настоящего контекста ещё нет, берите context.TODO().
  4. Проверяйте отмену в долгих операциях. Библиотечные вызовы следят за контекстом сами, но ваш собственный цикл на миллион итераций не следит. Периодически проверяйте в нём ctx.Err() или делайте select на ctx.Done().

Типичные ошибки

Потерянный cancel

ctx, _ := context.WithTimeout(context.Background(), time.Second) // утечка

Каждый WithCancel/WithTimeout/WithDeadline регистрирует ресурсы (таймер, место в дереве отмены), которые освобождает только вызов cancel. Если его выбросить, контекст с таймаутом доживёт до дедлайна, даже когда работа давно закончилась. Контекст из WithCancel вообще не освободится, пока жив родитель. go vet ловит это проверкой lostcancel:

$ go vet ./...
./main.go:12:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak

Правильно: defer cancel() следующей строкой после создания.

Горутина, которая не слушает Done

results := make(chan int)
go func() {
	results <- compute() // если получателя уже нет — горутина зависнет навсегда
}()

Если обработчик ушёл по таймауту, из results никто никогда не прочитает, и горутина утекла вместе с каналом. Лечится select с веткой отмены:

go func() {
	select {
	case results <- compute():
	case <-ctx.Done(): // получатель ушёл — выходим
	}
}()

Один таймаут на разные по смыслу операции

Таймаут стоит вешать на операцию, а не создавать один «универсальный» контекст на всё приложение. Запрос к базе за 100 мс и выгрузка отчёта за минуту не могут жить под одним лимитом: либо лимит слишком щедрый для первого, либо душит второй. Производные контексты дёшевы. Создавайте отдельный с подходящим таймаутом вокруг каждого внешнего вызова.

Background вместо пришедшего контекста

func handler(w http.ResponseWriter, r *http.Request) {
	data, err := fetchFromDB(context.Background()) // отмена запроса сюда не дойдёт
	...
}

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

Заключение

context.Context отвечает на вопрос, на который в Go нет другого ответа: как сообщить уже запущенной работе, что её результат больше не нужен. Отмена при этом кооперативная. Контекст лишь закрывает канал Done(), реагировать должен сам код. Хорошая новость: в типичном сервисе большую часть этой работы делают библиотеки. Передавайте ctx первым аргументом по всей цепочке. Не теряйте cancel. Не кладите в WithValue ничего, кроме метаданных запроса. Тогда отмена с таймаутами заработает сквозь HTTP, базу и горутины сама.

Дальше стоит посмотреть на errgroup, где контекст управляет группой параллельных задач, и на graceful shutdown, где он останавливает целый сервис. А общий маршрут изучения языка лежит в дорожной карте для начинающих.

FAQ

Что такое context в Go простыми словами?
Это объект, который передаётся первым аргументом по цепочке вызовов и несёт три вещи: сигнал отмены (канал Done()), дедлайн и значения уровня запроса. Через него вызывающий код может остановить уже запущенную работу. Например, прервать запрос к базе, если клиент отключился.
Зачем вызывать cancel, если контекст и так истечёт по таймауту?
cancel освобождает таймер и убирает контекст из дерева отмены сразу, а не по дедлайну. Без него ресурсы держатся до истечения таймаута, даже если работа закончилась за миллисекунду. Утечку ловит go vet (проверка lostcancel). Идиома: defer cancel() сразу после создания контекста.
Чем WithTimeout отличается от WithDeadline?
Только формой задания времени: WithTimeout принимает длительность от текущего момента, WithDeadline — абсолютный момент времени. WithTimeout(ctx, d) определён как WithDeadline(ctx, time.Now().Add(d)). Берите тот, в каком виде лимит у вас уже есть.
Можно ли хранить context в структуре?
Не рекомендуется: контекст привязан к конкретному запросу, а структура обычно живёт дольше. Передавайте его явно первым аргументом в каждую функцию. В стандартной библиотеке есть исключение, http.Request, и оно вынужденное: контекст добавляли в уже существующий API.
Как остановить горутину через context?
Передайте горутине контекст и проверяйте в ней канал отмены: select { case <-ctx.Done(): return; ... }. Когда вызывающий код выполнит cancel(), канал Done() закроется, и горутина увидит сигнал. Принудительно остановить горутину снаружи в Go нельзя, только попросить через контекст.

Теги: