Все главы учебника
Содержание учебника
Глава 07 / Основы

context

13 мин чтенияКонтент v0.7.1

О чём эта глава

Представь сервис, который обрабатывает HTTP-запрос. Внутри он лезет в БД, дёргает пару соседних сервисов, считает что-то в фоновых горутинах. И вот клиент закрыл вкладку. Или истёк таймаут. Вся эта работа стала бессмысленной — но она всё ещё крутится, жжёт CPU, держит соединения. Нужен способ дёрнуть за одну верёвочку и сказать всему дереву работы: «всё, расходимся».

Эта верёвочка — context.Context. Он закрывает три задачи, которые всплывают в любом сервисе: отмену (остановить работу досрочно), дедлайны и таймауты (не ждать дольше N секунд), и передачу request-scoped значений (request ID, trace, данные авторизации) через границы вызовов.

В этой главе разберём, как контексты складываются в дерево отмены, как сигнал течёт по нему через Done(), почему cancel нужно вызывать всегда, и где WithValue помогает, а где это запах кода.

Перед чтением

Нужны select, закрытие канала как сигнал, функции как значения и возврат error. context.Context — интерфейс; ctx.Done() возвращает канал сигнала, ctx.Err() — причину отмены. cancel — обычное значение-функция: её вызывают через cancel(). Она сообщает об отмене, но не ждёт завершения работ.

На первом проходе изучите WithCancel, WithTimeout, проверку Done и обязательный cancel. HTTP, WithValue и сигналы ОС можно перечитать после простого отменяемого работника. Дедлайн — момент, после которого операция должна прекратить ожидание; таймаут — длительность, из которой этот момент вычисляют.

Контракт: ctx — первый аргумент

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

func FetchUser(ctx context.Context, id int) (*User, error) { ... }

Несколько правил, за нарушение которых ругается и линтер, и ревьюер:

  • Не передавай nil как контекст. Если контекста ещё нет (заглушка, прототип) — бери context.TODO(). Он ничего не делает, но честно сигналит «здесь будет настоящий ctx».
  • Не клади context в структуру. Контекст живёт в пределах одного вызова/запроса, а поля структуры переживают его. Прокидывай ctx через аргументы.
  • Не храни его «на потом». Контекст — это про текущую операцию.
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()             // отменится, если клиент уйдёт
    rows, err := db.QueryContext(ctx, q)
    // ...
}

r.Context() уже отменяется, когда клиент разрывает соединение — нам остаётся лишь честно протащить этот ctx до самого дна (в БД, в исходящие вызовы).

Дерево отмены

Контексты образуют дерево. В корне — context.Background(), пустой контекст, который никогда не отменяется сам по себе. От любого контекста можно породить дочерний:

ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel() // об этом — ниже, но запомни сразу: cancel вызывают ВСЕГДА

Главное свойство дерева, ради которого всё и затевалось: отмена родителя отменяет всех потомков, но не наоборот.

  • Отменили parent → каскадом закрылись Done() всех его детей, внуков, правнуков.
  • Отменили ребёнка → родитель и его братья живут как ни в чём не бывало.

Это ровно тот же принцип «закрытие канала как broadcast» из главы каналы, только аккуратно организованный в иерархию. Один закрытый канал будит всех, кто слушает; одно дерево отмены гасит всё поддерево.

Кстати, WithTimeout — это просто удобная обёртка над WithDeadline: WithTimeout(parent, d) эквивалентно WithDeadline(parent, time.Now().Add(d)). Когда дедлайн наступает, контекст отменяется автоматически — будто кто-то вызвал cancel за тебя.

Done() и проверка отмены

Как код узнаёт, что его отменили? Через 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.Deadline() отдаёт время дедлайна (если он задан) — полезно, чтобы понять, сколько ещё можно ждать. Важный нюанс: пока твой код не проверяет ctx.Done(), отмена для него ничего не значит. Долгий цикл, который ни разу не заглянул в Done(), отменить нельзя — он досчитает до конца.

