Вы пишете сервис на 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.
Источики
pkg.go.dev/context - официальная документация пакета context. https://pkg.go.dev/context
Habr / Positive Technologies - «Пакет context в Go: взгляд профессионала», Константин Соколов, Сергей Мачульскис. https://habr.com/ru/companies/pt/articles/764850/
Habr - «Golang: context изнутри». https://habr.com/ru/articles/827544/
github.com/golang/go - исходный код context.go. https://github.com/golang/go/blob/master/src/context/context.go
backendinterview.ru - «Context - Backend interview». https://backendinterview.ru/goLang/concurrency/context.html
josephwoodward.co.uk - «Providing context to cancellations in Go 1.20 with WithCancelCause». https://josephwoodward.co.uk/2023/01/context-cancellation-cause-with-cancel-cause
ru.stackoverflow.com - «Объясните смысл Context в Go». https://ru.stackoverflow.com/questions/710097/
free5gc.org - «Go context in free5GC» . https://free5gc.org/blog/20250521/20250521/



.svg.webp)





