Context в Go

Представьте: пользователь отправил HTTP-запрос, который запускает несколько горутин — одна ходит в базу, другая в Redis, третья вызывает внешний API. Пользователь не дождался и закрыл вкладку. Без механизма отмены все три горутины продолжат работу — тратят ресурсы, держат соединения, делают бессмысленную работу. context.Context — это именно тот механизм, который решает эту проблему.


Что такое Context

Context — это интерфейс из четырёх методов:

go
type Context interface { Done() <-chan struct{} // канал, закрывается при отмене Err() error // причина отмены (nil если не отменён) Deadline() (time.Time, bool) // дедлайн если установлен Value(key any) any // значение по ключу }

Контекст всегда передаётся явно, первым аргументом, по соглашению называется ctx:

go
func ProcessOrder(ctx context.Context, orderID int) error { user, err := fetchUser(ctx, orderID) if err != nil { return err } return saveResult(ctx, user) }

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


Дерево контекстов

Контексты образуют дерево: каждый дочерний контекст наследует свойства родителя. Отмена родителя автоматически отменяет всех потомков — в любую глубину:

go
ctx := context.Background() // корень дерева ctxA, cancelA := context.WithCancel(ctx) // дочерний A ctxB, cancelB := context.WithCancel(ctxA) // дочерний B от A ctxC, cancelC := context.WithCancel(ctxA) // дочерний C от A cancelA() // отменяет A, B и C автоматически // cancelB и cancelC теперь no-op — уже отменены

Это ключевое свойство: HTTP-сервер может отменить корневой контекст запроса, и все вложенные операции — запросы к БД, внешние вызовы, дочерние горутины — получат сигнал отмены автоматически.


context.Background и context.TODO

Два корневых контекста, которые никогда не отменяются:

go
// Background — настоящий корень. Используется в main, тестах, // инициализации и как корень для всех остальных контекстов ctx := context.Background() // TODO — заглушка. Используется когда контекст нужен по сигнатуре, // но ещё не определено откуда он должен приходить ctx := context.TODO()

context.TODO() — это сигнал команде: "здесь нужно разобраться с контекстом позже". Линтеры умеют находить TODO контексты в коде.


WithCancel — ручная отмена

Создаёт контекст с функцией отмены. Отмена происходит явным вызовом cancel:

go
func fetchWithCancel() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // всегда! даже если работа завершилась успешно go func() { // имитируем отмену через секунду time.Sleep(time.Second) cancel() }() err := doLongWork(ctx) if err != nil { fmt.Println(err) // context canceled } }

defer cancel() — обязательное правило. Если не вызвать cancel, контекст и все его ресурсы не освободятся до отмены родителя. Это утечка — пусть небольшая, но в долгоживущих сервисах накапливается.

Как проверить отмену в своём коде

go
func doLongWork(ctx context.Context) error { for { select { case <-ctx.Done(): return ctx.Err() // context.Canceled или context.DeadlineExceeded default: // продолжаем работу if err := doStep(); err != nil { return err } } } }

Паттерн select с ctx.Done() и default — стандартный способ встроить поддержку отмены в цикл. Если работа делается не в цикле, а линейно — проверяем перед каждой дорогой операцией:

go
func processOrder(ctx context.Context, id int) error { if err := ctx.Err(); err != nil { return err // быстрая проверка без select } user, err := db.GetUser(ctx, id) // ctx передаём дальше if err != nil { return err } if err := ctx.Err(); err != nil { // снова проверяем между операциями return err } return sendEmail(ctx, user) }

WithTimeout и WithDeadline

Оба создают контекст, который автоматически отменяется по времени. Разница только в способе задания момента отмены:

go
// WithTimeout — через продолжительность от текущего момента ctx, cancel := context.WithTimeout(parent, 5*time.Second) defer cancel() // WithDeadline — через конкретный момент времени deadline := time.Now().Add(5 * time.Second) ctx, cancel := context.WithDeadline(parent, deadline) defer cancel()

WithTimeout(parent, d) — это просто синтаксический сахар над WithDeadline(parent, time.Now().Add(d)).

Таймауты в HTTP-клиенте

Самый распространённый сценарий — ограничить время на внешний вызов:

go
func callExternalAPI(url string) (*Response, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { // если таймаут — err содержит context.DeadlineExceeded return nil, fmt.Errorf("external API call failed: %w", err) } defer resp.Body.Close() // ... }

Проверка причины отмены

go
ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() err := doWork(ctx) if err != nil { switch ctx.Err() { case context.DeadlineExceeded: fmt.Println("таймаут — операция заняла слишком долго")\n case context.Canceled: fmt.Println("отменено явно через cancel()") } }

Дедлайн из родителя наследуется

Если родительский контекст уже имеет дедлайн — дочерний не может его расширить, только сузить:

go
// Родитель с дедлайном через 2 секунды parent, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // Попытка дать дочернему 10 секунд — бессмысленно, // родитель отменится через 2 секунды и дочерний тоже child, cancel2 := context.WithTimeout(parent, 10*time.Second) defer cancel2() // Дочерний с меньшим таймаутом — имеет смысл child2, cancel3 := context.WithTimeout(parent, 500*time.Millisecond) defer cancel3()

WithValue — передача значений

WithValue создаёт контекст, несущий пару ключ-значение. Значение доступно через ctx.Value(key) в любом дочернем контексте:

go
type contextKey string // собственный тип ключа const ( requestIDKey contextKey = "requestID" userIDKey contextKey = "userID" ) // Кладём значение ctx := context.WithValue(parent, requestIDKey, "abc-123") // Читаем в любом месте цепочки вызовов func handler(ctx context.Context) { reqID, ok := ctx.Value(requestIDKey).(string) if !ok { reqID = "unknown" } fmt.Println("request:", reqID) }

Почему ключ должен быть собственным типом

Контекст глобален для цепочки вызовов, и коллизии ключей реальны. Если использовать строку как ключ напрямую — любой пакет может случайно перезаписать значение:

go
// Плохо: строка как ключ — коллизии между пакетами ctx = context.WithValue(ctx, "userID", 42) ctx = context.WithValue(ctx, "userID", 99) // перезаписали! // Хорошо: собственный тип — гарантированно уникален type myPkg struct{ key string } ctx = context.WithValue(ctx, myPkg{"userID"}, 42)

Собственный неэкспортируемый тип ключа — единственный надёжный способ избежать коллизий.

Что передавать через контекст

Context — не замена аргументам функции. Через него передают cross-cutting concerns — данные, сквозные для всего запроса:

go
// Хорошо: через контекст // - Request ID / Trace ID для логирования и трейсинга // - User ID аутентифицированного пользователя // - Tenant ID в мультитенантных системах // - Deadline / таймаут // Плохо: через контекст // - Параметры бизнес-логики (передавайте явными аргументами) // - Опциональные зависимости (инъектируйте через конструктор) // - Ошибки (возвращайте явно)

Если что-то нужно везде в цепочке вызовов запроса — контекст. Если что-то нужно конкретной функции — явный аргумент.


Context в HTTP-сервере

Стандартная библиотека уже интегрирует Context: каждый входящий запрос несёт контекст, который отменяется когда клиент разрывает соединение:

go
func orderHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // контекст запроса — отменится если клиент ушёл // Добавляем request ID для трейсинга reqID := r.Header.Get("X-Request-ID") ctx = context.WithValue(ctx, requestIDKey, reqID) // Ограничиваем время обработки ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() result, err := processOrder(ctx, r) if err != nil { if errors.Is(err, context.Canceled) { // клиент ушёл — не нужно возвращать ошибку return } http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(result) }

Распространённые ошибки

Хранение контекста в структуре

go
// Плохо: контекст в структуре type Service struct { ctx context.Context // антипаттерн db *sql.DB } // Хорошо: контекст через аргумент метода type Service struct { db *sql.DB } func (s *Service) Process(ctx context.Context, id int) error { return s.db.QueryRowContext(ctx, "SELECT ...", id) }

Контекст привязан к конкретному запросу и живёт столько, сколько живёт запрос. Структура может жить дольше — хранить в ней контекст означает держать устаревший или уже отменённый контекст.

Передача nil вместо контекста

go
// Плохо: nil вызовет панику внутри при обращении к ctx.Done() processOrder(nil, orderID) // Хорошо: если нет контекста — используй Background или TODO processOrder(context.Background(), orderID) processOrder(context.TODO(), orderID) // если контекст появится позже

Игнорирование cancel

go
// Утечка: cancel никогда не вызывается ctx, _ := context.WithTimeout(parent, time.Second) // Правильно ctx, cancel := context.WithTimeout(parent, time.Second) defer cancel()

Вопросы на собеседовании

Q: Что такое Context и зачем он нужен?
A: Механизм для передачи сигнала отмены, дедлайнов и request-scoped данных через цепочку вызовов. Решает задачу: как остановить все горутины запроса если клиент ушёл или истёк таймаут. Контекст передаётся явно первым аргументом — это намеренное решение, делающее поддержку отмены видимой в API.

Q: Чем отличаются WithCancel, WithTimeout и WithDeadline?
A: WithCancel — отмена только явным вызовом cancel(). WithTimeout — автоматическая отмена через заданную продолжительность. WithDeadline — автоматическая отмена в конкретный момент времени. WithTimeout(ctx, d) — синтаксический сахар над WithDeadline(ctx, time.Now().Add(d)).

Q: Что произойдёт если не вызвать cancel()?
A: Утечка ресурсов: контекст и все его дочерние контексты не будут освобождены до отмены родителя. В долгоживущих сервисах это накапливается. Поэтому defer cancel() — обязательное правило сразу после создания контекста.

Q: Как устроено дерево контекстов? Что происходит при отмене родителя?
A: Контексты образуют дерево — каждый дочерний наследует свойства родителя. Отмена родителя автоматически рекурсивно отменяет всех потомков. Дочерний может отмениться раньше родителя, но не позже.

Q: Почему нельзя использовать строку как ключ для WithValue?
A: Коллизии между пакетами: разные пакеты могут использовать одинаковые строки и перезаписать значения друг друга. Собственный неэкспортируемый тип ключа (type myKey struct{}) гарантирует уникальность — разные пакеты не могут создать одинаковый тип.

Q: Что передавать через контекст, а что нет?
A: Через контекст — cross-cutting concerns запроса: request ID, trace ID, user ID аутентифицированного пользователя, tenant ID. Не через контекст: параметры бизнес-логики (явные аргументы), зависимости (инъекция через конструктор), ошибки (явный возврат). Контекст не замена аргументам функции.

Q: Почему нельзя хранить контекст в структуре?
A: Контекст привязан к конкретному запросу и имеет время жизни запроса. Структура может жить дольше — хранение контекста в ней означает риск использования устаревшего или уже отменённого контекста. Контекст всегда передаётся через аргумент метода.

Q: Как проверить причину отмены контекста?
A: Через ctx.Err(): возвращает context.Canceled при явной отмене через cancel(), и context.DeadlineExceeded при истечении таймаута или дедлайна. Пока контекст активен — возвращает nil.

Q: Может ли дочерний контекст расширить дедлайн родителя?
A: Нет. Если родитель отменится раньше — дочерний тоже отменится, даже если его собственный дедлайн ещё не наступил. Дочерний контекст может только сузить дедлайн, но не расширить.

Задачи: Context


Задача 1: Передача request ID

Уровень: Лёгкая

Что проверяет: WithValue, правильный тип ключа, извлечение значений

Условие: Реализуй middleware-функцию withRequestID(ctx context.Context, id string) context.Context и функцию getRequestID(ctx context.Context) string. Используй собственный тип ключа. Если ID нет в контексте — возвращай "unknown".

Решение:

go
package main import ( "context" "fmt" ) type contextKey string const requestIDKey contextKey = "requestID" func withRequestID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, requestIDKey, id) } func getRequestID(ctx context.Context) string { id, ok := ctx.Value(requestIDKey).(string) if !ok || id == "" { return "unknown" } return id } func handle(ctx context.Context) { fmt.Printf("[%s] handling request\n", getRequestID(ctx)) } func main() { ctx := context.Background() handle(ctx) // [unknown] handling request ctx = withRequestID(ctx, "abc-123") handle(ctx) // [abc-123] handling request }

Задача 2: Отмена по первому результату

Уровень: Средняя

Что проверяет: WithCancel, паттерн first-result-wins

Условие: Напиши функцию fastest(ctx context.Context, fns []func(ctx context.Context) int) int которая запускает все функции конкурентно и возвращает результат той которая завершится первой. Остальные горутины должны получить сигнал отмены.

Решение:

go
package main import ( "context" "fmt" "time" ) func fastest(ctx context.Context, fns []func(ctx context.Context) int) int { ctx, cancel := context.WithCancel(ctx) defer cancel() ch := make(chan int, len(fns)) // буферизованный — горутины не зависнут for _, fn := range fns { fn := fn go func() { result := fn(ctx) select { case ch <- result: case <-ctx.Done(): } }() } result := <-ch cancel() // отменяем остальные return result } func main() { ctx := context.Background() fns := []func(ctx context.Context) int{ func(ctx context.Context) int { time.Sleep(300 * time.Millisecond) return 1 }, func(ctx context.Context) int { time.Sleep(100 * time.Millisecond) return 2 // победитель }, func(ctx context.Context) int { time.Sleep(200 * time.Millisecond) return 3 }, } fmt.Println(fastest(ctx, fns)) // 2 }

Задача 3: Цепочка таймаутов

Уровень: Сложная

Что проверяет: наследование дедлайнов, понимание что дочерний не расширяет родительский

Условие: Что выведет код? Объясни почему.

go
package main import ( "context" "fmt" "time" ) func operation(ctx context.Context, name string, duration time.Duration) error { select { case <-time.After(duration): fmt.Printf("%s: completed\n", name) return nil case <-ctx.Done(): fmt.Printf("%s: cancelled — %v\n", name, ctx.Err()) return ctx.Err() } } func main() { parent, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() child, cancelChild := context.WithTimeout(parent, 500*time.Millisecond) defer cancelChild() operation(child, "fast", 100*time.Millisecond) operation(child, "slow", 300*time.Millisecond) }

Ожидаемый ответ:

text
fast: completed slow: cancelled — context deadline exceeded

Решение:

go
// parent: таймаут 200ms // child: таймаут 500ms — но ограничен parent'ом в 200ms // "fast" (100ms): завершается за 100ms — успевает до любого дедлайна. // После "fast" прошло ~100ms. // "slow" (300ms): нужно ещё 300ms, итого ~400ms. // Но parent отменится через ~200ms от старта (100ms уже прошло, осталось ~100ms). // child отменяется вместе с parent. // Ошибка: context.DeadlineExceeded. // Вывод: дочерний контекст НЕ может расширить дедлайн родителя. // WithTimeout(parent, 500ms) реально даёт min(200ms, 500ms) = 200ms.