Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
17.06.2026
Обновлено:
09.07.2026

Пакет context в Go: как управлять таймаутами и отменой операций

Данил Мануйлов

Вы пишете сервис на Go, делаете запрос к базе, к внешнему API - и вдруг клиент закрыл соединение. Как остановить работу, которая уже запущена? Ответ - context. Это главный инструмент Go для управления временем жизни операций, и без него не обходится ни один production-сервис.

Что такое context в Go

Зачем он нужен

Представьте курьерскую службу. Клиент оформил заказ - курьер поехал, склад начал сборку, колл-центр открыл тикет. Если клиент отменил заказ, все три процесса должны это узнать и остановиться. context в Go работает так же: он передаёт сигнал отмены по всей цепочке вызовов - через функции, горутины, границы API.

Пакет context появился как x/net/context в 2014 году, а в Go 1.7 (2016) его включили в стандартную библиотеку. Сейчас context.Context - первый аргумент у большинства функций в любом Go-сервисе.

// Так выглядит типичная подпись функции с контекстом
func fetchUser(ctx context.Context, userID int) (*User, error) {
    // ...
}

Четыре метода интерфейса

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

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
Метод Что делает
Deadline() Возвращает момент времени, когда контекст будет автоматически отменён. ok=false - дедлайн не установлен
Done() Возвращает канал, который закрывается при отмене. У неотменяемых контекстов возвращает nil
Err() Возвращает причину отмены: nil (активен), context.Canceled или context.DeadlineExceeded
Value(key) Возвращает значение по ключу, рекурсивно поднимаясь по дереву контекстов

Важная деталь: все четыре метода потокобезопасны - их можно вызывать одновременно из нескольких горутин.

Background и TODO: в чём разница

ctx := context.Background() // для продакшн-кода
ctx := context.TODO()       // временная заглушка

Background() возвращает корневой контекст - он никогда не отменяется, не имеет дедлайна и не хранит значений. Создавайте его в main() и передавайте дальше по цепочке.

TODO() - семантически то же самое, но сигнализирует команде: «здесь нужно разобраться с контекстом позже». В IDE и статических анализаторах это маркер незавершённого кода.

Отмена операций: WithCancel, WithTimeout, WithDeadline

Это главная причина, по которой context вообще придумали. Три функции - три способа сказать программе «стоп».

WithCancel: ручная отмена

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

defer cancel() // ВСЕГДА defer cancel()

go doWork(ctx)

// ... через какое-то время
cancel() // сигнал горутине остановиться

WithCancel возвращает дочерний контекст и функцию cancel(). Вызвали cancel() - канал ctx.Done() закрылся, все горутины, слушающие его, получили сигнал.

Правило: вызывайте cancel() всегда на том уровне, где контекст создан. Передавать cancel в другую функцию - антипаттерн: это ведёт к неочевидным утечкам и трудноуловимым багам.

cancel() безопасно вызывать несколько раз: первый вызов отменяет контекст, последующие игнорируются.

WithTimeout vs WithDeadline

// WithTimeout: относительное время
ctx, cancel := context.WithTimeout(parent, 3*time.Second)

defer cancel()
// WithDeadline: абсолютный момент
deadline := time.Now().Add(3 * time.Second)

ctx, cancel := context.WithDeadline(parent, deadline)

defer cancel()

Разница одна: WithTimeout принимает time.Duration (сколько ждать), WithDeadline принимает time.Time (до какого момента). Внутри оба создают один и тот же timerCtx. На практике WithTimeout удобнее - не нужно вычислять time.Now().Add(...) вручную.

Авторская ремарка: я чаще всего вижу у новичков одну и ту же ошибку - они создают WithTimeout, но забывают defer cancel(). Контекст сам отменится по таймауту, но канал и связанные ресурсы не освободятся раньше. Горутины висят, память растёт.

Как проверить отмену в горутине

func doWork(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			// контекст отменён - выходим
			fmt.Println("stopped:", ctx.Err())
			return

		default:
			// выполняем полезную работу
			processItem()
		}
	}
}

ctx.Err() возвращает:

  • nil - контекст ещё активен
  • context.Canceled - вызвали cancel() вручную
  • context.DeadlineExceeded - истёк таймаут или дедлайн

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

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

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil
	if err != nil {
		return nil, err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err // context.DeadlineExceeded если таймау
    }
	defer resp.Body.Close()
	return io.ReadAll(resp.Body)
}