Посмотрим на отмену в действии. Главная горутина отменяет контекст, рабочая ловит сигнал и завершается:

package main
 
import (
	"context"
	"fmt"
	"sync"
)
 
func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	wg.Add(1)
 
	go func() {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				fmt.Println("остановлен:", ctx.Err())
				return
			default:
				// полезная работа, пока нас не отменили
			}
		}
	}()
 
	cancel()  // дёргаем верёвочку
	wg.Wait()
	fmt.Println("главная функция завершилась чисто")
}

Здесь мы вызываем cancel() сразу, и горутина при первой же проверке Done() видит закрытый канал и выходит. wg.Wait() гарантирует, что main не закончится раньше, чем рабочая горутина напечатает свою строку.

Пустой default тут — лишь для наглядности: он показывает, что цикл крутится и проверяет Done() на каждой итерации. В реальном коде так не пишут — пустой default в бесконечном цикле жжёт 100% CPU; в ветке default делают порцию работы либо ждут на канале, а не гоняют цикл вхолостую.

Таймаут вместо ручной отмены

Часто отмену не нужно делать руками — достаточно сказать «жди не дольше N». Это WithTimeout. Контекст сам отменится по истечении срока, а ctx.Err() вернёт DeadlineExceeded. Сравним две горутины: одна успевает «ответить» до таймаута, другая нет.

package main
 
import (
	"context"
	"fmt"
	"sync"
	"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() {
	var wg sync.WaitGroup
 
	// быстрый: ответ уже лежит в буфере
	ctx1, c1 := context.WithTimeout(context.Background(), time.Second)
	defer c1()
	fast := make(chan string, 1)
	fast <- "ok"
 
	// медленный: ответа нет, таймаут уже истёк
	ctx2, c2 := context.WithTimeout(context.Background(), -time.Second)
	defer c2()
	slow := make(chan string) // никто не пишет
 
	wg.Add(2)
	go func() { defer wg.Done(); fmt.Println("быстрый ->", call(ctx1, fast)) }()
	go func() { defer wg.Done(); fmt.Println("медленный ->", call(ctx2, slow)) }()
	wg.Wait()
}

Маленькая хитрость для детерминированного вывода в браузере: второму контексту мы задали уже истёкший дедлайн (-time.Second), поэтому его Done() закрыт сразу. В реальном коде таймаут, конечно, положительный — но суть та же: select выбирает либо реальный результат, либо срабатывание дедлайна.

Обязательный cancel

Самая частая ошибка новичков — забыть cancel. Все три порождающие функции (WithCancel, WithTimeout, WithDeadline) возвращают функцию cancel, и её нужно вызвать всегда. Идиома — defer cancel() сразу после создания:

ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()                 // даже если doWork успел — cancel освобождает таймер
res, err := doWork(ctx)

Что будет, если забыть:

  • Дочерний контекст останется привязан к родителю до тех пор, пока не отменится родитель или сам ребёнок. Для WithTimeout/WithDeadline это произойдёт и при наступлении дедлайна. До этого контекст и связанный таймер могут удерживать ресурсы дольше необходимого.
  • go vet и линтеры предупредят о «lost cancel» — это не косметика, это реальный баг.

Может смутить: «а если операция успешно завершилась — зачем ещё cancel?». Затем, что cancel помимо отмены ещё и отвязывает контекст от родителя и гасит таймер. Повторный вызов cancel безопасен и идемпотентен, так что defer cancel() прекрасно уживается с ранним успешным выходом — лишним он не будет.

Передача значений через границы

context.WithValue несёт request-scoped данные — то, что относится к конкретному запросу и должно быть видно на всех слоях: request ID, trace span, данные аутентификации. Ключ обязательно делают неэкспортируемым типом, чтобы пакеты случайно не перетёрли значения друг друга:

package main
 
import (
	"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, "сходили в БД")
 
	plain := context.Background()
	logLine(plain, "запрос без id")
}

