Модуль 7. Context и отмена операций

Цели модуля

После модуля студент сможет:

  • Понимать назначение context.Context и его роль в Go
  • Создавать контексты с таймаутами, дедлайнами и отменой
  • Передавать context через цепочку вызовов
  • Использовать context для отмены долгих операций
  • Передавать request-scoped данные через WithValue
  • Реализовывать graceful shutdown HTTP-сервера
  • Понимать best practices работы с context

7.1 Что такое context.Context

Назначение

context.Context - механизм для передачи дедлайнов, сигналов отмены и request-scoped значений через границы API и между горутинами.

Context решает три задачи:

  1. Отмена операций (cancel propagation)
  2. Таймауты (deadlines)
  3. Передача request-scoped данных (request ID, trace ID, auth token)

Интерфейс

Context - встроенный интерфейс с четырьмя методами:

go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}
МетодНазначение
Deadline()Возвращает дедлайн операции
Done()Канал, закрывается при отмене
Err()Причина отмены (Canceled или DeadlineExceeded)
Value(key)Извлекает значение по ключу

Корневой контекст

go
// context.Background() - корневой контекст
// Используется в main, инициализации, тестах
ctx := context.Background()

// context.TODO() - когда контекст ещё не определён
// Используется как placeholder при рефакторинге
ctx := context.TODO()

Оба никогда не отменяются и не имеют дедлайна.


7.2 Создание контекстов

context.Background()

Корневой контекст, никогда не отменяется. Используется:

  • В функции main
  • При инициализации приложения
  • В тестах
  • На верхнем уровне запроса
go
func main() {
    ctx := context.Background()
    if err := run(ctx); err != nil {
        log.Fatal(err)
    }
}

context.TODO()

Placeholder когда контекст ещё не определён. Используется:

  • При рефакторинге (функция пока не принимает ctx)

  • Когда неясно какой контекст использовать

    // Временный вариант при рефакторинге func processData(data []byte) error { ctx := context.TODO() // позже заменим на параметр return process(ctx, data) }


7.3 WithTimeout и WithDeadline

context.WithTimeout

Создаёт контекст который автоматически отменяется через указанное время.

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()  // ВСЕГДА вызывайте defer cancel()

result, err := longOperation(ctx)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        fmt.Println("operation timed out")
    }
    return err
}

context.WithDeadline

Создаёт контекст который отменяется в указанное абсолютное время.

go
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

Разница между WithTimeout и WithDeadline

ФункцияПараметрИспользование
WithTimeoutduration (относительное)"операция максимум 5 секунд"
WithDeadlinetime.Time (абсолютное)"завершить до 12:00"

WithTimeout internally вызывает WithDeadline. Используйте WithTimeout для большинства случаев.

Проверка отмены по таймауту

go
result, err := doWork(ctx)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        // таймаут
    }
    if errors.Is(err, context.Canceled) {
        // отменено
    }
    return err
}

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

go
func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, fmt.Errorf("create request: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, fmt.Errorf("request timeout: %w", err)
        }
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

7.4 WithCancel

Базовое использование

WithCancel создаёт контекст который можно отменить вручную.

go
ctx, cancel := context.WithCancel(context.Background())

go func() {
    // долгая операция
    for {
        select {
        case <-ctx.Done():
            fmt.Println("cancelled:", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("working...")
        }
    }
}()

time.Sleep(3 * time.Second)
cancel()  // отменяем операцию
time.Sleep(time.Second)

Паттерн: отмена по условию

go
func processItems(ctx context.Context, items []Item) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            if err := process(ctx, item); err != nil {
                cancel()  // отменяем при ошибке
                return fmt.Errorf("process item %d: %w", item.ID, err)
            }
        }
    }
    return nil
}

Паттерн: отмена при сигнале ОС

go
func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    if err := run(ctx); err != nil {
        log.Fatal(err)
    }
}

func run(ctx context.Context) error {
    // при получении SIGINT/SIGTERM ctx отменится
    <-ctx.Done()
    fmt.Println("shutting down...")
    return nil
}

