39. Context. Управление жизненным циклом операций
«Управление сложностью — вот суть программирования».
— Брайан Керниган
До версии Go 1.7 бэкенд-разработчики страдали. Представьте типичную картину: на ваш сервер пришел тяжелый HTTP-запрос. Сервер создал главную горутину, которая породила еще пять дочерних: одна пошла в PostgreSQL, вторая — в Redis, третья — в сторонний платежный шлюз, а две другие занялись фоновыми математическими расчетами.
В этот момент пользователь в браузере нажал кнопку «Отмена» или закрыл вкладку. Сетевое соединение оборвалось. Что должно произойти? Все пять горутин обязаны немедленно прекратить работу и освободить память, дескрипторы сокетов и пулы соединений к базе данных.
Но до Go 1.7 для этого приходилось вручную прокидывать канал done <-chan struct{} через все слои абстракций. Если программист забывал проверить этот канал на каком-то уровне вложенности, горутины превращались в зомби: они продолжали молотить процессором впустую, выполняя никому не нужный запрос.
В Go 1.7 из недр инфраструктуры Google в стандартную библиотеку был перенесен пакет context. Он стал абсолютным стандартом языка (Idiomatic Go) для решения трех важнейших задач распределенных систем:
- Каскадная отмена операций (Cancellation) по всей глубине дерева вызовов.
- Таймауты и жесткие дедлайны (Timeouts & Deadlines).
- Передача Request-Scoped метаданных (данных, живущих строго в рамках одного запроса: TraceID, SpanID, авторизационные токены).
1. Интерфейс Context
context — это не закрытая магия ядра рантайма, а лаконичный и элегантный интерфейс, описанный в стандартной библиотеке:
type Context interface {
// Возвращает время, когда контекст будет автоматически отменен
Deadline() (deadline time.Time, ok bool)
// Возвращает канал, который закроется при отмене контекста
Done() <-chan struct{}
// Возвращает причину отмены (nil, если контекст еще жив)
Err() error
// Извлекает значение по ключу
Value(key any) any
}
Ключевой метод здесь — Done(). Он возвращает канал <-chan struct{}. Как мы подробно разбирали в статье 38. Select. Ожидание нескольких каналов, чтение из закрытого канала мгновенно возвращает zero-value без блокировки.
Этот простой трюк превращает закрытие канала в идеальный механизм широковещательного сигнала (broadcast): тысячи горутин, одновременно слушающих <-ctx.Done() в своих select, мгновенно просыпаются и начинают процедуру корректного завершения (Graceful Teardown). Но помните: отмена кооперативна. cancel() лишь закрывает канал Done() — горутина должна сама проверять ctx.Done() или ctx.Err() (context.Canceled, context.DeadlineExceeded); код, который контекст не проверяет, не остановится.
2. Иерархия: Дерево контекстов
Контексты в Go никогда не существуют изолированно — они всегда образуют направленное дерево (Directed Acyclic Graph). В корне любого дерева лежит пустой «вечный» контекст:
context.Background()— корневой контекст всей программы. Используется на самом верхнем уровне: в функцииmain(), при инициализации серверов или в тестах.context.TODO()— технический близнецBackground(). Он сообщает читателю кода и статическим анализаторам: «Здесь должен быть осмысленный контекст, но автор еще не решил, откуда его прокинуть» (маркер для будущего рефакторинга).
От корневого контекста мы отпочковываем дочерние контексты с помощью функций-оберток. Фундаментальное правило: отмена родительского контекста всегда каскадно отменяет всех его детей. Но отмена дочернего контекста никогда не влияет на родителя или соседей.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
BG["context.Background()"]:::process -->|"WithCancel"| C1["Контекст Отмены - C1"]
C1 -->|"WithTimeout"| T1["Таймаут Контекст - T1"]
C1 -->|"WithValue"| V1["Контекст Значений - V1"]
V1 -->|"WithCancel"| C2["Контекст Отмены - C2"]
C1 -.->|"Если C1 отменен<br>отменяется все дерево ниже"| T1
C1 -.->|"Сигнал отмены"| V1
V1 -.->|"Сигнал отмены"| C2
3. Под капотом: cancelCtx и Mutex
Когда вы вызываете ctx, cancel := context.WithCancel(parent), под капотом создается структура cancelCtx.
Как работает отмена (Mechanical Sympathy):
- При вызове функции
cancel()захватывается внутренний мьютексmu. - Если канал
doneуже был создан, он закрывается системной функциейclose(). Это мгновенно будит все горутины, которые висели вselect { case <-ctx.Done(): }. - Рантайм итерируется по хэш-таблице
childrenи рекурсивно вызываетcancel()для каждого ребенка. - Контекст удаляет себя из мапы
childrenсвоего родителя. Это критически важно: благодаря этому сборщик мусора (GC) может немедленно освободить память, занятую завершенной подветкой дерева!
sequenceDiagram
autonumber
participant App as Приложение / Handler
participant Parent as Родитель cancelCtx
participant Child as Дочерний cancelCtx
participant Worker as Фоновая Горутина
App->>Parent: cancel()
Parent->>Parent: mu.Lock()
Parent->>Parent: close(done) (Сигнал отмены)
Parent->>Child: Рекурсивный cancel()
Child->>Child: close(done)
Child-->>Worker: <-ctx.Done() разблокирован!
Worker->>Worker: Очистка ресурсов и выход
Parent->>Parent: Удаление из children родителя
Parent->>Parent: mu.Unlock()
4. Под капотом: valueCtx и промахи кэша
Функция context.WithValue(parent, key, val) возвращает структуру valueCtx. Это, пожалуй, самая спорная часть пакета context.
С точки зрения внутренней архитектуры, valueCtx — это не хэш-таблица, а обычный узел в односвязном списке, развернутом снизу вверх:
type valueCtx struct {
Context // Указатель на родителя
key, val any
}
Когда вы вызываете ctx.Value(myKey), рантайм:
- Проверяет текущий
valueCtx. Если ключ совпадает — возвращает значение. - Если нет, он идет вверх по дереву через встроенный интерфейс
Context, обращаясь к родителю. - И так до самого корня (
Background), пока не найдет ключ или не вернетnil.
flowchart BT
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
V3["valueCtx (UserRole='Admin')"]:::process -->|"Parent"| V2["valueCtx (UserID=42)"]
V2 -->|"Parent"| V1["cancelCtx"]
V1 -->|"Parent"| Root["context.Background()"]
Mechanical Sympathy: Цена поиска
Связанный список — худшая структура данных для современных процессоров из-за Pointer Chasing (погони за указателями). Обход дерева контекстов вверх приводит к постоянным L1/L2 Cache Misses (промахам кэша), так как каждый контекст аллоцирован в случайном месте кучи. Операция ctx.Value имеет сложность $O(N)$ (где $N$ — глубина дерева) и работает крайне медленно по меркам высоконагруженных систем.
Защита от коллизий ключей:
Так как тип ключа any, если вы используете context.WithValue(ctx, "userID", 123) (такой строковый ключ ловит и линтер staticcheck, проверка SA1029), другой пакет в вашем проекте может случайно перетереть ваше значение, использовав ту же строку "userID".
Идиоматичный подход — использовать кастомные неэкспортируемые типы:
type contextKey string
const userIDKey contextKey = "userID"
ctx = context.WithValue(ctx, userIDKey, 123)
// Внешний пакет физически не сможет обратиться к userIDKey
5. Архитектурные правила Idiomatic Go
Создатели Go прописали строгие правила использования контекста, за нарушение которых на код-ревью обычно больно бьют по рукам:
-
Context всегда первый аргумент. Функция, которой нужен контекст, должна принимать его первым параметром. Имя переменной всегда должно быть
ctx.Go func FetchUser(ctx context.Context, id int) (*User, error) -
Никогда не храните Context внутри Struct.
Контекст описывает жизненный цикл запроса/операции, а структура (например, клиент БД) существует годами.Go // ПЛОХО (Антипаттерн) type DBClient struct { ctx context.Context // Контекст протухнет, и клиент станет бесполезным db *sql.DB } // ХОРОШО func (c *DBClient) DoQuery(ctx context.Context, query string) -
Не передавайте
nilконтекст. Даже если вам кажется, что контекст сейчас не нужен, передайтеcontext.TODO(). Передачаnilприведет к панике при первой же попытке вызоваctx.Done().
Итог
- Context решает проблему каскадной отмены горутин, отвязывая нас от ручного жонглирования каналами
done. - Контексты образуют иерархическое дерево. Отмена родителя немедленно и рекурсивно отменяет всех детей (через закрытие каналов и защищенный
Mutex-ом список). - Всегда делайте
defer cancel(), иначе таймеры останутся в памяти (Goroutine/Memory Leak). WithValueработает как односвязный список, что медленно и недружелюбно к кэшу CPU. Используйте его только для Request-Scoped метаданных (TraceID), а не для передачи параметров бизнес-логики.
Каналы и Context — это мощные инструменты парадигмы CSP. Они отлично подходят для передачи данных и сигналов отмены. Но иногда нам не нужно передавать данные, а нужно просто защитить участок памяти от одновременной записи или подождать завершения пачки горутин. В таких ситуациях каналы избыточны и медленны. На помощь приходят классические примитивы синхронизации, которые мы разберем в следующей статье: 40. sync.WaitGroup, Mutex, RWMutex.