Обрати внимание: значение прошло через две вложенные функции, и нигде не пришлось тащить reqID отдельным параметром. Это и есть «через границы вызовов».

И правила, нарушение которых — антипаттерн:

  • Только request-scoped данные. Не превращай WithValue в свалку для опциональных параметров функции. Конфиги, зависимости, логгеры — это явные аргументы или поля структуры сервиса, а не значения в контексте.
  • Ключ — неэкспортируемый тип, не голая строка. Строковый ключ "id" из чужого пакета может затереть твой.
  • Если данные влияют на бизнес-логику и должны быть в сигнатуре — пусть будут в сигнатуре. «Бизнес-аргумент, спрятанный в WithValue» — классический запах кода: компилятор о нём не знает, IDE не подскажет, а nil вылезет в рантайме.

Перед задачей с HTTP-запросом

HTTP-запрос обращается к серверу по URL, например https://example.com/data. Метод GET запрашивает данные. Сервер отвечает статусом и телом ответа. resp.Body — поток байтов с методами чтения и закрытия; он удовлетворяет интерфейсу io.Reader, который описывает получение байтов через Read. Чтобы получить всё тело как []byte, используют io.ReadAll(resp.Body).

Рабочая последовательность для задачи 13: создать производный контекст, создать запрос через http.NewRequestWithContext, выполнить http.DefaultClient.Do, проверить ошибку, поставить defer resp.Body.Close(), прочитать тело и проверить ошибку чтения. defer cancel() ставят сразу после создания контекста. Тело ответа связано с ресурсами соединения; вернуть прочитанные байты и оставить Body открытым — разные действия.

Таймаут 200 мс задаёт верхний предел ожидания: если у родителя осталось 50 мс, ребёнок не получает дополнительные 200. Отмена или timeout означают прекращение ожидания по контракту операции; они не доказывают, что удалённый сервер ничего не сделал. HTTP-ответ с кодом ошибки, например 404, сам по себе не является сетевой ошибкой Do; нужную обработку статуса определяет условие функции.

Перед решением выпишите два пути: успешный запрос → чтение → закрытие; ошибка запроса → возврат ошибки без обращения к отсутствующему ответу. Сеть в браузерном интерпретаторе недоступна: такой код проверяйте локально.

Context и graceful shutdown

Где дерево отмены раскрывается во всю мощь — это корректное завершение сервиса. signal.NotifyContext связывает сигналы ОС (Ctrl+C, SIGTERM) с отменой контекста:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
 
// ... запускаем сервер с этим ctx ...
<-ctx.Done()        // ждём сигнала
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    // обработать ошибку: например, deadline exceeded
}

Отмена корня сообщает об остановке тем задачам, которым передан этот контекст или его потомок и которые проверяют отмену. Контексты HTTP-запросов не становятся его детьми автоматически: связь можно задать явно через http.Server.BaseContext, если этого требует жизненный цикл сервиса. Передача сигнала и завершение обработчиков — разные события. Для Shutdown нужен отдельный, ещё не отменённый контекст: передавать ему ctx после <-ctx.Done() нельзя. Отмена фоновой работы сама по себе не запускает HTTP shutdown; это отдельное действие.

Этот пример со signal/os/net/http нельзя запустить в браузере (нет сетевого стека и ОС-сигналов), поэтому он дан статичным блоком. Механика отмены, которую он демонстрирует, ровно та же, что в запускаемых примерах выше.

Параллелизм и гонки — честная оговорка

Контекст часто живёт рядом с конкурентной работой: десятки горутин делят один ctx, обращаются к общим данным, конкурируют реплики. Настоящего параллелизма и race-детектора в браузерном песочнике нет — yaegi исполняет горутины однопоточно. Поэтому пример «гонка реплик» ниже дан статичным блоком, без запуска, с разбором — запускать его в плейграунде смысла нет, гонку ты там не увидишь.