7.5 WithValue

Назначение

WithValue передаёт request-scoped данные через context. Используется для:

  • Request ID
  • Trace ID
  • Auth token (осторожно!)
  • User ID

НЕ используйте WithValue для:

  • Бизнес-данных
  • Конфигурации
  • Обязательных параметров функции

Создание и извлечение

go
type contextKey string

const requestIDKey contextKey = "request_id"

// Сохранение
ctx = context.WithValue(ctx, requestIDKey, "req-123")

// Извлечение
func getRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        return id
    }
    return ""
}

Best practices для WithValue

  1. Используйте кастомные типы для ключей (избегайте коллизий)

  2. Ключи неэкспортируемые (lowercase)

  3. Значения immutable

  4. Не храните секреты в context

  5. Документируйте какие ключи использует ваш пакет

    // ПЛОХО: string ключ может коллидировать ctx = context.WithValue(ctx, "request_id", "123")

    // ПРАВИЛЬНО: кастомный тип ключа type contextKey string const requestIDKey contextKey = "request_id" ctx = context.WithValue(ctx, requestIDKey, "123")

Пример: middleware с request ID

go
func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }

        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

7.6 Propagation context через вызовы

Правило: context первым аргументом

go
// ПРАВИЛЬНО
func GetRepo(ctx context.Context, id string) (*Repo, error)

// ПЛОХО
func GetRepo(id string, ctx context.Context) (*Repo, error)

Context всегда первый параметр функции. Это стандарт Go.

Правило: не сохраняйте context в структурах

go
// ПЛОХО
type Service struct {
    ctx context.Context  // не делайте так
}

// ПРАВИЛЬНО
type Service struct {
    repo repository.RepoRepository
}

func (s *Service) GetRepo(ctx context.Context, id string) (*Repo, error) {
    return s.repo.GetByID(ctx, id)
}

Правило: передавайте context через всю цепочку

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()  // context из запроса

    repo, err := h.usecase.Execute(ctx, id)  // передаём дальше
    if err != nil {
        h.handleError(w, err)
        return
    }
    // ...
}

func (uc *GetRepoUseCase) Execute(ctx context.Context, id string) (*Repo, error) {
    repo, err := uc.service.GetRepo(ctx, id)  // передаём дальше
    if err != nil {
        return nil, fmt.Errorf("get repo: %w", err)
    }
    return repo, nil
}

func (s *RepoService) GetRepo(ctx context.Context, id string) (*Repo, error) {
    repo, err := s.repo.GetByID(ctx, id)  // передаём в репозиторий
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, err)
    }
    return repo, nil
}

Правило: не передавайте nil context

go
// ПЛОХО
result, err := doSomething(nil, id)

// ПРАВИЛЬНО
result, err := doSomething(context.Background(), id)
// или
result, err := doSomething(context.TODO(), id)

7.7 Context и HTTP-серверы

r.Context()

Каждый HTTP-запрос имеет context. Он содержит:

  • Дедлайн запроса (если настроен)
  • Сигнал отмены при закрытии соединения клиентом
  • Значения из middleware
go
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

    // Проверяем отмену
    select {
    case <-ctx.Done():
        fmt.Println("request cancelled")
        return
    default:
    }

    // Используем ctx для операций
    result, err := doWork(ctx)
    // ...
}

Отмена при закрытии соединения

Когда клиент закрывает соединение, context запроса отменяется. Используйте это для отмены долгих операций:

go
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    result, err := longOperation(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // клиент закрыл соединение, не логируем как ошибку
            return
        }
        http.Error(w, "internal error", 500)
        return
    }

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

http.NewRequestWithContext

Для HTTP-клиентов всегда используйте NewRequestWithContext:

