Context в Go: отмена, дедлайны и best practices

Полный разбор context.Context в Go: WithCancel, WithTimeout, WithDeadline, WithValue, дерево контекстов, propagation отмены, best practices и антипаттерны — с примерами и вопросами с интервью.

02 марта 2026 г.20 минLexicon Team

context.Context — одна из тех тем, которые кажутся простыми ровно до того момента, пока на интервью не начинают задавать конкретные вопросы. Чем WithTimeout отличается от WithDeadline? Почему без defer cancel() происходит утечка? Как правильно хранить данные в контексте и почему строковые ключи — антипаттерн? Эти вопросы регулярно всплывают на middle и senior Go-интервью — и именно их мы разберём здесь.

context.Context появился в Go 1.7 как стандартизированный способ передавать сигнал отмены, дедлайн и request-scoped метаданные через цепочку функций и горутин. До этого каждая библиотека решала задачу по-своему: кто-то передавал done-каналы вручную, кто-то придумывал собственные структуры. Пакет context унифицировал подход и сегодня используется буквально везде: net/http, database/sql, gRPC, cloud-клиенты.

Если ты ещё не читал про горутины и каналы в Go — советуем начать оттуда. Context тесно связан с обоими механизмами: он передаётся через цепочки горутин и реализует отмену через каналы под капотом. Эта статья идёт дальше и разбирает context как самостоятельную тему, которую обязан знать каждый Go-разработчик.

Go для собеседований: 50 реальных вопросов

Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.

Начать тренировку

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

Весь пакет context строится вокруг одного интерфейса с четырьмя методами:

type Context interface {
    // Deadline возвращает время, когда контекст будет автоматически отменён.
    // ok == false, если дедлайн не установлен.
    Deadline() (deadline time.Time, ok bool)

    // Done возвращает канал, который закрывается при отмене контекста.
    // Возвращает nil, если контекст не может быть отменён (например, Background).
    Done() <-chan struct{}

    // Err возвращает причину отмены после закрытия Done.
    // context.Canceled — если был вызван cancel()
    // context.DeadlineExceeded — если истёк дедлайн
    // nil — если контекст ещё не отменён
    Err() error

    // Value возвращает значение, связанное с ключом, или nil.
    Value(key any) any
}

Ключевое: Done() возвращает канал <-chan struct{}, а не bool. Это намеренно — горутина может заблокироваться в select и ждать сразу несколько событий, одним из которых будет отмена контекста. Мы увидим этот паттерн в каждом втором примере.

context.Background() и context.TODO()

Два корневых контекста, которые нельзя отменить:

// Background — основной корневой контекст.
// Использовать в main(), тестах, верхнем уровне обработчиков запросов.
ctx := context.Background()

// TODO — явный сигнал: "здесь должен быть контекст, но я ещё не определился".
// Используется при рефакторинге кода или когда вызов будет подключён к цепочке позже.
ctx := context.TODO()

Оба контекста идентичны по поведению — разница только семантическая. Background() говорит "я знаю, что делаю". TODO() говорит "это временно, нужно доработать". Линтеры умеют ругаться на неуместный TODO() в production-коде.

Вызов методов на Background():

ctx := context.Background()

deadline, ok := ctx.Deadline() // time.Time{}, false
done := ctx.Done()             // nil
err := ctx.Err()               // nil
val := ctx.Value("key")        // nil

Done() возвращает nil — это значит, что Background никогда не будет отменён. Слушать nil-канал безопасно: <-nil блокируется навсегда, поэтому select с таким case просто не выберет его никогда.

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

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

context.Background()
├── WithCancel(Background)          ← можно отменить вручную
│   ├── WithTimeout(parent, 5s)     ← истекает через 5 секунд
│   │   └── WithValue(parent, k, v) ← несёт данные
│   └── WithCancel(parent)          ← независимая ветка
└── WithTimeout(Background, 30s)    ← другая ветка запроса