Если сервер не ответил за 5 секунд - запрос прерывается автоматически. Никаких висящих горутин.

Передача данных: context.WithValue

WithValue - самая спорная часть пакета. Используйте её только там, где данные пересекают границы API и существуют на протяжении всего запроса.

Что хранить в контексте

Подходит:

  • Request ID / trace ID для логирования и трассировки
  • Логгер с полями запроса
  • Токен аутентификации, ID пользователя

Не подходит:

  • Опциональные параметры функций (передавайте явно)
  • Бизнес-данные (заказ, товар и т.п.)
  • Конфигурация

Документация Go прямо говорит:

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

Правило неэкспортируемого ключа

// Объявляем неэкспортируемый тип ключа - внутри пакета
type ctxKey int

const requestIDKey ctxKey = iota

// Кладём значени
func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey, id)
}

// Достаём значение
func RequestIDFromContext(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey).(string)
	return id, ok
}

Неэкспортируемый тип ключа исключает коллизии: даже если два пакета используют одинаковое строковое значение, их типы разные. Это стандартный паттерн в Go-экосистеме.

Иерархия контекстов и распространение отмены

Как работает дерево контекстов

Контексты образуют дерево. Background() - корень. Каждый WithCancel/WithTimeout/WithValue создаёт дочерний узел.

Background
    └── WithCancel (ctx1, cancel1)
            ├── WithTimeout (ctx2, cancel2) - для БД-запроса
            └── WithValue (ctx3) - с request ID
                    └── WithTimeout (ctx4, cancel4) - для внешнего API

Правило распространения: отмена родителя отменяет всех потомков. Отмена потомка родителя не затрагивает. Это значит: если отменить ctx1, автоматически отменятся ctx2, ctx3, ctx4. Но если истечёт таймаут ctx2, ctx1 и ctx3 продолжат работать.

Внутри это реализует метод propagateCancel: он подписывает дочерний контекст на сигнал родительского. Когда родитель закрывает свой Done-канал, propagateCancel обходит всех потомков и вызывает их cancel().

Антипаттерны, на которых спотыкаются новички

1. Контекст в структуре

// ❌ НЕПРАВИЛЬНО
type Service struct {
	ctx context.Context
}

// ✅ ПРАВИЛЬН
func (s *Service) Process(ctx context.Context) error {
	// ...
}

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

2. cancel в чужих руках

// ❌ АНТИПАТТЕРН: передаём cancel куда попало
func process(ctx context.Context, cancel context.CancelFunc) {
    // Кто и когда вызовет cancel - непонятно
}

// ✅ Создали - сами отменили
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
process(ctx)

3. Игнорирование ctx.Done() в циклах

// ❌ Бесконечный цикл, не слышит отмену
for _, item := range items {
    process(item)
}

// ✅ Проверяем отмену на каждой итерации
for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    process(item)
}

Реальные примеры и разбор кода

HTTP-обработчик с таймаутом и отменой

func handler(w http.ResponseWriter, r *http.Request) {
	// Контекст запроса уже есть - создаём с таймаутом
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	result, err := queryDB(ctx)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			http.Error(w, "timeout", http.StatusGatewayTimeout)
			return
		}
		http.Error(w, "error", http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(result)
}

r.Context() уже содержит контекст входящего HTTP-запроса. Он отменяется автоматически, когда клиент разрывает соединение - даже если обработчик ещё работает.

Запрос к базе данных

func getUserByID(ctx context.Context, db *sql.DB, id int) (*User, error) {
	row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)

	var u User
	if err := row.Scan(&u.ID, &u.Name); err != nil {
		return nil, err
	}
	return &u, nil
}

Пакет database/sql полностью поддерживает контекст. Если контекст отменился - запрос прервётся, соединение вернётся в пул.

Graceful shutdown

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	go func() {
		sigCh := make(chan os.Signal, 1)
		signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
		<-sigCh
		cancel() // сигнал всем - пора останавливаться
	}()

	server := NewServer()
	server.Run(ctx) // сервер слушает ctx.Done() и завершается корректно
}

Авторская ремарка: этот паттерн - мой стандарт для любого нового сервиса. После cancel() у вас есть время на time.Sleep или WaitGroup, чтобы дождаться завершения горутин перед выходом.