// Гонка реплик: бьём в несколько источников, берём первый успешный ответ.
// Когда один ответил — отменяем остальных через общий ctx.
func firstSuccess(ctx context.Context, replicas []Replica) (Result, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // отменит «проигравшие» горутины
 
    if len(replicas) == 0 {
        return Result{}, fmt.Errorf("нет реплик")
    }
    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{result: res, err: 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 // все реплики ответили ошибкой
}

Это context-аналог or-channel из главы каналы: первый успешный результат «выигрывает», defer cancel() гасит проигравших, а буфер на len(replicas) не даёт опоздавшим горутинам залипнуть на отправке. На реальной машине под go test -race проверяй гонки данных; в браузере этой проверки нет. Детектор гонок проверяет конфликтный доступ к памяти, а завершение горутин после отмены нужно проверять отдельно.

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

  • Забыли defer cancel(). Утечка контекста, а для таймаутов — ещё и ресурсы таймера. Самый частый баг; go vet его ловит, не игнорируй предупреждение.
  • Не проверяют ctx.Done() в долгом цикле. Отмена есть, а толку нет — операция не реагирует. Долгие циклы обязаны периодически заглядывать в Done().
  • Хранят context в структуре или передают nil. Оба — антипаттерны. ctx идёт аргументом; если его пока нет — context.TODO().
  • Кладут зависимости в WithValue вместо явных аргументов. Логгер, конфиг, пул соединений — это не request-scoped данные.
  • Игнорируют ctx.Err() и возвращают свою выдуманную ошибку, теряя причину отмены. Отдавай наверх именно ctx.Err() — вызывающий хочет знать, это Canceled или DeadlineExceeded.

Мысленная модель

Держи в голове картинку: context — это дерево сигналов отмены с прикреплёнными дедлайнами и значениями. Отмена течёт сверху вниз через закрытие Done()-каналов. Каждая порождающая функция выдаёт тебе cancel, которым ты обязан воспользоваться. Думай о контексте как об области одной операции: после отмены работа должна сворачиваться в местах, где проверяет сигнал. Сам context не прерывает произвольный код и не ждёт его выхода.

Попробуйте сами

Создайте отменяемого родителя и двух детей через WithCancel. Отмените только первого ребёнка, выпишите ожидаемые Err для всех трёх контекстов. Затем отмените родителя и проверьте ожидания. Где нужно ждать завершения работ, если вместо одной проверки контекст передан горутинам?

Проверка

После отмены первого ребёнка: у родителя и второго ребёнка Err равен nil, у первого — context.Canceled. После отмены родителя отменены оба ребёнка:

parent, cancelParent := context.WithCancel(context.Background())
defer cancelParent()
a, cancelA := context.WithCancel(parent)
defer cancelA()
b, cancelB := context.WithCancel(parent)
defer cancelB()
 
cancelA()
<-a.Done()
fmt.Println(parent.Err(), a.Err(), b.Err())
cancelParent()
<-b.Done()
fmt.Println(parent.Err(), a.Err(), b.Err())

Нужны импорты context и fmt. Отмена идёт от родителя вниз; братья друг друга не отменяют. Для ожидания завершения самих горутин потребуется отдельный WaitGroup или канал завершения: cancel сообщает о событии, а не подтверждает выполнение очистки.

Что дальше

  • каналы — закрытие канала как broadcast, на котором стоит вся механика Done().
  • select — как именно ветка <-ctx.Done() уживается с рабочими ветками.
  • паттерны — worker pool'ы и пайплайны, которые протаскивают ctx насквозь и гасятся одной отменой.
  • утечки и гонки — как проверить, что после отмены ни одна горутина не утекла.

Как это связано с задачами тренажёра

Context — стержень топика 4 (13–15): отменяемые операции, таймауты, гонки реплик (задача 15 «First Successful» — тот самый context-аналог or-channel из задачи 01). В топике 7 (23–32) context пронизывает сервисные конструкции — worker pool'ы из паттернов, пайплайны, graceful shutdown. А проверка «не утекли ли горутины после отмены» — прямой мост к главе утечки и гонки.

Закрепи на практике07. Idempotent Initializer (sync.Once)