Модуль 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 решает три задачи:
- Отмена операций (cancel propagation)
- Таймауты (deadlines)
- Передача request-scoped данных (request ID, trace ID, auth token)
Интерфейс
Context - встроенный интерфейс с четырьмя методами:
gotype 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
- При инициализации приложения
- В тестах
- На верхнем уровне запроса
gofunc 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
Создаёт контекст который автоматически отменяется через указанное время.
goctx, 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
Создаёт контекст который отменяется в указанное абсолютное время.
godeadline := time.Now().Add(5 * time.Second) ctx, cancel := context.WithDeadline(context.Background(), deadline) defer cancel()
Разница между WithTimeout и WithDeadline
| Функция | Параметр | Использование |
|---|---|---|
| WithTimeout | duration (относительное) | "операция максимум 5 секунд" |
| WithDeadline | time.Time (абсолютное) | "завершить до 12:00" |
WithTimeout internally вызывает WithDeadline. Используйте WithTimeout для большинства случаев.
Проверка отмены по таймауту
goresult, err := doWork(ctx) if err != nil { if errors.Is(err, context.DeadlineExceeded) { // таймаут } if errors.Is(err, context.Canceled) { // отменено } return err }
Пример: HTTP-клиент с таймаутом
gofunc 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 создаёт контекст который можно отменить вручную.
goctx, 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)
Паттерн: отмена по условию
gofunc 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 }
Паттерн: отмена при сигнале ОС
gofunc 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 для:
- Бизнес-данных
- Конфигурации
- Обязательных параметров функции
Создание и извлечение
gotype 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
-
Используйте кастомные типы для ключей (избегайте коллизий)
-
Ключи неэкспортируемые (lowercase)
-
Значения immutable
-
Не храните секреты в context
-
Документируйте какие ключи использует ваш пакет
// ПЛОХО: 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
gofunc 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 через всю цепочку
gofunc (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
gofunc 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 запроса отменяется. Используйте это для отмены долгих операций:
gofunc 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:
gofunc 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) сервер должен:
- Перестать принимать новые запросы
- Дождаться завершения текущих запросов
- Закрыть соединения с БД
- Освободить ресурсы
- Завершиться чисто
Реализация с signal.NotifyContext
gofunc 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
gofunc 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()
goctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() // предотвращает утечку ресурсов
Без defer cancel() ресурсы context не освободятся до истечения таймаута или отмены родительского контекста.
2. Не сохраняйте context в структурах
Context - request-scoped. Храните его в параметрах функций, не в полях структур.
3. Context первым аргументом
gofunc DoWork(ctx context.Context, param string) error
4. Проверяйте ctx.Err() в долгих циклах
gofor _, item := range items { if ctx.Err() != nil { return ctx.Err() } process(item) }
5. Используйте select с ctx.Done() в горутинах
gogo func() { for { select { case <-ctx.Done(): return case job := <-jobsCh: process(job) } } }()
6. Не передавайте nil context
Используйте context.Background() или context.TODO().
7. WithValue только для request-scoped данных
Не используйте для бизнес-данных или конфигурации.
Привязка к шаблону проекта
Где context в шаблоне
textcmd/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-запросов
Что изучаем на примере шаблона
- signal.NotifyContext для graceful shutdown
- srv.Shutdown с таймаутом
- Propagation context через handler -> usecase -> service -> repository
- Использование ctx в SQL-запросах (pgx)
- Request ID middleware через WithValue
Задание: изучите cmd/app/commands/serve.go
- Как создаётся контекст с отменой по сигналу?
- Какой таймаут для graceful shutdown?
- Как закрываются ресурсы приложения?
- Что происходит если shutdown не успевает?
💱 Компромиссы
WithTimeoutvsWithDeadline: duration проще для «операция максимум 5 секунд», но единый дедлайн цепочки вызовов придётся пересчитывать самому на каждом уровне; абсолютныйtime.Timeфиксирует общий дедлайн, но для одиночной операции менее удобен.WithValuevs явный параметр:WithValueпроносит request ID сквозь чужие сигнатуры без их изменения, но зависимость становится невидимой и не типобезопасной; явный параметр виден в сигнатуре и проверяется компилятором, но раздувает параметры каждого слоя.- Долгий drain vs короткий shutdown-таймаут: большой таймаут
srv.Shutdownдаёт активным запросам доделать работу, но рестарты и деплои затягиваются; короткий быстро освобождает под, но обрывает запросы на середине. - Частая vs редкая проверка
ctx.Done(): проверка в каждом витке цикла мгновенно глушит воркер по отмене, но замусоривает код; редкие проверки читаются чище, но горутина продолжает жечь ресурсы уже после отмены. context.TODO()vscontext.Background():TODOчестно помечает долг — контекст ещё не проброшен — и легко ищется по коду, но легко оседает навсегда;Backgroundсразу корректный корень, но маскирует незавершённый рефакторинг.
Практическое задание
Задание 1: Операция с таймаутом
Реализуйте функцию которая выполняет операцию с таймаутом:
gofunc 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: Отмена воркера
Реализуйте воркер который останавливается при отмене контекста:
gofunc 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:
gotype 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:
gofunc 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:
gofunc (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
Реализуйте параллельную загрузку с отменой при первой ошибке:
gofunc 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 "Проверь себя"
✅ Итоги модуля
Студент должен:
- Понимать назначение 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/ шаблона.