Посмотрим на propagation в коде:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    parent, cancelParent := context.WithCancel(context.Background())
    child, cancelChild := context.WithCancel(parent)
    grandchild, cancelGrandchild := context.WithCancel(child)

    // Освобождаем ресурсы — хорошая практика
    defer cancelChild()
    defer cancelGrandchild()

    // Отменяем родителя
    cancelParent()

    // Проверяем, что propagation сработал
    fmt.Println("parent cancelled:", parent.Err())      // context.Canceled
    fmt.Println("child cancelled:", child.Err())         // context.Canceled
    fmt.Println("grandchild cancelled:", grandchild.Err()) // context.Canceled
}

Propagation происходит синхронно внутри cancelParent(). К моменту, когда функция вернётся, все дочерние контексты уже получат отмену и их Done() каналы будут закрыты.

Важно: дочерний контекст с более ранним дедлайном не ждёт родителя. Если у родителя таймаут 30 секунд, а у ребёнка — 5 секунд, ребёнок истечёт через 5 секунд независимо от того, жив ли родитель.

3. Отмена — context.WithCancel

WithCancel создаёт дочерний контекст и возвращает функцию cancel, которую нужно вызвать, когда операция завершена — успешно или с ошибкой:

ctx, cancel := context.WithCancel(parent)
defer cancel() // ВСЕГДА делайте это сразу после создания

defer cancel() — не просто хорошая практика, это обязательное правило. Без него рантайм Go удерживает в памяти все горутины, которые слушают ctx.Done(), плюс внутренние структуры контекста. Это классическая утечка ресурсов.

Практический пример — горутина-воркер с отменой:

func doWork(ctx context.Context, jobs <-chan int) error {
    for {
        select {
        case <-ctx.Done():
            // Контекст отменён — выходим с причиной
            return ctx.Err()
        case job, ok := <-jobs:
            if !ok {
                // Канал закрыт — работа завершена
                return nil
            }
            if err := processJob(ctx, job); err != nil {
                return err
            }
        }
    }
}

select без default блокируется до появления события в одном из каналов. Если ctx.Done() закрыт — этот case выбирается немедленно. Если в jobs появился элемент — обрабатываем его. Никакого busy-waiting, никакого опроса.

А вот как отменить воркер снаружи:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    jobs := make(chan int, 10)

    go func() {
        defer cancel() // Отмена при завершении горутины
        // ... наполняем jobs ...
        close(jobs)
    }()

    if err := doWork(ctx, jobs); err != nil {
        log.Printf("work stopped: %v", err)
    }
}

HTTP-запрос с отменой

