Урок курса
context.Context: отмена и таймаут по цепочке вызовов
Golang с нуляВ прошлом уроке stopCh — закрытый канал — служил сигналом остановки горутин. Он прекрасно справлялся с одной функцией, но как только вызовов становится несколько, нужно вручную прокидывать stopCh по всей цепочке и синхронизировать закрытие. context.Context решает именно эту задачу: отмена распространяется по дереву вызовов автоматически, а тип и соглашения стандартизированы.
context.Background() как корень и соглашение о передаче ctx первым аргументом
context.Background() — это отправная точка. Он не имеет дедлайна, никогда не отменяется и не несёт никаких значений. Его единственная роль — быть корнем, от которого вы потом получите дочерние контексты через WithCancel или WithTimeout.
В main это выглядит так:
package main
import (
"context"
"fmt"
)
func doWork(ctx context.Context, name string) {
fmt.Println("working:", name)
}
func main() {
ctx := context.Background()
doWork(ctx, "task-1")
}
Обратите внимание на сигнатуру doWork: ctx context.Context идёт первым параметром, тип — интерфейс context.Context, а не какой-либо конкретный тип. Это устоявшееся соглашение Go: если функция принимает контекст, он обычно передаётся первым параметром и называется ctx. Стандартная библиотека и большинство популярных пакетов придерживаются этого правила, поэтому функцию можно вызвать с любым контекстом — фоновым, с таймаутом или тестовым.
Почему ctx не хранят в поле структуры? Контекст описывает время жизни конкретного вызова или запроса: у каждого HTTP-запроса свой ctx, который отменяется, когда клиент отключился. Если поместить контекст в поле, он теряет эту привязку: непонятно, когда его отменять, кто владеет его жизненным циклом и откуда пришёл сигнал отмены. Это делает код непрозрачным и трудным для тестирования. Передача через аргумент явно показывает: «этот контекст действует только во время данного вызова».
WithCancel и WithTimeout: создание дочернего контекста и обязательный defer cancel
От корневого context.Background() вы не можете послать сигнал отмены — он неотменяем по определению. Чтобы получить контекст, который можно остановить, нужен дочерний:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Две вещи происходят одновременно: появляется дочерний контекст ctx и функция cancel, которая его останавливает. defer cancel() должна быть зарегистрирована немедленно — следующей строкой после вызова. Это не формальность.
Что делает cancel? Она закрывает ctx.Done(), удаляет связь дочернего контекста с родителем и отменяет всех потомков ctx. Без вызова cancel эта связь остаётся живой до тех пор, пока родительский контекст сам не завершится — возможно, никогда. Повторный вызов cancel() безопасен: он просто ничего не делает.
WithTimeout добавляет автоматический таймер:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
Через 2 секунды контекст отменится сам. Но defer cancel() всё равно нужен — и вот почему: если работа завершится за 500 мс, внутренний таймер продолжит жить ещё 1.5 секунды, удерживая ресурсы. cancel() гасит таймер досрочно.
Как различить причину отмены? Через ctx.Err():
- Явный вызов
cancel()→context.Canceled - Истёк таймаут →
context.DeadlineExceeded - Контекст ещё активен →
nil
Пока функция работает нормально, ctx.Err() возвращает nil. Как только контекст отменён — любым способом — значение фиксируется и уже не меняется.
ctx.Done() и ctx.Err(): остановка doWork через select при отмене и таймауте
ctx.Done() возвращает канал типа <-chan struct{}. Здесь важен один нюанс: для неотменяемого контекста — например, context.Background() — этот метод возвращает nil, и такой канал никогда не закрывается. Для контекстов, созданных через WithCancel или WithTimeout, канал ненулевой: когда контекст отменяется, он закрывается, и операция receive по нему становится постоянно готовой.
Типичная рабочая функция использует select для одновременного ожидания двух событий:
func doWork(ctx context.Context, workCh <-chan string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case item, ok := <-workCh:
if !ok {
return nil
}
fmt.Println("processing:", item)
}
}
}
Горутина заблокирована до тех пор, пока хотя бы один из каналов не станет готов — это не busy loop. После закрытия ctx.Done() ветка отмены становится постоянно готовой и сигнал уже не теряется. Однако приоритета у неё нет: если одновременно готов и workCh, select выбирает одну из веток псевдослучайно. Обработка отмены гарантированно произойдёт при одном из последующих проходов цикла — но не обязательно немедленно. Именно поэтому длительную работу внутри рабочей ветки стоит ограничивать или также делать отменяемой.
Посмотрим на оба сценария:
func main() {
workCh := make(chan string)
// Сценарий 1: явная отмена
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
go func() {
time.Sleep(100 * time.Millisecond)
cancel1()
}()
err := doWork(ctx1, workCh)
fmt.Println(err) // context canceled
// Сценарий 2: таймаут
ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel2()
err = doWork(ctx2, workCh)
fmt.Println(err) // context deadline exceeded
}
В первом случае горутина явно вызывает cancel1() — doWork в итоге получает context.Canceled. Во втором никто cancel2 явно не зовёт, но через 100 мс таймер срабатывает сам — doWork получает context.DeadlineExceeded. Значение ctx.Err() фиксируется в момент отмены и уже не меняется: функция, которая проверит его чуть позже, увидит ровно ту же причину остановки.
Попробуйте решить
Разработчик написал:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// ... запуск горутины с ctx ...
cancel() // явная отмена
Что произойдёт, когда defer cancel() сработает при выходе из функции, если cancel() уже был вызван явно ранее?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