go
func fetchData(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

7.8 Graceful shutdown

Что такое graceful shutdown

При получении сигнала завершения (SIGINT, SIGTERM) сервер должен:

  1. Перестать принимать новые запросы
  2. Дождаться завершения текущих запросов
  3. Закрыть соединения с БД
  4. Освободить ресурсы
  5. Завершиться чисто

Реализация с signal.NotifyContext

go
func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{
        Addr:    ":8080",
        Handler: router,
    }

    // Запуск сервера в горутине
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    log.Println("server started on :8080")

    // Ждём сигнала
    <-ctx.Done()
    log.Println("shutdown signal received")

    // Graceful shutdown с таймаутом
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("forced shutdown: %v", err)
    }

    log.Println("server stopped gracefully")
}

srv.Shutdown()

http.Server.Shutdown:

  • Закрывает listener (не принимает новые соединения)
  • Ждёт завершения активных запросов
  • Закрывает idle соединения
  • Возвращает ошибку если контекст отменён до завершения

Пример из шаблона: cmd/app/commands/serve.go

go
func runServe(cmd *cobra.Command, args []string) error {
    // Контекст с отменой по сигналу
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // Загрузка конфигурации
    cfg, err := config.Load()
    if err != nil {
        return fmt.Errorf("load config: %w", err)
    }

    // Сборка зависимостей
    app, err := app.Build(ctx, cfg)
    if err != nil {
        return fmt.Errorf("build app: %w", err)
    }

    // Создание HTTP-сервера
    srv := &http.Server{
        Addr:         cfg.HTTPAddr,
        Handler:      app.Router,
        ReadTimeout:  cfg.ReadTimeout,
        WriteTimeout: cfg.WriteTimeout,
        IdleTimeout:  cfg.IdleTimeout,
    }

    // Запуск в горутине
    go func() {
        log.Printf("server starting on %s", cfg.HTTPAddr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    // Ждём сигнала завершения
    <-ctx.Done()
    log.Println("shutdown signal received, draining connections...")

    // Graceful shutdown с таймаутом
    shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("forced shutdown: %v", err)
        return srv.Close()
    }

    // Закрытие ресурсов приложения
    if err := app.Close(); err != nil {
        return fmt.Errorf("close app: %w", err)
    }

    log.Println("server stopped gracefully")
    return nil
}

Best practices работы с context

1. Всегда вызывайте defer cancel()

go
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()  // предотвращает утечку ресурсов

Без defer cancel() ресурсы context не освободятся до истечения таймаута или отмены родительского контекста.

2. Не сохраняйте context в структурах

Context - request-scoped. Храните его в параметрах функций, не в полях структур.

3. Context первым аргументом

go
func DoWork(ctx context.Context, param string) error

4. Проверяйте ctx.Err() в долгих циклах

go
for _, item := range items {
    if ctx.Err() != nil {
        return ctx.Err()
    }
    process(item)
}

5. Используйте select с ctx.Done() в горутинах

go
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        case job := <-jobsCh:
            process(job)
        }
    }
}()

6. Не передавайте nil context

Используйте context.Background() или context.TODO().

7. WithValue только для request-scoped данных

Не используйте для бизнес-данных или конфигурации.


Привязка к шаблону проекта

Где context в шаблоне

text
cmd/app/commands/serve.go      # Graceful shutdown
internal/handlers/http/        # r.Context() в хэндлерах
internal/usecase/              # ctx первым аргументом
internal/service/              # propagation через сервисы
internal/repository/           # ctx в репозиториях
internal/adapters/postgres/    # ctx для SQL-запросов

Что изучаем на примере шаблона

  1. signal.NotifyContext для graceful shutdown
  2. srv.Shutdown с таймаутом
  3. Propagation context через handler -> usecase -> service -> repository
  4. Использование ctx в SQL-запросах (pgx)
  5. Request ID middleware через WithValue

Задание: изучите cmd/app/commands/serve.go

  • Как создаётся контекст с отменой по сигналу?
  • Какой таймаут для graceful shutdown?
  • Как закрываются ресурсы приложения?
  • Что происходит если shutdown не успевает?