Реальный сценарий: пользователь закрыл соединение — нужно отменить все downstream-запросы:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // r.Context() уже несёт отмену из net/http:
    // если клиент отключился — контекст отменяется автоматически
    ctx := r.Context()

    result, err := fetchData(ctx, "https://api.example.com/data")
    if err != nil {
        if ctx.Err() != nil {
            // Клиент ушёл — не пишем ошибку
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

func fetchData(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err // Включает context.Canceled если ctx отменён
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

http.NewRequestWithContext — правильный способ передать контекст в HTTP-запрос. Если контекст отменяется в процессе, http.DefaultClient.Do вернёт ошибку с ctx.Err() внутри.

4. Дедлайны и таймауты — WithDeadline и WithTimeout

Два метода, которые отличаются только способом задания времени:

// WithDeadline — абсолютное время окончания
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

// WithTimeout — относительная длительность (удобнее в большинстве случаев)
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

WithTimeout — это просто синтаксический сахар: внутри него WithDeadline(parent, time.Now().Add(timeout)). Используйте WithTimeout когда вам важна длительность, WithDeadline — когда есть конкретный дедлайн (например, из конфигурации или внешнего запроса).

Когда дедлайн истекает, ctx.Err() возвращает context.DeadlineExceeded:

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

time.Sleep(200 * time.Millisecond) // Ждём дольше таймаута

fmt.Println(ctx.Err()) // context deadline exceeded

HTTP-клиент с таймаутом

func callAPI(endpoint string, payload any) (*Response, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    data, _ := json.Marshal(payload)
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
    if err != nil {
        return nil, fmt.Errorf("create request: %w", err)
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        // Проверяем, истёк ли именно таймаут
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("API timeout after 10s: %w", ctx.Err())
        }
        return nil, fmt.Errorf("HTTP request: %w", err)
    }
    defer resp.Body.Close()
    // ...
}

Вложенные таймауты

Важная деталь: дочерний контекст не может пережить родительский. Если у родителя осталось 2 секунды, а дочернему дали 10 — реально будет 2:

func processWithSubtasks(parent context.Context) error {
    // Общий таймаут на всю операцию — 30 секунд
    ctx, cancel := context.WithTimeout(parent, 30*time.Second)
    defer cancel()

    // Таймаут на первый этап — не более 10 секунд
    step1Ctx, cancelStep1 := context.WithTimeout(ctx, 10*time.Second)
    defer cancelStep1()

    if err := runStep1(step1Ctx); err != nil {
        return err
    }

    // Если parent истёк раньше — step2Ctx уже будет отменён
    step2Ctx, cancelStep2 := context.WithTimeout(ctx, 15*time.Second)
    defer cancelStep2()

    return runStep2(step2Ctx)
}

Реальный дедлайн step2Ctx — минимум из ctx (30s от начала) и 15s от момента создания. Рантайм Go выбирает меньший автоматически.

Go-вопросы в Telegram

Ежедневные разборы горутин, каналов и паттернов.

Подписаться

5. WithValue — хранение данных в контексте

context.WithValue позволяет прикрепить к контексту пару ключ-значение, которая будет доступна всем функциям ниже по стеку вызовов:

ctx = context.WithValue(parent, key, value)
val := ctx.Value(key) // возвращает any (interface{})

Значения передаются вниз по дереву, но не вверх. Дочерний контекст видит все значения родителей. Родитель не видит значений дочерних.

Правильные ключи — unexported тип

Ключевая ошибка — использовать строку или int в качестве ключа:

// ПЛОХО: строка как ключ — высокий риск конфликта с другим пакетом
ctx = context.WithValue(ctx, "userID", 42)

Если два разных пакета используют ключ "userID" — они будут перетирать друг друга. Правильный способ — unexported тип из вашего пакета:

// Объявляем тип только внутри пакета
type contextKey string

const (
    userIDKey    contextKey = "userID"
    requestIDKey contextKey = "requestID"
)

// Вспомогательные функции — хорошая практика
func WithUserID(ctx context.Context, userID int64) context.Context {
    return context.WithValue(ctx, userIDKey, userID)
}

func UserIDFromContext(ctx context.Context) (int64, bool) {
    id, ok := ctx.Value(userIDKey).(int64)
    return id, ok
}

Теперь ключ userIDKey уникален для вашего пакета — другой пакет физически не может использовать тот же ключ, потому что тип contextKey unexported.

Middleware + Handler паттерн

Типичный сценарий: middleware добавляет данные в контекст, handler их читает:

// Middleware: извлекает токен, проверяет JWT, добавляет userID в контекст
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        // Добавляем userID в контекст запроса
        ctx := WithUserID(r.Context(), userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Handler: читает userID из контекста
func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
    userID, ok := UserIDFromContext(r.Context())
    if !ok {
        http.Error(w, "missing user ID", http.StatusInternalServerError)
        return
    }

    profile, err := db.GetProfile(r.Context(), userID)
    // ...
}

Обрати внимание: r.WithContext(ctx) создаёт новый *http.Request с обновлённым контекстом. Оригинальный запрос не мутируется.

Что НЕ нужно хранить в контексте

Context — не универсальное хранилище. Хранить там стоит только то, что действительно request-scoped:

  • Request ID, Trace ID, Span ID
  • Авторизованный пользователь (после проверки JWT)
  • Язык/локаль из заголовков запроса

Не стоит хранить:

  • Конфигурацию приложения — передавай через параметры или dependency injection
  • Репозитории, логгеры, DB-подключения — это зависимости, не данные запроса
  • Опциональные параметры функций — создавай struct с опциями

6. Как правильно передавать контекст

Два принципа, которые звучат просто, но нарушаются регулярно:

Первый аргумент, всегда называть ctx:

// Правильно
func GetUser(ctx context.Context, id int64) (*User, error)
func SendEmail(ctx context.Context, email, subject, body string) error

// Неправильно — ctx не первый аргумент
func GetUser(id int64, ctx context.Context) (*User, error)

Это соглашение прописано в официальном style guide Go. Все стандартные библиотеки его соблюдают. Нарушение создаёт неконсистентность и затрудняет поиск ctx в сигнатурах.

Не хранить контекст в struct:

// Плохо — контекст в поле структуры
type UserService struct {
    ctx context.Context // АНТИПАТТЕРН
    db  *sql.DB
}

func (s *UserService) GetUser(id int64) (*User, error) {
    return s.db.QueryRowContext(s.ctx, query, id).Scan(...)
}

// Хорошо — контекст передаётся в каждый метод
type UserService struct {
    db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
    return s.db.QueryRowContext(ctx, query, id).Scan(...)
}

Проблема с контекстом в struct: struct может жить долго (весь запрос, несколько запросов), тогда как контекст привязан к конкретной операции. Если метод вызывается в контексте разных запросов — у них будут разные дедлайны, разные данные. Хранение контекста в struct ломает эту изоляцию.

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

Передача контекста в горутины

Горутина захватывает контекст из замыкания:

func processItems(ctx context.Context, items []Item) error {
    errCh := make(chan error, len(items))

    for _, item := range items {
        item := item // захватываем переменную цикла
        go func() {
            // ctx захватывается из внешнего scope
            if err := processItem(ctx, item); err != nil {
                errCh <- err
                return
            }
            errCh <- nil
        }()
    }

    for range items {
        if err := <-errCh; err != nil {
            return err
        }
    }
    return nil
}

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

7. Обработка отмены в горутинах

Базовый паттерн — select с ctx.Done():

func worker(ctx context.Context, input <-chan Task) {
    for {
        select {
        case <-ctx.Done():
            log.Println("worker shutting down:", ctx.Err())
            return
        case task, ok := <-input:
            if !ok {
                log.Println("input channel closed")
                return
            }
            processTask(ctx, task)
        }
    }
}

Блокирующие операции

Когда вызов сам принимает контекст — просто передаём его:

func queryDB(ctx context.Context, db *sql.DB, userID int64) (*User, error) {
    var user User
    // QueryRowContext уважает контекст — вернёт ошибку при отмене
    err := db.QueryRowContext(ctx, `SELECT * FROM users WHERE id = $1`, userID).Scan(
        &user.ID, &user.Name, &user.Email,
    )
    if err != nil {
        return nil, err
    }
    return &user, nil
}

Стандартные библиотеки (database/sql, net/http, google.golang.org/grpc) умеют реагировать на отмену контекста. Передавайте ctx напрямую — не нужно оборачивать вручную.

Graceful shutdown сервера

func startWorkerPool(ctx context.Context, concurrency int, jobs <-chan Job) {
    var wg sync.WaitGroup

    for i := 0; i < concurrency; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            log.Printf("worker %d started", workerID)

            for {
                select {
                case <-ctx.Done():
                    log.Printf("worker %d stopping: %v", workerID, ctx.Err())
                    return
                case job, ok := <-jobs:
                    if !ok {
                        log.Printf("worker %d: jobs channel closed", workerID)
                        return
                    }
                    if err := job.Execute(ctx); err != nil {
                        log.Printf("worker %d job error: %v", workerID, err)
                    }
                }
            }
        }(i)
    }

    // Ждём завершения всех воркеров
    wg.Wait()
    log.Println("all workers stopped")
}

