Context в Go
Представьте: пользователь отправил HTTP-запрос, который запускает несколько горутин — одна ходит в базу, другая в Redis, третья вызывает внешний API. Пользователь не дождался и закрыл вкладку. Без механизма отмены все три горутины продолжат работу — тратят ресурсы, держат соединения, делают бессмысленную работу. context.Context — это именно тот механизм, который решает эту проблему.
Что такое Context
Context — это интерфейс из четырёх методов:
type Context interface {
Done() <-chan struct{} // канал, закрывается при отмене
Err() error // причина отмены (nil если не отменён)
Deadline() (time.Time, bool) // дедлайн если установлен
Value(key any) any // значение по ключу
}
Контекст всегда передаётся явно, первым аргументом, по соглашению называется ctx:
func ProcessOrder(ctx context.Context, orderID int) error {
user, err := fetchUser(ctx, orderID)
if err != nil {
return err
}
return saveResult(ctx, user)
}
Это не просто соглашение — это архитектурное решение. Контекст явный, его видно в сигнатуре, и любой читатель кода понимает: функция поддерживает отмену и таймауты.
Дерево контекстов
Контексты образуют дерево: каждый дочерний контекст наследует свойства родителя. Отмена родителя автоматически отменяет всех потомков — в любую глубину:
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
Два корневых контекста, которые никогда не отменяются:
// Background — настоящий корень. Используется в main, тестах,
// инициализации и как корень для всех остальных контекстов
ctx := context.Background()
// TODO — заглушка. Используется когда контекст нужен по сигнатуре,
// но ещё не определено откуда он должен приходить
ctx := context.TODO()
context.TODO() — это сигнал команде: "здесь нужно разобраться с контекстом позже". Линтеры умеют находить TODO контексты в коде.
WithCancel — ручная отмена
Создаёт контекст с функцией отмены. Отмена происходит явным вызовом cancel:
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, контекст и все его ресурсы не освободятся до отмены родителя. Это утечка — пусть небольшая, но в долгоживущих сервисах накапливается.
Как проверить отмену в своём коде
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 — стандартный способ встроить поддержку отмены в цикл. Если работа делается не в цикле, а линейно — проверяем перед каждой дорогой операцией:
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
Оба создают контекст, который автоматически отменяется по времени. Разница только в способе задания момента отмены:
// 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-клиенте
Самый распространённый сценарий — ограничить время на внешний вызов:
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()
// ...
}
Проверка причины отмены
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()")
}
}
Дедлайн из родителя наследуется
Если родительский контекст уже имеет дедлайн — дочерний не может его расширить, только сузить:
// Родитель с дедлайном через 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) в любом дочернем контексте:
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)
}
Почему ключ должен быть собственным типом
Контекст глобален для цепочки вызовов, и коллизии ключей реальны. Если использовать строку как ключ напрямую — любой пакет может случайно перезаписать значение:
// Плохо: строка как ключ — коллизии между пакетами
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 — данные, сквозные для всего запроса:
// Хорошо: через контекст
// - Request ID / Trace ID для логирования и трейсинга
// - User ID аутентифицированного пользователя
// - Tenant ID в мультитенантных системах
// - Deadline / таймаут
// Плохо: через контекст
// - Параметры бизнес-логики (передавайте явными аргументами)
// - Опциональные зависимости (инъектируйте через конструктор)
// - Ошибки (возвращайте явно)
Если что-то нужно везде в цепочке вызовов запроса — контекст. Если что-то нужно конкретной функции — явный аргумент.
Context в HTTP-сервере
Стандартная библиотека уже интегрирует Context: каждый входящий запрос несёт контекст, который отменяется когда клиент разрывает соединение:
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)
}
Распространённые ошибки
Хранение контекста в структуре
// Плохо: контекст в структуре
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 вместо контекста
// Плохо: nil вызовет панику внутри при обращении к ctx.Done()
processOrder(nil, orderID)
// Хорошо: если нет контекста — используй Background или TODO
processOrder(context.Background(), orderID)
processOrder(context.TODO(), orderID) // если контекст появится позже
Игнорирование cancel
// Утечка: 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".
Решение:
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 которая запускает все функции конкурентно и возвращает результат той которая завершится первой. Остальные горутины должны получить сигнал отмены.
Решение:
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: Цепочка таймаутов
Уровень: Сложная
Что проверяет: наследование дедлайнов, понимание что дочерний не расширяет родительский
Условие: Что выведет код? Объясни почему.
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)
}
Ожидаемый ответ:
fast: completed
slow: cancelled — context deadline exceeded
Решение:
// 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.