context.Context переносит сигнал отмены, необязательный дедлайн и значения уровня запроса через границы API и горутин, чтобы всё дерево вызовов одного запроса можно было остановить разом.
Интерфейс: Deadline(), Done(), Err() и Value(key). Контекст неизменяем, и его безопасно делить между горутинами.
Корни: context.Background() и context.TODO(): пустые, никогда не отменяются.
Производные: WithCancel, WithTimeout и WithDeadline добавляют способ остановки; WithValue прикрепляет одну пару ключ/значение. Каждый возвращает нового потомка, родитель не меняется.
Новые дополнения: WithCancelCause (Go 1.20), затем WithoutCancel, AfterFunc, WithTimeoutCause и WithDeadlineCause (Go 1.21).
Типичные применения: остановить запросы к БД и исходящие HTTP-вызовы, когда клиент отключился, ограничить операцию таймаутом, пронести request id или trace span.
GoMiddle
Context и отмена
Дерево контекстов, таймауты, дедлайны, значения, распространение отмены
Закрыто вопросов
0 / 21
- 0Повторить
- 2Часто спрашивают
Вопросы
Во время выполнения ничем не отличаются: оба возвращают пустой контекст, который никогда не отменяется, не имеет дедлайна и не хранит значений. Разница в намерении: Background() служит осознанным корнем, а TODO() помечает место, куда позже должен прийти настоящий контекст.
Background() берут на вершине дерева вызовов: в main и в долгоживущем воркере, который сам владеет своим временем жизни. В тестах лучше t.Context() (Go 1.24+).
TODO() ставят, пока мигрируете код, который ещё не принимает ctx; это метка, которую потом найдут грепом.
Внутри функции, которая уже получила ctx, не нужен ни тот ни другой. Начиная оттуда с Background(), вы отрезаете вызов от дедлайна и отмены вызывающего; это обычная причина, по которой таймаут «не работает».
Она выведет <nil> context canceled, затем context canceled context canceled: отмена потомка никак не затрагивает родителя, а отмена родителя отменяет все контексты, производные от него.
Производные контексты образуют дерево, и отмена идёт только вниз: от узла ко всем его потомкам.
cancelChild() закрывает только child.Done(); parent.Err() остаётся nil.
cancelParent() сразу отменяет child2, хотя его собственный таймер на один час не сработал. Потомок получает причину родителя, поэтому его Err() возвращает context.Canceled, а не DeadlineExceeded.
То же правило ограничивает дедлайны: потомок никогда не живёт дольше родителя.
Именно поэтому контекст хорошо ложится на запросы: отмените контекст запроса, и остановятся все производные от него запросы к БД и исходящие вызовы, а отказ одного подвызова не завершает весь запрос.
Done() возвращает канал, который закрывается, когда контекст завершился, и его можно ждать в select; Err() говорит, почему он завершился: context.Canceled или context.DeadlineExceeded, а пока контекст жив, возвращает nil.
Канал закрывают, а не пишут в него, поэтому все ждущие на нём горутины просыпаются разом.
После закрытия Done() значение Err() гарантированно не nil и больше не меняется.
Побеждает первая причина: отмените контекст от WithTimeout до срабатывания таймера, и Err() останется context.Canceled.
У Background() и TODO() метод Done() возвращает nil-канал; чтение из него блокируется навсегда, и это правильно для контекста, который никогда не завершается.
Проверяйте через errors.Is(err, context.DeadlineExceeded), а не через ==: библиотеки обычно возвращают эти ошибки обёрнутыми.
cancel освобождает то, что держит производный контекст: снимает регистрацию потомка в родителе и останавливает его таймер. Без вызова всё это живёт, пока не сработает таймер или не завершится родитель, а у WithCancel под долгоживущим родителем это может быть вечно.
Пишите defer cancel() строкой сразу после создания контекста, даже если работа обычно заканчивается раньше. Отмена уже завершённого контекста сводится к дешёвому no-op, повторять её безопасно.
Забытый cancel под долгоживущим отменяемым родителем (например, общим контекстом сервера из signal.NotifyContext) приводит к медленной утечке памяти: по одному потомку на вызов остаётся в map children родителя. Под Background() ничего не регистрируется, но WithTimeout держит таймер, пока тот не сработает, а go vet ругается в любом случае.
go vet (проверка lostcancel) находит выброшенный cancel (ctx, _ := ...) и cancel, который вызывается не на всех путях.
Если контекст должен пережить функцию, верните cancel вызывающему: теперь он им владеет.
Контекст принадлежит одному вызову, а не объекту: первым параметром ctx он делает время жизни каждого вызова явным, а сохранённый в структуре привязывает все последующие вызовы к дедлайну, отмене и значениям того запроса, который эту структуру создал.
Структуру делят многие запросы. Сохранённый контекст обычно принадлежал запросу, который давно завершился, поэтому следующие вызовы падают с context.Canceled или пишут в лог чужие trace id и пользователя.
Run(ctx context.Context) error сообщает читателю, что работу можно отменить, а фиксированная позиция помогает ревьюерам и линтерам заметить пропущенный ctx.
Никогда не передавайте nil: context.WithCancel(nil) паникует, а http.NewRequestWithContext(nil, ...) возвращает ошибку. Если ничего лучше нет, передайте context.TODO().
Общепринятое исключение составляет структура, которая сама представляет один запрос, например *http.Request: контекст читают через r.Context(), а заменяют через r.WithContext(ctx).
Нет. Отмена лишь закрывает канал Done() и выставляет Err(); убить горутину снаружи в Go нельзя, поэтому она останавливается, только когда её собственный код замечает сигнал и выходит.
В каждой точке блокировки делайте select по ctx.Done() рядом с каналом или таймером, которого ждёте.
Передавайте ctx во все вызовы, которые его принимают: db.QueryContext, http.NewRequestWithContext, gRPC-стабы. Они следят за ним сами.
В CPU-bound цикле проверяйте ctx.Err() раз в N итераций или на каждый батч; проверка дешёвая.
Заметив отмену, верните ctx.Err() и выбросьте частичный результат.
Горутина, заблокированная в том, что не принимает контекст (time.Sleep, обычный conn.Read, отправка в канал вне select), не реагирует вообще. Так и начинаются утечки горутин.
WithCancel берут, когда момент остановки решает ваш собственный код, WithTimeout подходит, когда операции выделяют относительный бюджет («не больше 2 с»), а WithDeadline нужен, когда у вас уже есть абсолютный момент времени, общий для нескольких шагов.
**WithCancel: остановить остальных воркеров, когда один уже получил ответ; остановить продюсера, когда ушёл консьюмер; привязать фоновые циклы к завершению сервиса.
WithTimeout**: вариант по умолчанию для одного исходящего вызова или запроса к БД. Это буквально WithDeadline(parent, time.Now().Add(d)).
**WithDeadline**: дедлайн пришёл извне: бюджет вызывающего из заголовка, конец окна пакетной обработки, дедлайн родителя минус запас.
Все три возвращают cancel, который нужно вызвать, и ни один не может продлить родителя: дедлайн потомка позже родительского ничего не меняет.
signal.NotifyContext даёт повседневный четвёртый вариант: контекст, который отменяется по SIGINT или SIGTERM, используется как корень в main.
От родителя: у входящего запроса оставалось всего 300 мс, а производный контекст никогда не живёт дольше родителя; WithTimeout фактически оставляет более ранний из двух дедлайнов.
Если дедлайн родителя раньше, WithDeadline даже не заводит таймер, а ctx.Deadline() возвращает время родителя.
Значит, таймаут, который вы пишете, задаёт потолок для оставшегося бюджета, а не выделяет время. Именно поэтому один дедлайн, выставленный на входе, ограничивает всё дерево вызовов.
5 с всё равно оставьте: cron-задача или консьюмер очереди вызывают код вообще без дедлайна, и тогда ваш становится единственным ограничением.
Если time.Until(deadline) слишком мало, чтобы успеть, падайте сразу, а не занимайте соединение из пула под запрос, который не успеет завершиться.
ctx, cancel := context.WithTimeout(ctx, ...) перепривязывает ctx, поэтому запрос к базе получает стомиллисекундный контекст кэша, а ни один вызов не получит больше времени, чем есть у переданного ему контекста.
Каждая строка ниже этой наследует короткий дедлайн; это классическая ошибка, когда таймаут добавляют в уже существующую функцию.
context.WithTimeout(ctx, 2*time.Second) внутри db.Load не поможет: потомок не может пережить родителя.
Исправление: создать от контекста запроса два «брата» (cacheCtx и dbCtx), каждый со своим defer cancel(). Отмена одного не задевает другой.
Разные имена делают зону действия каждого бюджета видимой на ревью.
Она выведет bob, alice, <nil>: Value идёт вверх по цепочке и сравнивает ключи через ==, а ключи разных типов никогда не равны, даже если в них одинаковый текст.
Два пакета, которые оба используют обычную строку "user" как ключ, молча перезаписывают друг друга; побеждает значение, добавленное последним.
Объявите неэкспортируемый тип ключа, обычно type userKey struct{}. Ни один другой пакет не может создать значение этого типа, поэтому коллизия невозможна.
Экспортируйте аксессоры, а не ключ: WithUser(ctx, u) и UserFrom(ctx) (*User, bool). Тогда приведение типа живёт в одном месте.
Храните там только данные уровня запроса: request id, trace span, аутентифицированного пользователя. Необязательные параметры, *sql.DB или конфиг относятся к зависимостям; передавайте их аргументами или полями структуры, где их проверит компилятор.
staticcheck (SA1029) ругается на встроенные типы вроде string в роли ключей контекста.
Canceled значит, что вызывающий ушёл: остановитесь сразу и не считайте это сбоем; DeadlineExceeded значит, что работа шла слишком медленно (это ваша проблема латентности), и ретрай имеет смысл, только пока у общего контекста ещё есть время.
Перед каждым ретраем проверяйте контекст вызывающего: если ctx.Err() != nil, выходите; любая следующая попытка мгновенно упадёт.
Ждите между попытками через select по ctx.Done() и таймеру, а не через time.Sleep, который держит горутину припаркованной уже после ухода вызывающего.
Сработавший таймаут отдельной попытки можно ретраить, пока жив родительский контекст: этот DeadlineExceeded относится к попытке, а не к вызывающему.
На HTTP-границе отмену клиентом логируют как 499 (соглашение nginx) и не алертят; дедлайн логируют как 504, и он расходует ваш бюджет ошибок.
Классифицируйте через errors.Is: эти ошибки обычно приходят обёрнутыми.
defer cancel() выполняется, когда возвращается Run, а не в конце итерации; а Run возвращается только при остановке, поэтому каждая задача оставляет отложенный вызов, который держит её контекст и таймер, и куча растёт вместе с числом обработанных задач.
defer привязан к функции, а не к телу цикла. Каждая итерация добавляет ещё один отложенный вызов в список Run, и это замыкание не даёт собрать timerCtx задачи.
Позднее освобождение к тому же оставляет таймер каждой задачи запущенным, а потомка держит зарегистрированным в карте children родителя, пока не сработает его 30-секундный таймаут; cancel() убрал бы всё это сразу.
Исправление: вызвать cancel() сразу после возврата из handle или вынести тело в метод runJob(ctx, job), где defer cancel() снова уместен, потому что эта функция возвращается после каждой задачи.
go vet молчит: lostcancel проверяет только, что cancel вызывается на каждом пути, но не когда. deferInLoop из gocritic (экспериментальная, включается явно в golangci-lint) или правило defer с опцией loop в revive ловят такой код, а профиль кучи показывает удерживаемую память, выделенную в пакете context.
net/http отменяет контекст запроса, когда закрывается соединение клиента, когда сбрасывается HTTP/2-стрим или когда возвращается ServeHTTP, но останавливается только та работа, которой этот контекст передали.
Исходящие запросы создавайте через http.NewRequestWithContext(r.Context(), ...). Обычный http.NewRequest использует context.Background(), и вызов апстрима продолжается после ухода клиента.
Отмена прерывает установку соединения, ожидание заголовков ответа и текущий resp.Body.Read, поэтому io.Copy стримящегося тела сразу возвращается.
То же самое с db.QueryContext, gRPC-вызовами и вашими собственными циклами с select: без ctx нет и отмены.
В HTTP/1.1 сервер начинает следить за разрывом соединения только после того, как хендлер прочитал тело запроса целиком; если нужна быстрая отмена, читайте тело до долгой работы.
context.Canceled от апстрима трактуйте как «клиент ушёл»: без 502 и без алерта. defer resp.Body.Close() всё равно нужен.
Канал небуферизованный, поэтому, когда в select побеждает таймаут, никто так и не читает из канала, и рабочая горутина навсегда блокируется на ch <-: по одной утёкшей горутине, со стеком и скачанным телом ответа, на каждый вызов, упавший по таймауту.
Отправка в небуферизованный канал ждёт получателя. После того как fetch вернул ctx.Err(), единственного получателя больше нет, а сборщик мусора никогда не освобождает заблокированную горутину.
Исправление: make(chan result, 1). Единственная отправка всегда помещается в буфер, поэтому горутина доходит до конца и завершается, читает кто-то результат или нет. Подойдёт и отправка внутри select с веткой case <-ctx.Done():.
Буфер не останавливает сам download: без контекста он всё равно работает до конца. Если клиент принимает контекст, передайте ему ctx и уберите лишнюю горутину совсем.
Как поймать: профиль горутин: /debug/pprof/goroutine?debug=1 показывает стек с огромным счётчиком, заканчивающийся на runtime.chansend1, а debug=2 помечает такие горутины [chan send]; в тестах goleak.VerifyNone(t) из go.uber.org/goleak падает, если вызов по таймауту оставил после себя горутину.
context.WithoutCancel(ctx) (Go 1.21) возвращает контекст, который сохраняет значения родителя, но никогда не отменяется и не имеет дедлайна; он нужен для работы, которая должна пережить запрос и при этом логироваться с его trace id и тенантом.
Типичный случай: запись в аудит, прогрев кэша или уведомление, запущенные из хендлера. На r.Context() такая работа отменится в момент возврата хендлера и будет падать случайным образом, причём чаще, когда хендлер возвращается быстро.
Дедлайн тоже пропадает, поэтому добавьте своё ограничение: context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second).
Отслеживайте отвязанную работу через sync.WaitGroup, чтобы при остановке её можно было дождаться.
Не используйте его, чтобы заглушить ошибки отмены: если после ухода клиента результат никому не нужен, отмена и есть правильное поведение.
До Go 1.21 команды писали собственный тип контекста, который пробрасывал только Value.
Каждый вызов With* оборачивает родителя в небольшую структуру, и контексты образуют связное дерево: отменяемый узел хранит множество своих потомков и при отмене закрывает свой канал и по очереди отменяет их, а Value просто опрашивает родителей вверх по цепочке.
Типы узлов
valueCtx: встроенный родитель плюс один key и val.
cancelCtx: мьютекс, лениво создаваемый канал done, err, cause и children map[canceler]struct{}.
timerCtx: cancelCtx плюс time.Timer и дедлайн.
Привязка к родителю (propagateCancel)
Если родитель уже завершён, потомок отменяется сразу.
Если родитель является стандартным cancelCtx или оборачивает его, потомок добавляется в его map children, без всяких горутин.
Для собственной реализации Context пакет использует её метод AfterFunc, если он есть (Go 1.21+), а иначе запускает горутину, которая ждёт оба канала Done. Поэтому собственные контексты обходятся дороже.
Отмена
Под мьютексом выставляется err, закрывается done, отменяется каждый потомок и очищается map; затем узел удаляет себя из map родителя. Забытый cancel как раз и оставляет в этой map запись.
Value
valueCtx сравнивает свой ключ, а иначе делегирует родителю, поэтому поиск занимает O(глубины). Для нескольких значений это нормально; если нужно много, храните одну структуру под одним ключом.
Используйте context.WithCancelCause (Go 1.20): cancel(err) запоминает причину, а context.Cause(ctx) возвращает её на этом контексте и на всех потомках, при этом ctx.Err() остаётся обычным context.Canceled, на который опираются существующие проверки.
Побеждает первая причина, как и первая отмена. cancel(nil) выставляет причину context.Canceled.
WithTimeoutCause и WithDeadlineCause (Go 1.21) привязывают sentinel-ошибку к дедлайну, и при вложенных бюджетах видно, какой слой исчерпал время. Причина применяется, только когда срабатывает таймер; явный cancel() по-прежнему даёт Canceled.
Для контекста, которому причину не задавали, Cause(ctx) возвращает то же, что Err(), поэтому вызывать его всегда безопасно.
errgroup.WithContext из golang.org/x/sync уже отменяет контекст с ошибкой первого воркера в качестве причины.
Логику управления стройте на Err() и errors.Is, а причину используйте для диагностики: в строке лога, в спане, в возвращаемой ошибке.
Зарегистрируйте колбэк через context.AfterFunc(ctx, f) (Go 1.21): f запустится в своей горутине, когда ctx завершится, а до тех пор ничего не висит в ожидании.
Самописный вариант go func() { <-ctx.Done(); c.Close() }() паркует горутину на каждый вызов, и при долгоживущем ctx она так и не завершается после успешного вызова.
AfterFunc возвращает stop. stop() возвращает true, если он не дал f запуститься, и false, если f уже стартовал; так видно, вероятно ли, что ошибка вызвана отменой.
f выполняется конкурентно с вашим кодом, поэтому всё, чего он касается, должно это выдерживать: допустим ли Close одновременно с Run, надо проверить по контракту клиента.
Тот же приём связывает контексты с API, которые понимают только дедлайны: conn.SetReadDeadline(time.Now()) разблокирует ожидающий Read на net.Conn.
Если ctx уже завершён в момент регистрации, f стартует сразу.
Нет. Shutdown закрывает листенеры и простаивающие соединения, а затем ждёт, пока активные запросы завершатся; его ctx ограничивает только время ожидания, и когда он истекает, хендлеры продолжают работать с живым r.Context().
Чтобы достучаться до хендлеров, задайте http.Server.BaseContext: все контексты запросов производные от него, поэтому его отмена разом отменяет r.Context() во всех выполняющихся запросах.
Рабочая последовательность: перевести readiness-проверку в состояние «не готов», вызвать Shutdown с щедрым бюджетом и, только если он вернул ошибку, отменить базовый контекст, чтобы отстающие сдались, а затем ещё раз коротко подождать.
Захваченные (hijacked) соединения (WebSocket) и горутины, запущенные хендлерами, Shutdown не отслеживает; см. вопрос про graceful shutdown в теме Go HTTP-сервисов.
Хендлер, который никогда не проверяет свой ctx, игнорирует оба рычага: отмена кооперативная.
Контекст сам по себе не пересекает сеть, поэтому каждый хоп должен отправлять оставшийся бюджет, а следующий должен превращать его обратно в дедлайн; тогда работа останавливается везде, как только исходный вызывающий сдался.
Передавайте длительность (time.Until(deadline)), а не метку времени: часы двух машин расходятся. gRPC делает это через grpc-timeout; в обычном HTTP заголовок придумываете вы.
В обычном HTTP middleware читает заголовок и вызывает context.WithTimeout(r.Context(), d), ограничив его серверным максимумом: дедлайн от клиента считается недоверенным вводом.
Исходящие вызовы порождайте от входящего ctx, а не от context.Background() с фиксированным таймаутом: это сбрасывает бюджет на каждом хопе, и в этом ошибка B.
На каждом хопе вычитайте запас на сетевой обмен (round trip) и на отправку собственной ошибки, а если бюджет исчерпан, не вызывайте нижестоящий сервис вовсе.
Дедлайны должны только сокращаться при движении вниз по дереву вызовов; таймаут на хоп остаётся верхним пределом, а не заменой.