При отмене контекста (например, SIGTERM → cancel()) каждый воркер выйдет из своего select через ctx.Done(). wg.Wait() дождётся всех — это и есть graceful shutdown.

Проверка перед дорогой операцией

Если операция сама не принимает контекст, можно проверить его состояние вручную:

func processHeavyTask(ctx context.Context, data []byte) error {
    // Проверяем до начала дорогой операции
    if err := ctx.Err(); err != nil {
        return fmt.Errorf("context already cancelled: %w", err)
    }

    // Тяжёлая CPU-операция (не принимает ctx)
    result := heavyCompute(data)

    // Проверяем снова перед записью результата
    if err := ctx.Err(); err != nil {
        return fmt.Errorf("cancelled before write: %w", err)
    }

    return saveResult(ctx, result)
}

В цикле стоит проверять ctx.Err() на каждой итерации:

func processBatch(ctx context.Context, items []Item) error {
    for i, item := range items {
        // Проверяем контекст в начале каждой итерации
        select {
        case <-ctx.Done():
            return fmt.Errorf("cancelled at item %d: %w", i, ctx.Err())
        default:
        }

        if err := processItem(item); err != nil {
            return err
        }
    }
    return nil
}

select с default — неблокирующая проверка: если ctx.Done() не закрыт — сразу идём в default и продолжаем работу.