💱 Компромиссы

  • WithTimeout vs WithDeadline: duration проще для «операция максимум 5 секунд», но единый дедлайн цепочки вызовов придётся пересчитывать самому на каждом уровне; абсолютный time.Time фиксирует общий дедлайн, но для одиночной операции менее удобен.
  • WithValue vs явный параметр: WithValue проносит request ID сквозь чужие сигнатуры без их изменения, но зависимость становится невидимой и не типобезопасной; явный параметр виден в сигнатуре и проверяется компилятором, но раздувает параметры каждого слоя.
  • Долгий drain vs короткий shutdown-таймаут: большой таймаут srv.Shutdown даёт активным запросам доделать работу, но рестарты и деплои затягиваются; короткий быстро освобождает под, но обрывает запросы на середине.
  • Частая vs редкая проверка ctx.Done(): проверка в каждом витке цикла мгновенно глушит воркер по отмене, но замусоривает код; редкие проверки читаются чище, но горутина продолжает жечь ресурсы уже после отмены.
  • context.TODO() vs context.Background(): TODO честно помечает долг — контекст ещё не проброшен — и легко ищется по коду, но легко оседает навсегда; Background сразу корректный корень, но маскирует незавершённый рефакторинг.

Практическое задание

Задание 1: Операция с таймаутом

Реализуйте функцию которая выполняет операцию с таймаутом:

go
func fetchWithTimeout(ctx context.Context, url string, timeout time.Duration) ([]byte, error) {
    ctx, cancel := context.WithTimeout(ctx, timeout)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, fmt.Errorf("create request: %w", err)
    }

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetch: %w", err)
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Задание 2: Отмена воркера

Реализуйте воркер который останавливается при отмене контекста:

go
func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker stopped:", ctx.Err())
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            process(ctx, job)
        }
    }
}

Задание 3: Middleware с request ID

Реализуйте middleware который добавляет request ID в context:

go
type contextKey string
const requestIDKey contextKey = "request_id"

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }

        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func getRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        return id
    }
    return ""
}

Задание 4: Graceful shutdown

Реализуйте HTTP-сервер с graceful shutdown:

go
func runServer(ctx context.Context, addr string) error {
    srv := &http.Server{
        Addr:    addr,
        Handler: buildRouter(),
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    <-ctx.Done()

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    return srv.Shutdown(shutdownCtx)
}

func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    if err := runServer(ctx, ":8080"); err != nil {
        log.Fatal(err)
    }
}

Задание 5: Propagation через слои

Реализуйте цепочку handler -> usecase -> service -> repository с propagation context:

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    ctx := r.Context()

    repo, err := h.usecase.Execute(ctx, id)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            return  // клиент закрыл соединение
        }
        h.handleError(w, err)
        return
    }

    writeJSON(w, http.StatusOK, repo)
}

Задание 6: Параллельные операции с errgroup и context

Реализуйте параллельную загрузку с отменой при первой ошибке:

go
func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([]string, len(urls))

    for i, url := range urls {
        i, url := i, url
        g.Go(func() error {
            data, err := fetch(ctx, url)
            if err != nil {
                return fmt.Errorf("fetch %s: %w", url, err)
            }
            results[i] = data
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

Quiz "Проверь себя"

1. Чем WithTimeout отличается от WithDeadline?
2. Почему нельзя сохранять context в структурах?
3. Когда хранить данные в context оправдано?
4. Как context помогает при graceful shutdown?
5. Что умеет context.Context? — выберите все подходящие
6. Что делает signal.NotifyContext?
7. Что произойдёт при вызове cancel() дважды?
8. Что произойдёт, если не вызвать defer cancel() после WithTimeout?

✅ Итоги модуля

Студент должен:

  • Понимать назначение context.Context
  • Создавать контексты с таймаутами и отменой
  • Передавать context через цепочку вызовов
  • Использовать WithValue для request-scoped данных
  • Реализовывать graceful shutdown HTTP-сервера
  • Проверять отмену в долгих операциях
  • Применять best practices работы с context
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 8

Следующий модуль: Модуль 8. Создание Web API сервиса - net/http, http.ServeMux, хэндлеры и роуты, JSON encoding/decoding, health checks. Привязка к internal/handlers/http/api/v1/ шаблона.