Go context.Context: правильное использование отмены, таймаутов и значений
Контекст в Go — это управление потоком выполнения, а не хранилище.
Использование context.Context в Go достаточно простое, чтобы его можно было легко испортить — и это главная проблема.
Большинство разработчиков на Go быстро осваивают поверхностные правила: передавать контекст как первый аргумент, проверять ctx.Done(), использовать context.WithTimeout и никогда не передавать nil.
func DoSomething(ctx context.Context) error {
// ...
}
Эти правила полезны, но они охватывают только простую часть. В продакшн-сервисах контекст — это не просто конвенция параметров, это плоскость управления жизненным циклом запроса.

Контекст сообщает работе, когда нужно остановиться, сколько времени осталось, какой путь отмены был выбран и какие значения, привязанные к запросу, должны пересечь границы API. При правильном использовании он предотвращает утечки горутин, избегает бесполезной работы, распространяет дедлайны и упрощает остановку сервисов. При неправильном использовании он превращается в мешок скрытых зависимостей, фальшивых глобальных переменных, забытых таймаутов, утекающих таймеров и запутанного поведения при отмене.
Слегка субъективная версия этого утверждения звучит так: используйте контекст для отмены, дедлайнов и метаданных, привязанных к запросу, и не используйте его как контейнер для зависимостей.
Для чего нужен контекст
У пакета context есть три основные задачи — отмена, дедлайны и таймауты, а также значения, привязанные к запросу, — и эти три задачи покрывают всё, для чего он предназначен.
Контекст должен отвечать на такие вопросы:
Был ли этот запрос отменен?
Сколько времени осталось на эту операцию?
Какой ID запроса должен быть прикреплен к логам?
Какой аутентифицированный пользователь ассоциирован с этим запросом?
Контекст не должен отвечать на такие вопросы:
Где мое соединение с базой данных?
Где мой логгер?
Где моя конфигурация?
Какую реализацию сервиса мне использовать?
Это зависимости — передавайте их явно через параметры функций (см. Инъекция зависимостей в Go для паттернов чистого решения этой задачи). Контекст предназначен для жизненного цикла запроса и метаданных запроса, а не для сборки приложения.
Базовая структура контекста
Базовый интерфейс небольшой:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Важные части:
Done()закрывается, когда контекст отменяется или истекает его дедлайн.Err()объясняет, почему контекст закончился.Deadline()сообщает, есть ли у контекста дедлайн.Value()хранит данные, привязанные к запросу.
Большая часть кода не реализует этот интерфейс. Он получает контекст и передает его дальше.
Первое правило: передавайте контекст явно
Для функций, выполняющих работу, привязанную к запросу или отменяемую, передавайте контекст как первый параметр — это стандартная конвенция Go, которую ожидает каждая библиотека и инструмент в экосистеме:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Делайте это для функций, которые могут:
- Вызывать базу данных
- Вызывать другой сервис
- Ждать очереди
- Запускать фоновую работу
- Блокироваться на вводе/выводе
- Использовать таймаут
- Требовать значений, привязанных к запросу
- Требовать отмены
Не добавляйте контекст в крошечные чистые функции, которым он не нужен.
Это нормально:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Не каждой функции нужен контекст. Добавление контекста везде делает код зашумленным.
Не храните контекст в структурах
Хранение контекста в структуре — одна из самых распространенных ошибок в кодовых базах Go, и её стоит выделить явно. Не делайте так:
type UserService struct {
ctx context.Context
db *sql.DB
}
Делайте так:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Контекст принадлежит запросу, операции или задаче, тогда как структура сервиса обычно живет гораздо дольше любого отдельного запроса. Смешивание этих жизненных циклов делает отмену неясной и затрудняет понимание того, какой операции принадлежит контекст.
Существуют редкие исключения для типов, которые действительно представляют жизненный цикл одной операции, но они настолько редки, что правило по умолчанию должно быть простым:
Передавайте контекст. Не храните его.
Не передавайте nil-контекст
Никогда не передавайте nil в качестве контекста.
Плохо:
err := svc.DoWork(nil)
Используйте context.Background(), когда нет существующего контекста:
err := svc.DoWork(context.Background())
В тестах используйте тестовый контекст, когда это возможно:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Nil-контекст может вызвать панику, когда код вызывает методы на нем. Фоновый контекст явный и безопасный.
Фоновые, TODO и запросные контексты
Существует три распространенных начальных точки.
context.Background
Используйте context.Background() на верхнем уровне программы, когда родительский контекст не существует — это корневой контекст, от которого производятся все дочерние контексты:
func main() {
ctx := context.Background()
_ = run(ctx)
}
или:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Используйте context.TODO(), когда вы знаете, что контекст должен использоваться, но еще не решили, какой именно.
ctx := context.TODO()
Это полезно во время миграции, но не должно становиться постоянным, если реальный контекст существует.
Контекст запроса
В HTTP-серверах используйте контекст запроса:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
Контекст запроса отменяется, когда клиентское соединение закрывается, запрос отменяется или сервер завершает обработку запроса.
Для веб-сервисов это обычно контекст, который следует передавать в прикладной код.
Отмена с помощью context.WithCancel
Используйте context.WithCancel, когда хотите явно остановить работу.
ctx, cancel := context.WithCancel(parent)
defer cancel()
Возвращаемая функция cancel отменяет дочерний контекст и освобождает связанные с ним ресурсы. Всегда вызывайте её, когда закончите — даже если контекст в конечном итоге истечет по таймауту, ранний вызов отмены избегает удержания ресурсов дольше необходимого.
Пример:
func RunWorker(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
done := make(chan error, 1)
go func() {
done <- doBackgroundWork(ctx)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
Паттерн прост:
- Произведите дочерний контекст.
- Отложите отмену.
- Передайте дочерний контекст в работу, которая должна остановиться вместе.
- Следите за
ctx.Done().
Таймауты с context.WithTimeout
Используйте context.WithTimeout, когда операция имеет максимальную длительность.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Пример с HTTP-клиентом:
func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Это делает таймаут частью операции, а не скрытой глобальной настройкой.
Всегда вызывайте cancel
Когда вы вызываете WithCancel, WithTimeout или WithDeadline, всегда вызывайте возвращаемую функцию отмены — это важно для корректности.
Хорошо:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Плохо:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Невызов отмены может удерживать таймеры и дочерние контексты живыми дольше, чем необходимо.
Дедлайны против таймаутов
Таймаут относительный:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Дедлайн абсолютный:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
Большинство прикладного кода использует таймауты. Дедлайны полезны, когда запрос имеет фиксированное время окончания, которое должно быть общим для нескольких операций — например, если у запроса осталось 900 миллисекунд, не давайте каждому downstream-вызову новый таймаут в 1 секунду; вместо этого распространите оставшийся бюджет.
Бюджеты таймаутов через слои сервисов
Распространенная ошибка — слепое наложение таймаутов.
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_ = service.DoWork(ctx)
}
func (s *Service) DoWork(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.repo.Query(ctx)
}
Это выглядит безобидно, но скрывает реальный бюджет. Слой сервиса обычно должен уважать дедлайн вызывающего, а не сбрасывать таймер до того же значения.
Более удачный паттерн:
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := service.DoWork(ctx); err != nil {
// обработка ошибки
return
}
}
Затем внутри сервиса:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Добавляйте дочерний таймаут только тогда, когда субоперации нужен меньший бюджет:
func (s *Service) DoWork(ctx context.Context) error {
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.repo.Query(queryCtx)
}
Правильная ментальная модель проста: весь запрос имеет один внешний бюджет, конкретные субоперации могут иметь меньшие бюджеты, выделенные из этого бюджета, и ни один слой не должен незаметно расширять запрос за пределы того, что намеревался вызывающий.
Проверка ctx.Err() для различения отмены и таймаута
Когда контекст заканчивается, ctx.Err() возвращает причину.
Обычно это одна из:
context.Canceled
context.DeadlineExceeded
Пример:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
Это позволяет вызывающим различать отмену и таймаут, и это различие имеет значение на практике. Отмененный запрос часто означает, что клиент отключился, тогда как ошибка превышения дедлайна обычно означает, что ваш сервис был слишком медленным — их не следует всегда логировать, повторять или сообщать одинаковым образом.
Используйте context.Cause для лучших причин отмены
Современный Go также поддерживает отмену с учетом причины.
Полезные функции включают:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Обычный ctx.Err() сообщает вам общую причину: отменен или превышен дедлайн.
context.Cause(ctx) может сообщить вам более конкретную причину.
Пример:
var ErrShutdown = errors.New("server shutting down")
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
// Пришел сигнал завершения.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Используйте отмену с учетом причины, когда причина важна для вызывающих, логов или поведения очистки, и избегайте её там, где обычного ctx.Err() достаточно — дополнительные детали стоят того, только если диагностика действительно в них нуждается.
Пример HTTP-сервера
Обычный HTTP-обработчик должен начинаться с r.Context(). Для полного обзора структурирования сервисов Go HTTP см. Создание REST API в Go.
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.PathValue("id")
user, err := svc.GetUser(ctx, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Сервис должен принимать и распространять контекст:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Репозиторий должен использовать методы базы данных, осознающие контекст:
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
return nil, err
}
return &user, nil
}
Важно — цепочка: каждый слой передает один и тот же контекст следующему:
Не разрывайте цепочку, создавая context.Background() посередине.
Ошибка context.Background(): разрыв цепочки отмены
Это распространенная ошибка:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
Это отбрасывает всю информацию об отмене и дедлайнах от вызывающего. Если клиент отключается, запрос к базе данных продолжает выполняться. Если запрос истекает по таймауту, downstream-работа может все еще находиться в процессе выполнения. Если сервер завершает работу, этот код полностью игнорирует это. Замена полученного контекста на context.Background() внутри бизнес-логики почти всегда неверно.
Используйте контекст, который вам дали:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Используйте context.Background() только на границе, где нет родительского контекста.
Пример HTTP-клиента
Для исходящих HTTP-запросов прикрепляйте контекст к запросу.
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Не делайте так:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Это создает запрос без контекста операции.
Также избегайте полагаться только на http.Client.Timeout. Он может быть полезен как ограничитель безопасности, но контексты запросов дают вам лучшую передачу по цепочке вызовов.
Распространенный паттерн:
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Используйте это, когда вызов downstream-API имеет конкретный бюджет внутри более крупного запроса.
Пример базы данных
Большинство API баз данных Go имеют методы, осознающие контекст. Для более широкого взгляда на то, как библиотеки доступа к данным Go обрабатывают контекст — включая GORM, Ent, Bun и sqlc — см. Сравнение ORM Go для PostgreSQL.
Используйте их.
Хорошо:
rows, err := db.QueryContext(ctx, query, args...)
Хорошо:
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Хорошо:
result, err := db.ExecContext(ctx, query, args...)
Плохо:
rows, err := db.Query(query, args...)
Формы, осознающие контекст, позволяют операциям с базой данных останавливаться, когда запрос отменяется или истекает по таймауту, что особенно важно для медленных запросов, перегруженных баз данных и пользовательских API, где задержка напрямую влияет на пользовательский опыт.
Транзакции и контекст
Транзакции требуют тщательного обращения с контекстом.
Транзакция обычно должна начинаться с контекста операции:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Затем используйте тот же контекст для операций транзакции:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Будьте осторожны с таймаутами вокруг транзакций. Если контекст отменяется до Commit, транзакция может быть откатлена. Это может быть то, что вы хотите, но это должно быть осознанным.
Для длинных транзакций лучшим ответом обычно является не более долгий таймаут — а более короткая транзакция, выполняющая меньше работы за единицу времени.
Фоновые воркеры и контекст
Фоновые воркеры должны получать контекст, представляющий их жизненный цикл.
Пример:
type Worker struct {
logger *slog.Logger
}
func (w *Worker) Run(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := w.doOnce(ctx); err != nil {
w.logger.Error("worker iteration failed", "err", err)
}
}
}
}
Этот воркер останавливается чисто, когда контекст отменяется, и его тикер правильно очищается через defer ticker.Stop(). В main вы бы создали корневой контекст, привязанный к сигналам ОС:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
worker := &Worker{logger: slog.Default()}
if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("worker stopped", "err", err)
}
}
Это правильное использование контекста: он описывает жизненный цикл работы процесса, и когда ОС отправляет сигнал, всё дерево горутин, разделяющих этот контекст, остановится вместе.
Предотвращение утечек горутин с отменой контекста
Утечка горутины происходит, когда горутина остается заблокированной навсегда после того, как она больше не полезна.
Контекст помогает предотвратить это.
Плохо:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
У этой горутины нет пути остановки.
Лучше:
func StartWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}()
}
Любая горутина, которая зацикливается, почти всегда должна иметь путь отмены.
Это не означает, что каждая горутина должна напрямую получать контекст, но у системы должен быть четкий способ её остановки.
context.AfterFunc
context.AfterFunc запускает функцию после отмены контекста.
Он может быть полезен для очистки, разблокировки операций или мостирования API, которые не поддерживают контекст нативно.
Пример:
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Пробуждение или очистка, если необходимо.
})
defer stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
Используйте AfterFunc осторожно — он запускает логику, когда происходит отмена, что может затруднить отслеживание потока управления. Для большинства прикладных кодов обычный select на ctx.Done() яснее и проще для понимания. AfterFunc наиболее ценен, когда вам нужно адаптировать отмену контекста к API, который еще не принимает контекст.
context.WithoutCancel
context.WithoutCancel создает контекст, который не отменяется, когда родительский отменяется.
Это полезно, но его также легко использовать неправильно.
Пример использования:
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Обработка запроса...
_ = ctx
auditCtx := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
defer cancel()
_ = audit.Write(ctx, "request completed")
}()
}
}
Идея в том, что запись аудита может нуждаться в продолжении работы недолго даже после отмены контекста запроса. Это должно быть редким и осознанным — не используйте WithoutCancel как способ избежать обработки отмены. Используйте его только тогда, когда дочерняя работа действительно должна пережить отмену родителя, и всегда добавляйте новый таймаут: контекст, который игнорирует отмену, но не несет дедлайна, может легко создать утечки фоновых горутин.
Правильное использование значений контекста
Значения контекста предназначены для данных, привязанных к запросу, которые пересекают границы API.
Хорошие примеры:
- ID запроса
- ID трассировки
- ID аутентифицированного пользователя
- ID арендатора
- Локаль
- Принципал безопасности
- Метаданные корреляции
Плохие примеры:
- Соединение с базой данных
- Логгер как скрытая зависимость
- Флаги функций для обычного потока управления
- Опциональные параметры функций
- Конфигурация
- Клиенты сервисов
Полезное правило: если значение является частью идентичности запроса или контекста наблюдаемости, оно может принадлежать контексту. Если это зависимость, которая нужна вашему коду для выполнения работы, передавайте её явно.
Используйте типизированные ключи для значений контекста
Не используйте обычные строки в качестве ключей контекста.
Плохо:
ctx = context.WithValue(ctx, "userID", "123")
Это может столкнуться с другими пакетами.
Используйте неэкспортируемый пользовательский тип ключа:
type userIDKey struct{}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey{}, userID)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey{}).(string)
return userID, ok
}
Этот паттерн дает вам типобезопасность на границе пакета, избегает столкновений ключей с другими пакетами и сохраняет поверхность API контекста чистой с типизированными функциями доступа.
Не используйте значения контекста для опциональных параметров
Это плохо:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Это скрывает контракт функции.
Предпочтите явные параметры:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Значения контекста не должны заменять аргументы функций. Скрытый вход делает код сложнее для понимания, тестирования и ревью — и любой, читающий сигнатуру функции, не будет иметь ни малейшего представления о том, что этот параметр вообще существует.
Логирование и контекст
Существует два распространенных подхода к логированию с контекстом. Примеры здесь используют пакет Go log/slog — для более глубокого погружения в структурированное логирование с slog в продакшн-сервисах см. Структурированное логирование в Go с slog.
Подход 1: Извлечение значений и прикрепление их к логам
func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
if requestID, ok := RequestIDFromContext(ctx); ok {
logger = logger.With("request_id", requestID)
}
logger.Info(msg)
}
Это сохраняет логгер явным как правильную зависимость и использует контекст только для значений, привязанных к запросу, которые легитимно должны пересекать границы API.
Подход 2: Хранение логгера в контексте
Некоторые кодовые базы хранят логгер в контексте.
Это может быть удобно, но я не рекомендую это как значение по умолчанию. Это превращает контекст в контейнер зависимостей.
Мое предпочтение:
- Передавайте зависимости логгера явно.
- Храните ID трассировки и ID запроса в контексте.
- Добавляйте эти значения в логи на границах или в middleware.
Это сохраняет зависимости видимыми.
Контекст и трассировка
Трассировка — один из сильнейших случаев использования для значений контекста, и это действительно хорошее соответствие. OpenTelemetry и подобные системы используют контекст для распространения spans трассировки через вызовы функций и границы процессов, потому что данные трассировки — это именно тот вид метаданных, привязанных к запросу, для которых контекст был разработан.
Типичный паттерн выглядит так:
func (s *Service) DoWork(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "Service.DoWork")
defer span.End()
return s.repo.Query(ctx)
}
Контекст несет активный span трассировки, и репозиторий может создать дочерний span из него. Каждый слой добавляет свой собственный span без явной передачи объектов трассировщика — контекст делает эту работу прозрачно по всему дереву вызовов.
Обработка ошибок с контекстом
Когда операция останавливается из-за отмены контекста, сохраняйте эту информацию. Паттерны здесь дополняют более широкие стратегии дизайна ошибок, описанные в Архитектуре обработки ошибок Go.
Пример:
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Клиент отменил или вызывающий остановил работу.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Таймаут.
return err
}
return err
}
Не оборачивайте ошибки контекста слепо так, чтобы скрывать их.
Оборачивание с %w сохраняет errors.Is, так что вызывающие все еще могут обнаруживать отмену или таймаут:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Полная замена ошибки отбрасывает эту информацию и ломает любого вызывающего, который проверяет конкретные типы ошибок контекста:
if err != nil {
return errors.New("query user failed")
}
Маппинг ошибок контекста на HTTP-ответы
Ошибки контекста часто маппятся на разные HTTP-результаты.
Пример:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// Клиент, вероятно, ушел.
// Некоторые системы логируют это как закрытый клиентом запрос.
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
Не относитесь к отмене клиента как к сбою приложения — если пользователь закрыл вкладку браузера, это не означает, что ваш сервис ведет себя неправильно, и логирование этого как ошибки добавляет шум без сигнала.
Контекст в middleware
HTTP middleware — распространенное место для добавления значений, привязанных к запросу.
Пример middleware ID запроса:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey{}, requestID)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey{}).(string)
return requestID, ok
}
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
ctx := WithRequestID(r.Context(), requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Это хорошее использование контекста. ID запроса принадлежит запросу, он должен пройти через всю цепочку вызовов, и прикрепление его к логам и трассам на каждом слое — это именно тот вид сквозных проблем наблюдаемости, который значения контекста предназначены для поддержки.
Контекст в тестах
В тестах избегайте слепого использования context.Background().
Предпочтите t.Context(), когда работа принадлежит жизненному циклу теста:
func TestService(t *testing.T) {
ctx := t.Context()
err := service.DoWork(ctx)
if err != nil {
t.Fatal(err)
}
}
Для поведения таймаутов тестируйте с реальным таймаутом только если таймаут мал и значим.
Для конкурентного и зависящего от времени кода рассмотрите использование testing/synctest — Тестирование конкурентного кода Go с synctest охватывает этот инструмент в глубину:
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
time.Sleep(30 * time.Second)
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("got %v, want deadline exceeded", ctx.Err())
}
})
}
Это позволяет вам тестировать реальные значения таймаутов без ожидания реального времени.
Контекст и errgroup
Для групп горутин, которые должны отменяться вместе, errgroup часто является хорошим выбором.
Пример:
func FetchAll(ctx context.Context, ids []string, client *Client) error {
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
id := id
g.Go(func() error {
_, err := client.Fetch(ctx, id)
return err
})
}
return g.Wait()
}
Если одна горутина возвращает ошибку, контекст группы отменяется, и другие горутины, которые уважают ctx.Done(), могут остановиться раньше. Это гораздо чище, чем ручное управление несколькими горутин, каналами и путями отмены. Ключевая фраза здесь — “уважать контекст” — errgroup не может остановить работу, которая игнорирует ctx.Done().
Graceful shutdown
Контекст централен для graceful shutdown.
Типичная настройка сервера имеет:
- корневой контекст, отменяемый сигналами ОС
- HTTP-сервер
- фоновые воркеры
- таймаут завершения
- логику очистки
Пример:
func main() {
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{
Addr: ":8080",
Handler: routes(),
}
go func() {
<-root.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown failed", "err", err)
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
Обратите внимание, что контекст завершения не такой же, как корневой контекст — корневой уже отменен, когда приходит сигнал ОС. Отдельный контекст с таймаутом дает процессу завершения ограниченное количество времени для дренирования запросов в полете перед принудительным выходом, что является тонким, но важным различием, которое делает graceful shutdown действительно работающим.
Общие антипаттерны
Антипаттерн 1: Использование контекста как контейнера зависимостей
Плохо:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Передавайте зависимости явно.
Антипаттерн 2: Создание context.Background внутри бизнес-логики
Плохо:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Это разрывает передачу отмены.
Антипаттерн 3: Забывание cancel
Плохо:
ctx, _ := context.WithTimeout(parent, time.Second)
Хорошо:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Антипаттерн 4: Помещение опциональных параметров в контекст
Плохо:
ctx = context.WithValue(ctx, "includeDeleted", true)
Используйте явные структуры опций.
Антипаттерн 5: Передача контекста слишком глубоко в чистый код
Плохо:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Чистые вычисления не нуждаются в контексте, если они не длительны или не отменяемы.
Антипаттерн 6: Игнорирование отмены в циклах
Плохо:
for item := range items {
process(item)
}
Лучше:
for item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
Антипаттерн 7: Поглощение ошибок контекста
Плохо:
if err != nil {
return errors.New("operation failed")
}
Хорошо:
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Сохраняйте ошибки отмены и дедлайна.
Практический чек-лист контекста
Используйте этот чек-лист для бэкенд-кода Go.
Сигнатуры функций
- Контекст — первый параметр.
- Контекст не хранится в долгоживущих структурах.
- Контекст не передается в чистые вспомогательные функции, если это не необходимо.
- Nil-контекст никогда не используется.
Отмена
- Длительные циклы проверяют
ctx.Done(). - Горутины имеют путь остановки.
- Жизненные циклы воркеров привязаны к родительскому контексту.
- Отмена контекста распространяется на downstream-вызовы.
Таймауты
- Внешние таймауты запроса устанавливаются на границе.
- Таймауты субопераций меньше внешнего бюджета.
- Функции отмены всегда вызываются.
- Таймауты не накладываются слепо на каждом слое.
Значения
- Значения контекста привязаны к запросу.
- Ключи используют пользовательские типы, а не обычные строки.
- Зависимости не хранятся в контексте.
- Опциональные параметры не хранятся в контексте.
Ошибки
context.Canceledиcontext.DeadlineExceededсохраняются.- Ошибки контекста правильно маппятся на границах API.
- Отмена с учетом причины используется только тогда, когда причина важна.
Тесты
- Тесты используют
t.Context()где уместно. - Тесты таймаутов избегают медленных реальных снов.
- Конкурентное поведение таймаутов тестируется с
testing/synctest, когда это полезно. - Утечки горутин проверяются путем обеспечения существования путей остановки.
Как аудировать использование контекста в кодовой базе Go
Ищите эти паттерны:
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
Затем спросите:
- Используется ли
context.Background()только на границах верхнего уровня? - Всегда ли вызываются функции отмены?
- Размещены ли таймауты на разумных границах?
- Действительно ли значения контекста привязаны к запросу?
- Скрыты ли зависимости в значениях контекста?
- Можно ли остановить горутины?
- Сохраняются ли ошибки контекста?
Это хорошая привычка ревью кода, потому что многие ошибки контекста — это не синтаксические ошибки — это ошибки жизненного цикла, которые проявляются только при отмене, нагрузке или условиях завершения.
Мои субъективные правила
Эти правила скучны, но они работают.
Правило 1: Контекст — это поток управления
Используйте контекст для управления отменой, дедлайнами и метаданными запроса.
Не используйте его для контрабанды зависимостей.
Правило 2: Вызывающий владеет бюджетом
Функция обычно должна уважать контекст, который она получает.
Создавайте более короткий дочерний таймаут только тогда, когда субоперации нужен конкретный меньший бюджет.
Правило 3: Background принадлежит на краю
Используйте context.Background() в main, тестах и настройке верхнего уровня.
Не используйте его внутри методов сервисов и репозиториев для ухода от отмены.
Правило 4: Значения должны быть скучными
ID запроса, ID трассировки, ID пользователя и ID арендатора принадлежат контексту. Соединения с базой данных, логгеры, структуры конфигурации и клиенты сервисов не принадлежат — это зависимости, и их следует передавать явно.
Правило 5: Каждой горутине нужен жизненный цикл
Если горутина запускается, вы должны точно знать, как она останавливается. Контекст часто является правильным ответом, и если это не контекст, должен быть какой-то другой четкий механизм — канал, примитив синхронизации или явный сигнал.
Финальные мысли
context.Context не сложен потому, что API велик — API мал. Он сложен потому, что представляет жизненный цикл, а жизненный цикл — это архитектура. Каждое решение о том, где контекст течет, где он производится и где он останавливается, является решением о том, как ваш сервис обрабатывает сбои, нагрузку и завершение работы.
Хорошо используемый контекст делает сервисы Go легче отменять, легче останавливать, легче наблюдать и менее склонными к утечкам горутин. Плохо используемый контекст скрывает зависимости, отбрасывает дедлайны и делает код сложнее для понимания под давлением.
Практический вывод прост:
Передавайте контекст вниз.
Не храните его.
Не заменяйте явные параметры значениями.
Уважайте отмену.
Используйте таймауты на границах.
Всегда вызывайте cancel.
Это Go context, сделанный правильно.
Эта статья является частью кластера Архитектура приложений в продакшене, который охватывает структуру кода, доступ к данным, паттерны интеграции и архитектуру тестирования для продакшн-систем Go и Python.