Продвинутые сценарии

Пакет context предоставляет несколько функций для более тонкого управления временем жизни операций. Они появились в версиях Go 1.20 и 1.21 и прочно вошли в повседневную практику разработки.

WithCancelCause и context.Cause: отмена с указанием причины

Начиная с Go 1.20, можно передать произвольную ошибку при отмене контекста:

ctx, cancel := context.WithCancelCause(parent)

// Где-то в горутине:
cancel(fmt.Errorf("user %d not found", userID))

// Где-то снаружи:
fmt.Println(context.Cause(ctx)) // user 42 not found

До этого ctx.Err() возвращал только context.Canceled без деталей. Теперь context.Cause(ctx) даёт полную причину. Полезно для retry-логики: ошибка говорит, стоит ли повторять попытку.

WithoutCancel: контекст, не зависящий от отмены родителя

Функция, доступная начиная с Go 1.21:

// ctx отменён, но cleanupCtx - нет
cleanupCtx := context.WithoutCancel(ctx)
go cleanup(cleanupCtx) // выполнится даже после отмены родителя

Нужен для операций, которые должны завершиться независимо от состояния родительского контекста: rollback транзакций, запись в audit log, очистка ресурсов. Канал Done() у такого контекста равен nil - чтение из него заблокирует программу навсегда.

AfterFunc: действие при отмене контекста

Функция, доступная начиная с Go 1.21:

stop := context.AfterFunc(ctx, func() {
    // Выполнится в отдельной горутине при отмене ctx
    conn.Close()
})

// Если хотим отменить регистрацию:
if stop() {
    // функция была остановлена до выполнения
}

До Go 1.21 приходилось вручную запускать горутину, которая ждала ctx.Done(). AfterFunc делает это элегантно и потокобезопасно через sync.Once.

Альтернативное мнение

Не все разделяют энтузиазм по поводу context.WithValue. Ряд Go-разработчиков считает передачу данных через контекст антипаттерном в любом виде: она скрывает зависимости, усложняет тестирование и нарушает явность - один из принципов Go. Их позиция: если данные нужны функции, они должны быть в её сигнатуре. Компромисс: используйте WithValue только для инфраструктурных данных (trace ID, логгер), но никогда - для бизнес-логики.

Нетривиальный факт

Внутри пакета context есть глобальная переменная closedchan - заранее закрытый канал, создаваемый при инициализации пакета. Он используется при отмене контекста, если метод Done() ещё ни разу не вызывался: вместо создания нового канала в done atomic.Value записывается ссылка на уже закрытый closedchan. Это экономит одну аллокацию на каждый отменённый контекст, который никто не слушал.

FAQ

Что такое context в Go?

Context - интерфейс стандартного пакета context. Он переносит сигнал отмены, таймаут и данные запроса через цепочку функций и горутин без изменения их сигнатур.

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

WithTimeout принимает time.Duration - отсчёт от текущего момента. WithDeadline принимает time.Time - абсолютный момент истечения. Внутри оба создают timerCtx; WithTimeout удобнее на практике.

Зачем нужен defer cancel()?

cancel() освобождает ресурсы контекста - каналы и связанные горутины. Без defer cancel() они не освободятся, пока не сработает таймаут. Это классический источник утечек.

Можно ли хранить context в структуре?

Нет. Официальная документация Go явно это запрещает. Передавайте ctx первым аргументом каждой функции - это делает код предсказуемым и легко тестируемым.

Что вернёт ctx.Done() для context.Background()?

nil. Корневые контексты неотменяемы. Чтение из nil-канала заблокирует программу навсегда - используйте select с default или создавайте отменяемый дочерний контекст.

Что такое context.Canceled и context.DeadlineExceeded?

Это ошибки, которые возвращает ctx.Err() после отмены. context.Canceled - явный вызов cancel(). context.DeadlineExceeded - истёк таймаут или дедлайн.

Какие расширенные возможности управления отменой есть в пакете context?

Пакет context позволяет не только отменять операции, но и передавать причину отмены (WithCancelCause/Cause), создавать контексты, не зависящие от отмены родителя (WithoutCancel), а также регистрировать функции, которые вызываются при отмене (AfterFunc). Эти возможности доступны начиная с Go 1.20 и 1.21.

Источики

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

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