39. Context. Управление жизненным циклом операций

«Управление сложностью — вот суть программирования».
— Брайан Керниган

До версии Go 1.7 бэкенд-разработчики страдали. Представьте типичную картину: на ваш сервер пришел тяжелый HTTP-запрос. Сервер создал главную горутину, которая породила еще пять дочерних: одна пошла в PostgreSQL, вторая — в Redis, третья — в сторонний платежный шлюз, а две другие занялись фоновыми математическими расчетами.

В этот момент пользователь в браузере нажал кнопку «Отмена» или закрыл вкладку. Сетевое соединение оборвалось. Что должно произойти? Все пять горутин обязаны немедленно прекратить работу и освободить память, дескрипторы сокетов и пулы соединений к базе данных.

Но до Go 1.7 для этого приходилось вручную прокидывать канал done <-chan struct{} через все слои абстракций. Если программист забывал проверить этот канал на каком-то уровне вложенности, горутины превращались в зомби: они продолжали молотить процессором впустую, выполняя никому не нужный запрос.

В Go 1.7 из недр инфраструктуры Google в стандартную библиотеку был перенесен пакет context. Он стал абсолютным стандартом языка (Idiomatic Go) для решения трех важнейших задач распределенных систем:

  1. Каскадная отмена операций (Cancellation) по всей глубине дерева вызовов.
  2. Таймауты и жесткие дедлайны (Timeouts & Deadlines).
  3. Передача Request-Scoped метаданных (данных, живущих строго в рамках одного запроса: TraceID, SpanID, авторизационные токены).

1. Интерфейс Context

context — это не закрытая магия ядра рантайма, а лаконичный и элегантный интерфейс, описанный в стандартной библиотеке:

Go
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):

  1. При вызове функции cancel() захватывается внутренний мьютекс mu.
  2. Если канал done уже был создан, он закрывается системной функцией close(). Это мгновенно будит все горутины, которые висели в select { case <-ctx.Done(): }.
  3. Рантайм итерируется по хэш-таблице children и рекурсивно вызывает cancel() для каждого ребенка.
  4. Контекст удаляет себя из мапы 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 — это не хэш-таблица, а обычный узел в односвязном списке, развернутом снизу вверх:

Go
type valueCtx struct {
    Context // Указатель на родителя
    key, val any
}

Когда вы вызываете ctx.Value(myKey), рантайм:

  1. Проверяет текущий valueCtx. Если ключ совпадает — возвращает значение.
  2. Если нет, он идет вверх по дереву через встроенный интерфейс Context, обращаясь к родителю.
  3. И так до самого корня (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".
Идиоматичный подход — использовать кастомные неэкспортируемые типы:

Go
type contextKey string
const userIDKey contextKey = "userID"

ctx = context.WithValue(ctx, userIDKey, 123)
// Внешний пакет физически не сможет обратиться к userIDKey

5. Архитектурные правила Idiomatic Go

Создатели Go прописали строгие правила использования контекста, за нарушение которых на код-ревью обычно больно бьют по рукам:

  1. Context всегда первый аргумент. Функция, которой нужен контекст, должна принимать его первым параметром. Имя переменной всегда должно быть ctx.

    Go
    func FetchUser(ctx context.Context, id int) (*User, error)
    
  2. Никогда не храните Context внутри Struct.
    Контекст описывает жизненный цикл запроса/операции, а структура (например, клиент БД) существует годами.

    Go
    // ПЛОХО (Антипаттерн)
    type DBClient struct {
        ctx context.Context // Контекст протухнет, и клиент станет бесполезным
        db  *sql.DB
    }
    
    // ХОРОШО
    func (c *DBClient) DoQuery(ctx context.Context, query string)
    
  3. Не передавайте nil контекст. Даже если вам кажется, что контекст сейчас не нужен, передайте context.TODO(). Передача nil приведет к панике при первой же попытке вызова ctx.Done().


Итог

  1. Context решает проблему каскадной отмены горутин, отвязывая нас от ручного жонглирования каналами done.
  2. Контексты образуют иерархическое дерево. Отмена родителя немедленно и рекурсивно отменяет всех детей (через закрытие каналов и защищенный Mutex-ом список).
  3. Всегда делайте defer cancel(), иначе таймеры останутся в памяти (Goroutine/Memory Leak).
  4. WithValue работает как односвязный список, что медленно и недружелюбно к кэшу CPU. Используйте его только для Request-Scoped метаданных (TraceID), а не для передачи параметров бизнес-логики.

Каналы и Context — это мощные инструменты парадигмы CSP. Они отлично подходят для передачи данных и сигналов отмены. Но иногда нам не нужно передавать данные, а нужно просто защитить участок памяти от одновременной записи или подождать завершения пачки горутин. В таких ситуациях каналы избыточны и медленны. На помощь приходят классические примитивы синхронизации, которые мы разберем в следующей статье: 40. sync.WaitGroup, Mutex, RWMutex.