8. Best practices

Правила, которые хочется напечатать и повесить над монитором:

defer cancel() сразу после создания. Это первое, что нужно написать после context.WithCancel, WithTimeout, WithDeadline. Без этого рантайм Go не сможет освободить ресурсы, привязанные к контексту:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // ← сразу после WithTimeout, до любого другого кода

Не передавать nil контекст. Если нет нормального контекста — используйте context.TODO(). Передача nil вызовет панику в первом же вызове метода на контексте:

// Плохо
doSomething(nil) // паника при ctx.Done()

// Хорошо
doSomething(context.TODO())

context.WithValue — только для request-scoped данных. Ключ — unexported тип, значение — данные запроса (tracing, auth). Не используйте для конфигурации или зависимостей.

Контекст immutable — не нужен mutex. Каждый WithValue, WithCancel, WithTimeout создаёт новый контекст, не модифицируя родителя. Контексты безопасны для конкурентного использования из множества горутин.

Проверяй ctx.Err() перед дорогими операциями. Это предотвращает лишнюю работу, если контекст уже отменён.

Передавай ctx в каждую функцию, которая делает I/O. Если функция обращается к сети, БД, файловой системе — она должна принимать context.Context. Это даёт вызывающей стороне контроль над временем жизни операции.

9. Антипаттерны

Context в поле struct. Обсудили выше — ломает изоляцию, скрывает время жизни контекста. Исключения редки и должны быть явно обоснованы.

Игнорирование cancel(). Если вы вызываете WithCancel или WithTimeout без defer cancel() — это потенциальная утечка:

// Утечка: cancel никогда не вызывается при ошибке
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
result, err := doSomething(ctx)
if err != nil {
    return err // cancel() не вызван!
}
cancel()
// Правильно: defer гарантирует вызов в любом случае
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
result, err := doSomething(ctx)

Строки или int как ключи в WithValue. Приводит к конфликтам между пакетами:

// Плохо — любой пакет может использовать тот же строковый ключ
ctx = context.WithValue(ctx, "token", token)

// Хорошо — типизированный unexported ключ
type contextKey int
const tokenKey contextKey = 1
ctx = context.WithValue(ctx, tokenKey, token)

Хранить в контексте опциональные параметры функции. Это антипаттерн функционального дизайна: скрытые зависимости, сложно тестировать:

// Плохо — опции через контекст
func CreateUser(ctx context.Context, user User) error {
    sendEmail, _ := ctx.Value("sendEmail").(bool)
    // ...
}

// Хорошо — явные параметры
type CreateUserOptions struct {
    SendWelcomeEmail bool
}
func CreateUser(ctx context.Context, user User, opts CreateUserOptions) error {
    // ...
}

Не проверять ctx.Done() в длинном цикле. Если цикл обрабатывает тысячи элементов, а внутри нет проверки контекста — отмена не дойдёт до горутины вовремя. Добавляй неблокирующую проверку через select { case <-ctx.Done(): ... default: }.

Создавать новый Background() вместо передачи ctx. Это обрывает цепочку отмены:

