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

context

15 мин чтенияКонтент v0.12.1

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

Сервис обрабатывает 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, новые функции пакета и сигналы ОС можно перечитать позже.

Соглашение: ctx — первый аргумент

Во всём 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)     // плюс отмена в момент deadline
defer cancel() // вызывать обязательно; почему — ниже

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

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

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

Дочерний контекст не может жить дольше родителя. Если у родителя до дедлайна осталось 50 мс, а вы создали ребёнка с таймаутом 200 мс, ребёнок всё равно будет отменён через 50 мс вместе с родителем.

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.Err() возвращает nil. ctx.Deadline() сообщает дедлайн, если он задан; по нему можно решить, есть ли смысл начинать долгую операцию.

Важно понимать, чего context не делает. Он не прерывает выполняющийся код. Если функция десять секунд считает в цикле и ни разу не смотрит на ctx.Done(), отмена на неё не подействует: она досчитает до конца. Отмена в Go кооперативная: код сам проверяет сигнал в удобных местах и сам сворачивается.

Воркер обрабатывает задачи, пока его не отменят:

package main
 
import (
	"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 завершился")
}
обработал задачу 1
обработал задачу 2
обработал задачу 3
воркер остановлен: context canceled
main завершился

Порядок вывода здесь гарантирован, и стоит понять почему. Канал jobs небуферизованный: каждая отправка в main завершается только тогда, когда воркер принял задачу. После третьей отправки main вызывает cancel(). В этот момент в jobs ничего нет, готова только ветка ctx.Done(), и воркер выходит. Если бы jobs был буферизованным и задачи лежали в буфере в момент отмены, обе ветки оказались бы готовы, и select мог бы выбрать любую: часть задач осталась бы необработанной.

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

Часто отменять руками не нужно: достаточно сказать «жди не дольше N». Это WithTimeout. Функция call ждёт либо ответа, либо отмены контекста:

package main
 
import (
	"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

Самая частая ошибка — не вызвать 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 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, "сходили в БД")
 
	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.

ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
 
cancel(errors.New("реплика вернула 500"))
fmt.Println(ctx.Err())          // context canceled
fmt.Println(context.Cause(ctx)) // реплика вернула 500

context.WithoutCancel(ctx) создаёт контекст, который сохраняет значения родителя, но не отменяется вместе с ним. Пригодится, когда после ответа клиенту нужно дописать аудит-лог: контекст запроса к этому моменту уже отменён.

context.AfterFunc(ctx, f) вызывает f в отдельной горутине после отмены ctx. Возвращает функцию stop, которая отменяет эту регистрацию. Удобно, чтобы связать отмену контекста с API, которое о контекстах ничего не знает, например закрыть соединение.

Перед задачей 13: HTTP-запрос с контекстом

HTTP-запрос обращается к серверу по адресу (URL), например https://example.com/data. Метод GET запрашивает данные. Сервер отвечает кодом статуса (200 — успех, 404 — не найдено) и телом ответа. Тело resp.Body — поток байтов: его читают, например, io.ReadAll(resp.Body), а после чтения обязательно закрывают через resp.Body.Close(), иначе соединение не вернётся в пул и будет держать ресурсы.

Запрос с контекстом в задаче 13 собирается так:

  1. создать производный контекст с таймаутом и сразу поставить defer cancel();
  2. создать запрос через http.NewRequestWithContext(ctx, http.MethodGet, url, nil) и проверить ошибку;
  3. выполнить http.DefaultClient.Do(req) и проверить ошибку; если ошибка есть, ответа нет и обращаться к resp нельзя;
  4. поставить defer resp.Body.Close();
  5. прочитать тело и проверить ошибку чтения.

Две тонкости. Таймаут ограничивает ожидание и сверху ограничен родительским контекстом: если у родителя осталось 50 мс, ребёнок с таймаутом 200 мс отменится через 50. И ответ с кодом 404 или 500 — не ошибка Do: Do вернёт err == nil, а код нужно проверять отдельно, если этого требует условие. Сети в браузерной песочнице нет, такой код проверяйте локально.

Graceful shutdown

Корректное завершение сервиса — место, где дерево отмены видно целиком. 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() у всех трёх. Затем отмените родителя и снова запишите. Если вместо простых проверок контексты переданы горутинам, как убедиться, что те действительно завершились?

Проверка

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

Отмена идёт от родителя вниз; братья друг друга не отменяют. Err() здесь можно проверять сразу после вызова cancel: к моменту возврата из cancel контекст и все его потомки уже отменены. Чтобы дождаться горутин, которым передали контексты, нужен WaitGroup или канал завершения: cancel сообщает о событии, но не подтверждает, что горутины закончили работу.

Что дальше

Следующая глава, «Паттерны», собирает из каналов, select и контекста конвейеры и пулы воркеров, которые останавливаются одной отменой.

В задачах курса context — основа блока 4 (задачи 13–15: HTTP с таймаутом, отмена набора фоновых задач, первый успешный ответ). В блоке 7 он есть в каждом пуле: отмена задач, остановка с таймаутом. Как проверить, что после отмены не осталось горутин, разобрано в главе «Утечки горутин и гонки».

Продолжить

Остались вопросы?