GraphLMS

Go
0/32 решеноНачать
Глава 07 · Основы~13 мин чтения

context

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

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

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

В этой главе разберём, как контексты складываются в дерево отмены, как сигнал течёт по нему через Done(), почему cancel нужно вызывать всегда, и где 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 вылезет в рантайме.

Context и graceful shutdown

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

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
 
// ... запускаем сервер с этим ctx ...
<-ctx.Done()        // ждём сигнала
srv.Shutdown(ctx)   // начинаем мягко гасить

Один отменённый корень каскадом останавливает все обработчики, фоновые задачи и исходящие вызовы — то самое «дёрнуть за верёвочку», только теперь верёвочку дёргает сама операционная система.

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

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

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

// Гонка реплик: бьём в несколько источников, берём первый успешный ответ.
// Когда один ответил — отменяем остальных через общий ctx.
func firstSuccess(ctx context.Context, replicas []Replica) (Result, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // отменит «проигравшие» горутины
 
    results := make(chan Result, len(replicas))
    for _, r := range replicas {
        go func(r Replica) {
            if res, err := r.Query(ctx); err == nil {
                select {
                case results <- res:
                case <-ctx.Done(): // нас уже опередили — тихо уходим
                }
            }
        }(r)
    }
 
    select {
    case res := <-results:
        return res, nil          // defer cancel() погасит остальных
    case <-ctx.Done():
        return Result{}, ctx.Err()
    }
}

Это 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, которым ты обязан воспользоваться. Думай о контексте как об «области видимости запроса»: пока он жив — работаем; отменился — сворачиваемся везде и сразу.

Что дальше

  • каналы — закрытие канала как 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. А проверка «не утекли ли горутины после отмены» — прямой мост к главе утечки и гонки.