// Плохо — создаём свежий контекст, теряя propagation
func handleRequest(ctx context.Context) {
    // Если внешний ctx отменится — этот запрос продолжит работу
    go fetchData(context.Background()) // ← обрыв цепочки
}

// Хорошо
func handleRequest(ctx context.Context) {
    go fetchData(ctx) // ← отмена распространится
}

10. Что точно спросят на собеседовании

Расскажи об интерфейсе context.Context. Какие у него методы?

Четыре метода: Deadline() возвращает время истечения и флаг его наличия; Done() возвращает канал, который закрывается при отмене; Err() возвращает причину отмены (Canceled или DeadlineExceeded); Value() возвращает данные по ключу. Контексты immutable — каждый With* создаёт новый.

Чем WithTimeout отличается от WithDeadline?

WithTimeout принимает time.Duration — относительный интервал. WithDeadline принимает time.Time — абсолютный момент. Внутри WithTimeout(ctx, d) реализован как WithDeadline(ctx, time.Now().Add(d)). Семантически они эквивалентны, разница в удобстве: когда важна длительность — WithTimeout, когда конкретный момент — WithDeadline.

Почему важно всегда вызывать cancel()?

Функция cancel() освобождает ресурсы рантайма: внутренние структуры, которые связывают дочерний контекст с родительским. Без вызова cancel() все горутины, слушающие ctx.Done(), и внутренние структуры останутся в памяти до отмены родительского контекста. Это утечка. defer cancel() — защита от забывчивости.

Как работает propagation отмены?

При создании дочернего контекста рантайм регистрирует его в родителе. Когда родитель отменяется, рантайм обходит всех зарегистрированных детей и закрывает их Done() каналы. Это каскадная отмена: отмена одного узла в дереве распространяется на всё поддерево вниз. Вверх — не распространяется.

Что вернёт ctx.Err() после таймаута и после ручной отмены?

После cancel()context.Canceled. После истечения дедлайна или таймаута — context.DeadlineExceeded. Это два sentinel errors в пакете context, можно проверять через errors.Is.

Как безопасно хранить данные в контексте?

Нужен unexported тип для ключей — это предотвращает конфликты между пакетами. Лучшая практика: создать пакет с typedKey, WithXxx(ctx, value) context.Context и XxxFromContext(ctx context.Context) (T, bool). Так получается чистый API без прямого доступа к ctx.Value снаружи.

В горутине нужно слушать и результаты работы, и отмену контекста. Как?

Через select с двумя case:

select {
case result := <-resultCh:
    // обрабатываем результат
case <-ctx.Done():
    // контекст отменён
    return ctx.Err()
}

select выбирает первый готовый case. Если оба готовы одновременно — выбор случаен, это нормально для данного паттерна.

Итоги

context.Context — небольшой интерфейс с огромной ответственностью. Он скрепляет всю конкурентную модель Go: позволяет передавать сигнал отмены и дедлайн через цепочки функций и горутин, не нарушая инкапсуляцию.

Ключевые принципы, которые стоит запомнить:

Всегда вызывать cancel() — через defer сразу после создания. Контекст — первый аргумент, всегда называется ctx. Не хранить в struct. WithValue — только для request-scoped данных с типизированными ключами. В горутинах слушать ctx.Done() через select. Проверять ctx.Err() перед дорогими операциями. Не создавать context.Background() там, где должен идти существующий ctx.

Эти принципы превращают хаотичную конкурентность в управляемую систему. Именно это Go и предлагает: не просто инструменты, а архитектурные соглашения, которые делают конкурентный код читаемым и предсказуемым.

Если хочешь углубиться в смежные темы — загляни в статьи о горутинах и планировщике GMP, буферизованных и небуферизованных каналах, а также 50 вопросов junior–middle Go-разработчика.

Тренируй Go-интервью в реальном времени

Lexicon проводит голосовые mock-интервью по Go: горутины, контексты, каналы, ошибки. Получи развёрнутый фидбэк после каждой сессии.

Попробовать бесплатно

Автор

Lexicon Team

Читайте также