Сервис обрабатывает HTTP-запрос: идёт в базу, вызывает два соседних сервиса, что-то
считает в фоновых горутинах. Клиент закрыл вкладку. Вся эта работа больше никому не
нужна, но продолжает выполняться: держит соединения с базой, занимает процессор. Нужен
способ одним действием сказать всем участникам «заканчиваем».
В Go таким способом служит context.Context. Он решает три задачи: отмена
(остановить работу досрочно), дедлайны (не ждать дольше заданного срока) и
передача данных запроса (идентификатор запроса, данные трассировки) через все слои
вызовов.
Устроен контекст на уже знакомой идее: сигнал отмены — это закрытие канала, которое
видят сразу все ожидающие (см. главу о каналах). Context добавляет к
этому иерархию: отмена родителя отменяет всех потомков.
Нужны select, закрытие канала как сигнал, функции как значения и возврат error.
context.Context — интерфейс с четырьмя методами. Два из них нужны постоянно:
ctx.Done() возвращает канал, который закрывается при отмене, а ctx.Err() объясняет
причину. Функции WithCancel, WithTimeout и WithDeadline возвращают вместе с новым
контекстом функцию cancel; её вызывают как обычную функцию, cancel(). Она сообщает
об отмене и сразу возвращается, не дожидаясь, пока работа действительно остановится.
Два термина. Дедлайн — момент времени, после которого операция должна прекратить
ожидание («до 12:00:05»). Таймаут — длительность, из которой дедлайн вычисляют
(«5 секунд от сейчас»).
На первом проходе изучите WithCancel, WithTimeout, проверку Done и обязательный
cancel. Разделы про HTTP, WithValue, новые функции пакета и сигналы ОС можно
перечитать позже.
Во всём Go-коде принято одно соглашение: контекст передают первым параметром с именем
ctx:
func FetchUser(ctx context.Context, id int) (*User, error) { ... }
И несколько правил, за нарушение которых придирается и линтер, и ревьюер:
Не передавайте nil вместо контекста. Если контекста пока нет, используйте
context.TODO(): он ничего не делает и только помечает место, куда позже нужно
протащить настоящий.
Не храните контекст в поле структуры. Контекст относится к одной операции, а
структура живёт дольше. Передавайте его аргументом.
В HTTP-обработчике контекст уже есть: r.Context(). Сервер отменяет его, когда клиент
разрывает соединение или обработчик возвращается.
func handler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // отменится, если клиент уйдёт rows, err := db.QueryContext(ctx, q) // база прервёт запрос по отмене // ...}
Остаётся передать этот ctx во все вызовы ниже: в базу, в исходящие HTTP-запросы, в
свои функции.
В корне любого дерева — context.Background(): пустой контекст, который никогда не
отменяется сам. От любого контекста можно создать дочерний:
ctx, cancel := context.WithCancel(parent) // отмена вручнуюctx, cancel := context.WithTimeout(parent, 5*time.Second) // плюс отмена через 5 сctx, cancel := context.WithDeadline(parent, deadline) // плюс отмена в момент deadlinedefer cancel() // вызывать обязательно; почему — ниже
Главное свойство дерева: отмена родителя отменяет всех потомков, но не наоборот.
Отменили родителя — закрылись каналы Done() у детей, внуков и всех ниже.
Отменили ребёнка — родитель и его остальные дети продолжают работать.
WithTimeout(parent, d) — то же самое, что WithDeadline(parent, time.Now().Add(d)).
Когда дедлайн наступает, контекст отменяется сам, как будто кто-то вызвал cancel.
Дочерний контекст не может жить дольше родителя. Если у родителя до дедлайна осталось
50 мс, а вы создали ребёнка с таймаутом 200 мс, ребёнок всё равно будет отменён через
50 мс вместе с родителем.
Код узнаёт об отмене через ctx.Done(): канал, который закрывается в момент отмены.
Закрытый канал всегда готов к приёму, поэтому ветка <-ctx.Done() в
select срабатывает сразу после отмены:
func worker(ctx context.Context, in <-chan Job) error { for { select { case <-ctx.Done(): return ctx.Err() // Canceled или DeadlineExceeded case job, ok := <-in: if !ok { return nil // задачи кончились } process(job) } }}
После отмены ctx.Err() возвращает причину:
context.Canceled — кто-то вызвал cancel();
context.DeadlineExceeded — истёк срок.
До отмены ctx.Err() возвращает nil. ctx.Deadline() сообщает дедлайн, если он задан;
по нему можно решить, есть ли смысл начинать долгую операцию.
Важно понимать, чего context не делает. Он не прерывает выполняющийся код. Если
функция десять секунд считает в цикле и ни разу не смотрит на ctx.Done(), отмена на
неё не подействует: она досчитает до конца. Отмена в Go кооперативная: код сам
проверяет сигнал в удобных местах и сам сворачивается.
Воркер обрабатывает задачи, пока его не отменят:
package mainimport ( "context" "fmt" "sync")func main() { ctx, cancel := context.WithCancel(context.Background()) jobs := make(chan int) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() for { select { case <-ctx.Done(): fmt.Println("воркер остановлен:", ctx.Err()) return case j := <-jobs: fmt.Println("обработал задачу", j) } } }() for i := 1; i <= 3; i++ { jobs <- i // отправка завершится, когда воркер примет задачу } cancel() wg.Wait() fmt.Println("main завершился")}
Порядок вывода здесь гарантирован, и стоит понять почему. Канал jobs небуферизованный:
каждая отправка в main завершается только тогда, когда воркер принял задачу. После
третьей отправки main вызывает cancel(). В этот момент в jobs ничего нет, готова
только ветка ctx.Done(), и воркер выходит. Если бы jobs был буферизованным и
задачи лежали в буфере в момент отмены, обе ветки оказались бы готовы, и select мог бы
выбрать любую: часть задач осталась бы необработанной.
Часто отменять руками не нужно: достаточно сказать «жди не дольше N». Это WithTimeout.
Функция call ждёт либо ответа, либо отмены контекста:
package mainimport ( "context" "fmt" "time")func call(ctx context.Context, reply <-chan string) string { select { case r := <-reply: return "ответ: " + r case <-ctx.Done(): return "не дождались: " + ctx.Err().Error() }}func main() { // ответ уже готов, таймаут в секунду не понадобится ctx1, cancel1 := context.WithTimeout(context.Background(), time.Second) defer cancel1() fast := make(chan string, 1) fast <- "ok" fmt.Println(call(ctx1, fast)) // ответа нет, таймаут 20 мс истечёт ctx2, cancel2 := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel2() slow := make(chan string) // в него никто не пишет fmt.Println(call(ctx2, slow))}
ответ: okне дождались: context deadline exceeded
Первый вызов возвращается сразу: в буфере уже лежит ответ, а ctx1.Done() ещё не закрыт.
Второй ждёт 20 мс, после чего контекст отменяется сам, и ctx.Err() возвращает
DeadlineExceeded.
Самая частая ошибка — не вызвать cancel. Все три функции WithCancel, WithTimeout и
WithDeadline возвращают cancel, и вызвать её нужно всегда. Идиома — defer cancel()
на следующей строке после создания:
ctx, cancel := context.WithTimeout(ctx, time.Second)defer cancel() // даже если doWork закончил вовремяres, err := doWork(ctx)
Зачем вызывать cancel, если операция уже успешно закончилась? Дочерний контекст
зарегистрирован у родителя, чтобы получить от него отмену, а у WithTimeout ещё и
запущен таймер. Пока не вызван cancel (или не отменён родитель, или не наступил
дедлайн), эти ресурсы не освобождаются. Если родитель живёт долго, например это контекст
всего сервиса, а дочерние создаются на каждый запрос, неосвобождённые контексты будут
копиться.
go vet ловит забытый cancel (проверка lostcancel):
the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak
Повторный вызов cancel безопасен и ничего не делает, так что defer cancel() не мешает
отменить контекст раньше вручную.
context.WithValue прикрепляет к контексту пару «ключ — значение». Её видят все функции,
которым передали этот контекст или его потомков. Предназначение узкое: данные, которые
относятся к конкретному запросу и нужны на многих уровнях, — идентификатор запроса,
данные трассировки, сведения об аутентификации.
Ключ делают значением собственного неэкспортируемого типа. Тогда ключ из чужого пакета
не совпадёт с вашим, даже если строка внутри одинаковая:
package mainimport ( "context" "fmt")type ctxKey string // свой тип ключа; в пакете его не экспортируютconst reqIDKey ctxKey = "reqID"func withReqID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, reqIDKey, id)}func logLine(ctx context.Context, msg string) { id, ok := ctx.Value(reqIDKey).(string) if !ok { id = "<нет>" } fmt.Printf("[req=%s] %s\n", id, msg)}func main() { ctx := withReqID(context.Background(), "abc-123") logLine(ctx, "начали обработку") logLine(ctx, "сходили в БД") logLine(context.Background(), "запрос без id")}
[req=abc-123] начали обработку[req=abc-123] сходили в БД[req=<нет>] запрос без id
logLine получила идентификатор, хотя он не передавался отдельным параметром.
ctx.Value возвращает any, поэтому нужно приведение типа с проверкой ok: значения
может не оказаться.
Правила использования:
Только данные запроса. Логгер, конфигурация, подключение к базе — это зависимости, их
передают явными аргументами или полями структуры сервиса.
Если значение влияет на бизнес-логику, ему место в сигнатуре функции. Параметр,
спрятанный в контексте, не виден компилятору и IDE, а его отсутствие обнаружится только
во время работы.
В Go 1.20 и 1.21 в пакет добавили функции, которые часто встречаются в современном коде.
context.WithCancelCause возвращает функцию отмены, принимающую ошибку-причину.
ctx.Err() по-прежнему вернёт context.Canceled, а context.Cause(ctx) — переданную
причину. Это помогает в логах отличить «клиент ушёл» от «упала соседняя задача».
Для дедлайнов есть WithTimeoutCause и WithDeadlineCause.
context.WithoutCancel(ctx) создаёт контекст, который сохраняет значения родителя, но не
отменяется вместе с ним. Пригодится, когда после ответа клиенту нужно дописать аудит-лог:
контекст запроса к этому моменту уже отменён.
context.AfterFunc(ctx, f) вызывает f в отдельной горутине после отмены ctx.
Возвращает функцию stop, которая отменяет эту регистрацию. Удобно, чтобы связать
отмену контекста с API, которое о контекстах ничего не знает, например закрыть
соединение.
HTTP-запрос обращается к серверу по адресу (URL), например https://example.com/data.
Метод GET запрашивает данные. Сервер отвечает кодом статуса (200 — успех, 404 — не
найдено) и телом ответа. Тело resp.Body — поток байтов: его читают, например,
io.ReadAll(resp.Body), а после чтения обязательно закрывают через resp.Body.Close(),
иначе соединение не вернётся в пул и будет держать ресурсы.
Запрос с контекстом в задаче 13 собирается так:
создать производный контекст с таймаутом и сразу поставить defer cancel();
создать запрос через http.NewRequestWithContext(ctx, http.MethodGet, url, nil) и
проверить ошибку;
выполнить http.DefaultClient.Do(req) и проверить ошибку; если ошибка есть, ответа
нет и обращаться к resp нельзя;
поставить defer resp.Body.Close();
прочитать тело и проверить ошибку чтения.
Две тонкости. Таймаут ограничивает ожидание и сверху ограничен родительским
контекстом: если у родителя осталось 50 мс, ребёнок с таймаутом 200 мс отменится через
50. И ответ с кодом 404 или 500 — не ошибка Do: Do вернёт err == nil, а код нужно
проверять отдельно, если этого требует условие. Сети в браузерной песочнице нет, такой
код проверяйте локально.
Корректное завершение сервиса — место, где дерево отмены видно целиком.
signal.NotifyContext превращает сигналы ОС (Ctrl+C, SIGTERM от оркестратора) в отмену
контекста:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()go runWorkers(ctx) // фоновая работа получает ctx и следит за ctx.Done()<-ctx.Done() // ждём сигналаshutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()if err := srv.Shutdown(shutdownCtx); err != nil { // не успели за 5 секунд: например, context deadline exceeded}
Несколько вещей, которые здесь легко упустить:
Отмена корневого контекста доходит только до тех, кому этот контекст (или его потомок)
передали и кто проверяет Done().
Контексты HTTP-запросов не становятся потомками этого корня автоматически. Связать их
можно через поле http.Server.BaseContext, если это нужно.
Shutdown нужен свой, ещё не отменённый контекст: ctx после <-ctx.Done() уже
отменён, и Shutdown с ним вернётся немедленно, не дав обработчикам доработать.
Сигнал «останавливайтесь» и факт «все остановились» — разные события. Дождаться
фоновых горутин нужно отдельно, через WaitGroup или канал завершения.
Сигналов ОС и сети в браузере нет, поэтому пример без кнопки запуска.
Задача 15 и типичный сценарий: отправить запрос нескольким репликам, взять первый
успешный ответ, остальные отменить. Контекст делает отмену проигравших одной строкой:
func firstSuccess(ctx context.Context, replicas []Replica) (Result, error) { if len(replicas) == 0 { return Result{}, errors.New("нет реплик") } ctx, cancel := context.WithCancel(ctx) defer cancel() // при выходе отменит всех, кто ещё работает type reply struct { result Result err error } results := make(chan reply, len(replicas)) // место для ответа каждой реплики for _, r := range replicas { go func(r Replica) { res, err := r.Query(ctx) // Query должен сам реагировать на ctx results <- reply{res, err} }(r) } var lastErr error for range replicas { select { case res := <-results: if res.err == nil { return res.result, nil } lastErr = res.err case <-ctx.Done(): return Result{}, ctx.Err() } } return Result{}, lastErr // все реплики ответили ошибкой}
Разберём, почему здесь нет утечек. Буфер results вмещает ответ каждой реплики, поэтому
горутины, которые закончат после того, как мы вернули результат, положат ответ в буфер и
завершатся, а не зависнут на отправке. defer cancel() сообщает проигравшим, что их
ответ больше не нужен; насколько быстро они остановятся, зависит от того, проверяет ли
Query контекст. Ветка <-ctx.Done() нужна на случай, если отменят внешний контекст.
Та же идея «первый из нескольких» уже встречалась в «или»-канале из задачи 01 (см.
главу select); контекст здесь заменяет самодельные done-каналы.
Нет defer cancel(). Контекст и его таймер не освобождаются до отмены родителя.
go vet об этом предупреждает.
Долгий цикл не проверяет ctx.Done(). Отмена есть, но код её не замечает.
Контекст в поле структуры или nil вместо контекста. Передавайте аргументом; если
контекста пока нет — context.TODO().
Зависимости в WithValue. Логгер, конфигурация, пул соединений — явные аргументы.
Своя ошибка вместо ctx.Err(). Вызывающему важно знать, отменили операцию
(Canceled) или вышло время (DeadlineExceeded). Возвращайте ctx.Err() или
оборачивайте его через fmt.Errorf("...: %w", ctx.Err()).
cancel считают ожиданием.cancel() возвращается сразу. Чтобы убедиться, что
горутины закончили, их нужно дождаться отдельно.
Context — дерево сигналов отмены с прикреплёнными дедлайнами и значениями. Отмена идёт
сверху вниз через закрытие каналов Done(). Каждая функция, создающая контекст, выдаёт
cancel, и его нужно вызвать. Context ничего не прерывает силой: работа заканчивается
там, где код сам проверяет сигнал.
Создайте родительский контекст через WithCancel и от него два дочерних, a и b,
тоже через WithCancel. Отмените только a и запишите, что вернёт Err() у всех трёх.
Затем отмените родителя и снова запишите. Если вместо простых проверок контексты переданы
горутинам, как убедиться, что те действительно завершились?
Отмена идёт от родителя вниз; братья друг друга не отменяют. Err() здесь можно
проверять сразу после вызова cancel: к моменту возврата из cancel контекст и все его
потомки уже отменены. Чтобы дождаться горутин, которым передали контексты, нужен
WaitGroup или канал завершения: cancel сообщает о событии, но не подтверждает, что
горутины закончили работу.
Следующая глава, «Паттерны», собирает из каналов, select и
контекста конвейеры и пулы воркеров, которые останавливаются одной отменой.
В задачах курса context — основа блока 4 (задачи 13–15: HTTP с таймаутом, отмена набора
фоновых задач, первый успешный ответ). В блоке 7 он есть в каждом пуле: отмена задач,
остановка с таймаутом. Как проверить, что после отмены не осталось горутин, разобрано в
главе «Утечки горутин и гонки».