2.5context
Проследим отмену и deadline от родителя к дочернему context и дальше до исходящего вызова.
Дерево контекстов и отмена сверху вниз
Контекст нельзя создать «просто так»: каждый конструктор (WithCancel,
WithTimeout, WithValue) первым аргументом принимает
родительский контекст и возвращает новый, привязанный к нему. Родитель запоминает
ребёнка в своей карте children, ребёнок держит ссылку на родителя. Из этих
связей и вырастает дерево контекстов: в корне сидит
context.Background(), который не отменяется никогда, на уровень ниже по узлу на
каждый входящий запрос, ещё ниже по узлу на каждый исходящий вызов внутри запроса. Дерево
тут не метафора: это реальные поля в структуре cancelCtx, по которым сам пакет
context ходит при отмене.
Распространение отмены начинается, когда узел
дерева отменяют: он закрывает свой канал Done() и рекурсивно отменяет всех
своих детей, их детей и так далее до листьев. Направление строго одно, вниз.
Отменённый ребёнок не трогает ни родителя, ни соседей, иначе таймаут одного запроса гасил бы
весь сервис. Отсюда практический смысл всей конструкции: «отменить запрос целиком» значит
отменить один узел, а все горутины под ним узнают об этом сами, без единой строчки
ручной передачи сигнала.
Зачем он нужен
Задача, ради которой context придуман, звучит так: пришёл HTTP-запрос, он породил вызов в сервисный слой, тот породил три параллельных запроса в БД и один во внешний API, а каждый из них свои горутины. Клиент отвалился через 200 мс. Кто и как остановит эти пятнадцать горутин и освободит соединения?
Без context каждый уровень пришлось бы связывать своим done-каналом и вручную
пробрасывать вниз. Context и есть такой done-канал плюс дедлайн, плюс причина отмены, плюс
небольшой набор request-scoped значений, оформленные в один интерфейс из четырёх методов:
type Context interface {
Deadline() (deadline time.Time, ok bool) // когда истечёт (если задан)
Done() <-chan struct{} // закроется при отмене
Err() error // Canceled / DeadlineExceeded, nil пока жив
Value(key any) any // request-scoped значение
}
WithCancel/WithTimeout подвешивает
узел к родителю. Отмена распространяется строго вниз, и потому «отменить весь запрос одним
вызовом» не задевает соседние запросы.Что внутри
close одного канала плюс рекурсивный обход
детей. Остальное учёт: карта детей, идемпотентность и обязательное отцепление от родителя, ради
которого и нужен defer cancel().| Конструктор | Что даёт | Когда применять |
|---|---|---|
Background() | пустой корень, не отменяется | main, инициализация, тесты верхнего уровня |
TODO() | то же самое | заглушка «сюда потом придёт настоящий ctx»; ищется грепом |
WithCancel(p) | отмена по явному вызову | фоновые задачи, конвейеры, остановка воркеров |
WithTimeout(p, d) | отмена через интервал | запрос к БД/API: «не дольше 2 секунд» |
WithDeadline(p, t) | отмена в момент времени | общий бюджет запроса, распределяемый между шагами |
WithValue(p, k, v) | request-scoped значение | request id, trace id, user id - и всё |
WithCancelCause(p) 1.20 | отмена с указанием причины | когда важно, почему отменили |
AfterFunc(ctx, f) 1.21 | вызвать f при отмене | уборка без собственной горутины-наблюдателя |
WithoutCancel(p) 1.21 | копия значений без отмены | дописать метрику/аудит после отмены запроса |
WithTimeout(parent, d) разворачивается ровно в WithDeadline(parent,
time.Now().Add(d)). Разница появляется, когда бюджет делится между шагами: если у
тебя всего 1 секунда на весь запрос и три последовательных вызова, WithTimeout(ctx,
time.Second) на каждом даст суммарно 3 секунды, а один WithDeadline
сверху честно ограничит всё. И ещё: дедлайн наследуется, дочерний контекст не может
иметь дедлайн позже родительского, лишний WithTimeout(ctx, time.Hour) внутри
запроса с бюджетом 200 мс просто ничего не изменит.
context.Cause, AfterFunc и WithoutCancel
// Go 1.20: причина отмены. ctx.Err() скажет «что», context.Cause «почему».
ctx, cancel := context.WithCancelCause(parent)
go func() {
if err := validate(req); err != nil {
cancel(fmt.Errorf("невалидный запрос: %w", err))
}
}()
<-ctx.Done()
ctx.Err() // context.Canceled, всегда одно и то же, для логов бесполезно
context.Cause(ctx) // "невалидный запрос: поле id пустое", это и пишем в лог
// Для дедлайнов тоже работает (Go 1.21: WithTimeoutCause / WithDeadlineCause)
ctx, cancel := context.WithTimeoutCause(parent, 2*time.Second, errDBSlow)
// при истечении: ctx.Err() == DeadlineExceeded, Cause(ctx) == errDBSlow
// Go 1.21: AfterFunc, колбэк при отмене без своей горутины
stop := context.AfterFunc(ctx, func() {
conn.Close() // выполнится в отдельной горутине при отмене ctx
})
defer stop() // отменить регистрацию, если управились раньше
// Go 1.21: у WithoutCancel значения есть, отмены нет
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
doWork(ctx)
// аудит пишем, даже если клиент отвалился
go writeAudit(context.WithoutCancel(ctx), event)
}
Долгие годы ctx.Err() возвращал ровно два значения на всю экосистему:
context.Canceled и context.DeadlineExceeded. В логах это выглядело
как «context canceled» без единого намёка, кто и зачем отменил. Одна из самых частых жалоб
на Go в проде. WithCancelCause закрывает вопрос: причина сохраняется в поле
cause у самого верхнего отменённого узла и наследуется вниз, а
ctx.Err() остаётся прежним ради обратной совместимости. В своих сервисах
правило простое: отменяешь - объясняй.
Правила использования
ctx- первый параметр, всегда с именемctx:func Get(ctx context.Context, id int) (*User, error).- Не хранить в полях структур. Контекст живёт столько же, сколько операция, а структура
живёт дольше. Исключение одно и признано самим стандартом:
http.Request, где контекст неотделим от запроса. - Никогда не передавать
nil. Если непонятно, что передать, ставьcontext.TODO(). defer cancel()обязателен после любогоWithCancel/WithTimeout/WithDeadline. Без него узел остаётся вchildrenродителя, а таймер лежит в куче таймеров.go vetловит это правиломlostcancel.WithValue- только для request-scoped данных: request id, trace id, user id, локаль. Не для зависимостей (логгер, БД, конфиг), их передают явно.- Ключ
WithValue- свой неэкспортируемый тип, а не строка: строковый ключ может совпасть с чужим и молча перетереть значение.
// Правильный ключ для WithValue
type ctxKey int
const (
keyRequestID ctxKey = iota
keyUserID
)
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, keyRequestID, id)
}
func RequestID(ctx context.Context) string {
v, _ := ctx.Value(keyRequestID).(string) // типобезопасный геттер
return v
}
// Антипаттерн: зависимости через контекст
ctx = context.WithValue(ctx, "db", db) // строковый ключ + зависимость = двойная ошибка
db := ctx.Value("db").(*sql.DB) // паника при рефакторинге, ноль проверок компилятора
context.Background() вместо переданного
ctx, обнуляет всю цепочку выше.context в net/http: с обеих сторон
В HTTP-сервисе контекст возникает в двух местах, и на собесе спрашивают про оба. На входе
стоит r.Context(), который сервер отменяет сам. На выходе
http.NewRequestWithContext, через который отмена уезжает к соседнему сервису. А
между ними Server.Shutdown, который останавливает весь сервер.
// Вход: сервер сам отменяет r.Context(), когда
// 1) клиент разорвал соединение (HTTP/1.1) или сбросил стрим (HTTP/2, RST_STREAM);
// 2) ServeHTTP вернулся: тогда контекст отменяется в любом случае;
// 3) вызван Server.Close (Shutdown сам его не отменяет, только ждёт).
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
res, err := slowQuery(ctx) // отмена уедет в БД
if err != nil {
// клиент ушёл, писать в w уже некуда, но и паники не будет: запись просто теряется
if errors.Is(err, context.Canceled) {
log.Info("клиент отвалился, работа отменена")
return
}
http.Error(w, "internal", 500)
return
}
json.NewEncoder(w).Encode(res)
}
// Выход: контекст запроса едет в чужой сервис
func callBilling(ctx context.Context, id string) (*Bill, error) {
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, billingURL+id, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req) // при отмене Transport рвёт соединение
if err != nil {
return nil, fmt.Errorf("billing: %w", err)
}
defer resp.Body.Close()
...
}
r.Context() отменяется, как только ServeHTTP вернул управление;
обрыв клиента здесь лишь один из поводов. Поэтому «запущу фоновую задачу и отвечу 202» с
go doWork(r.Context()) сломается сразу: задача получит отменённый контекст
через доли миллисекунды. Правильно так: context.WithoutCancel(r.Context()) (Go
1.21) или отдельный контекст приложения с собственным таймаутом.
Остановка сервера по сигналу
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
// 1.16+: Notify-контекст вместо ручного канала сигналов
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // пришёл SIGTERM
// Go 1.26: NotifyContext отменяет через CancelCauseFunc, причина называет сигнал
log.Info("останавливаемся", "причина", context.Cause(ctx))
// Проверено запуском на go1.27 (программа послала SIGTERM сама себе):
// ctx.Err() = context canceled -- обезличено, как и раньше
// context.Cause(ctx) = terminated signal received
stop() // вернуть обработчик по умолчанию: второй Ctrl+C убьёт жёстко
shutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// Shutdown закрывает слушателя и ждёт активные запросы.
// Контекст здесь задаёт дедлайн на ожидание, а не отменяет сами запросы.
if err := srv.Shutdown(shutCtx); err != nil {
log.Warn("не все запросы успели за 20с, рвём принудительно")
srv.Close()
}
}
Закрывает все слушающие сокеты (новых соединений сервер больше не принимает), закрывает все
idle-соединения keep-alive, и ждёт, пока активные запросы доработают. Уже выполняющиеся
хендлеры он не отменяет, даже когда истечёт shutCtx: он просто вернёт ошибку,
а r.Context() отменится, только когда следом вызовут srv.Close() и
соединения порвутся. И Shutdown не ждёт перехваченные соединения, на которых работают WebSocket и другие апгрейды протокола. Сервер про такое соединение уже ничего не знает, поэтому закрывать его приходится
самому.
Раньше из <-ctx.Done() было понятно только «что-то случилось»:
ctx.Err() возвращал context.Canceled одинаково и для SIGTERM, и
для Ctrl+C, и для собственного вызова stop(). С Go 1.26
signal.NotifyContext отменяет контекст через CancelCauseFunc,
поэтому причину достаёт обычный context.Cause(ctx): он вернёт ошибку, в тексте
которой назван сигнал (для SIGTERM это terminated signal received). Польза
ровно одна, зато ощутимая: одна строка в логе перед остановкой, по которой потом видно,
оркестратор погасил под штатно или кто-то нажал Ctrl+C руками.
Вопросы
9Задача, которую он решает
Один входящий запрос порождает десятки горутин: сервисный слой, три параллельных запроса к
БД, вызов внешнего API, запись в кэш. Клиент отвалился через 200 мс, и вся эта работа стала
бессмысленной, но продолжает жечь CPU, держать соединения из пула и место в памяти. Без
общего механизма каждый слой пришлось бы связывать своим done-каналом и
передавать его руками через все сигнатуры.
Context даёт ровно такой done-канал, только со стандартным интерфейсом из четырёх методов,
который понимают database/sql, net/http, gRPC, драйверы Kafka и
вообще вся экосистема. Ценна тут именно стандартизация: любая библиотека умеет отменяться
одинаково.
Дерево
- Корнем служит
context.Background(), он никогда не отменяется и не имеет дедлайна. ЭтоemptyCtx, буквально пустая структура. - Каждый
WithCancel/WithTimeout/WithValueсоздаёт новый контекст-потомок, ссылающийся на родителя. Родитель о потомке знает через картуchildren(только отменяемые потомки в ней есть). - Отмена родителя рекурсивно отменяет всех потомков. Отмена потомка на родителя не влияет: сигнал ходит только вниз.
- Дедлайн наследуется по минимуму: потомок не может жить дольше родителя.
WithTimeout(ctx, time.Hour)внутри запроса с бюджетом 200 мс не даст ничего.
Механика отмены
Done() возвращает <-chan struct{}, который при отмене
закрывается. Закрытие канала будит сразу всех, кто на нём ждёт, и повторное чтение из
закрытого канала возвращает нулевое значение мгновенно. Поэтому один и тот же контекст можно
проверять из любого числа горутин без всяких блокировок. Сам канал создаётся лениво: если
Done() никто не звал, канала в структуре нет.
«Context не останавливает горутину. Он лишь сообщает, что результат больше не нужен.
Остановиться - обязанность самой горутины: проверить ctx.Done() в
select или передать ctx в вызов, который умеет отменяться». Кто
этого не произносит, обычно и в коде забывает про ctx.Done().
cancelCtx,
у двух последних сверху таймер), четвёртый - неотменяемый узел-обёртку со значением.
WithTimeout(p,d) - это буквально
WithDeadline(p, time.Now().Add(d)).Разбор по одному
WithCancel(parent)отменяется только по явному вызовуcancel(). Для фоновых воркеров, конвейеров, «останови всё, если один упал». ВозвращаетcancelCtx.WithTimeout(parent, d)- отмена через интервал. 95% случаев в продовом коде: «запрос к платёжке не дольше 800 мс». Внутри лежитtimerCtx, то естьcancelCtx+time.Timer, который при срабатывании зовёт ту жеcancelсDeadlineExceeded.WithDeadline(parent, t)отменяет в абсолютный момент. Нужен, когда бюджет делится между последовательными шагами: три вызова поWithTimeout(ctx, 1s)дадут суммарно 3 секунды, а одинWithDeadlineсверху честно ограничит весь запрос секундой. Так обычно и делают «сквозной дедлайн», передавая его в заголовке между сервисами.WithValue(parent, k, v)стоит особняком: этоvalueCtx, у него нет ни отмены, ни таймера, ни карты детей. Одно значение на узел; поискValue(k)идёт линейно вверх по цепочке до корня. Отсюда правило: не наворачивать десятки значений, каждое это лишний узел и лишний шаг поиска.
Стоимость
WithValue стоит одну маленькую аллокацию, поиск идёт за O(глубины).
WithCancel добавляет к аллокации запись в карту children родителя
под мьютексом. WithTimeout делает то же самое плюс таймер в куче таймеров
рантайма. На горячем пути с миллионом RPS это заметно, а в обычном сервисе это шум рядом с
сетевым вызовом.
Что ловят
cancelобязателен даже уWithTimeout. Многие думают: «таймер сам сработает, зачемdefer cancel()». Но если функция вернулась за 10 мс, а таймаут 30 секунд, то 30 секунд узел висит вchildrenродителя и таймер лежит в куче. При тысяче RPS это тысячи мёртвых объектов.go vetловит правиломlostcancel.cancel()идемпотентен: зови сколько угодно раз, второй вызов увидит непустойerrи выйдет.- Отмена родителя не отличима от собственного
cancel()безcontext.Cause:ctx.Err()вернётCanceledв обоих случаях. Так же не отличить таймаут родителя от своего: оба даютDeadlineExceeded.
ctx.Err() отвечает «что» и всегда одно из двух
значений; context.Cause(ctx) отвечает «почему» - возвращает ошибку, переданную
в cancel(err) у WithCancelCause, или причину дедлайна из
WithTimeoutCause.Почему это понадобилось
До Go 1.20 весь мир видел в логах ровно две строки: context canceled и
context deadline exceeded. Кто отменил, на каком уровне и из-за чего,
оставалось загадкой. В сервисе с errgroup и десятью параллельными вызовами это означало, что
при падении одного из них девять остальных писали в лог бесполезное context
canceled, а настоящая ошибка терялась.
var errTooManyRetries = errors.New("исчерпаны ретраи")
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil) // nil = штатное завершение
go func() {
if err := doRetries(ctx); err != nil {
cancel(fmt.Errorf("%w: %v", errTooManyRetries, err))
}
}()
<-ctx.Done()
ctx.Err() // context.Canceled, всегда
context.Cause(ctx) // "исчерпаны ретраи: dial tcp: i/o timeout"
errors.Is(context.Cause(ctx), errTooManyRetries) // true: причину можно матчить
Детали, которые отличают знающего
Causeнаследуется вниз. Причина хранится в узле, где произошла отмена; потомки, отменённые каскадом, отдают ту же причину. Поэтому её видно из самой глубины стека.- На неотменённом контексте
Causeвозвращаетnil, ровно как иErr(). - Если причину не задали (обычный
WithCancel),Causeвернёт то же, чтоErr(). То есть зватьCauseвместоErr()в логах безопасно всегда. - Go 1.21 добавил
WithTimeoutCauseиWithDeadlineCause: при истеченииErr()будетDeadlineExceeded, аCauseотдаст твою ошибку вродеerrBillingSlow. Удобно, когда в цепочке несколько таймаутов и надо понять, чей сработал. errgroupс 1.20 внутри используетWithCancelCause, так что причиной отмены группы становится ошибка первой упавшей горутины.
В логах и в трейсах пиши context.Cause(ctx), в бизнес-логике сравнивай
ctx.Err() с context.Canceled/DeadlineExceeded.
Наружу, в HTTP-ответ, причину отдавать не надо: она внутренняя.
Почему первым аргументом и с именем ctx
Это конвенция всей стандартной библиотеки: func (db *DB) QueryContext(ctx
context.Context, query string, args ...any). Единообразие даёт дешёвый греп
(grep -rn "ctx context.Context" находит все отменяемые точки), позволяет
линтерам вроде contextcheck и containedctx работать, и делает
обёртки-мидлвари механическими. Исключение только одно: контекст не передают в
конструкторы объектов, живущих дольше вызова.
Почему не хранить в полях структуры
// Антипаттерн
type Service struct {
ctx context.Context // чей это контекст? какого запроса?
db *sql.DB
}
func (s *Service) Get(id int) (*User, error) {
return s.db.QueryRowContext(s.ctx, ...) // отменится, когда отменится... что-то
}
Сервис живёт весь аптайм, он синглтон. Контекст запроса живёт 200 мс. Положишь один в
другой, и получишь либо вечный контекст (тогда отмена не работает вовсе), либо контекст
первого запроса, отмена которого убьёт все последующие. Плюс гонка: поле пишется и читается
из разных горутин. Линтер containedctx в golangci-lint ловит это правилом.
Легальное исключение признано самим стандартом: http.Request, где контекст
неотделим от объекта запроса и меняется через r.WithContext(ctx), создающий
копию.
Почему WithValue - не для зависимостей
- Нет типобезопасности.
ctx.Value(k).(*sql.DB)приводит тип в рантайме. Убрали значение из мидлвари, и узнаешь об этом паникой в проде, а не ошибкой компиляции. - Нет видимости зависимостей. Сигнатура
func Handle(ctx context.Context)ничего не говорит о том, что функции нужны БД, логгер и кэш. Явные параметры или поля структуры документируют себя сами. - Ключ обязан быть своим неэкспортируемым типом. Строковый ключ
"user"может совпасть с чужим из библиотеки и молча перетереться. Потому линтер staticcheck (проверка SA1029) и ругается на встроенные типы в ключах; в стандартномgo vetтакой проверки нет. - Что класть можно: request id, trace id / span, user id и роли после аутентификации, локаль, флаги A/B-теста, дедлайн-подсказки. То есть всё, что относится к конкретному запросу и нужно на всех уровнях, но бизнес-параметром функции не считается.
Спрашивают: «а можно передать через context логгер?» Правильный ответ: логгер как зависимость нельзя, а логгер, уже обогащённый полями запроса (trace id, user id), это распространённая практика, и spec она не противоречит, потому что данные всё равно request-scoped. Тут важно объяснить границу, а не заучить запрет.
net/http для каждого соединения держит фоновую
горутину connReader, которая читает сокет; увидев EOF или ошибку, она зовёт
cancelCtx запроса - и r.Context().Done() закрывается.Механика
На каждое принятое соединение сервер запускает conn.serve. Внутри, пока
выполняется хендлер, работает фоновое чтение (startBackgroundRead): оно ждёт
данных, чтобы поймать либо новый пайплайненный запрос, либо закрытие. Если чтение вернуло
EOF или ECONNRESET, вызывается cancelCtx, и контекст запроса
отменяется с context.Canceled. В HTTP/2 то же самое делает
RST_STREAM или закрытие стрима.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case res := <-doWork(ctx):
json.NewEncoder(w).Encode(res)
case <-ctx.Done():
// клиент ушёл, сработал TimeoutHandler или вызван srv.Close
log.Info("отменено", "err", ctx.Err(), "cause", context.Cause(ctx))
return // писать в w бессмысленно, но безопасно
}
}
Четыре причины отмены r.Context()
- Клиент разорвал соединение или отменил стрим.
- Сработал
http.TimeoutHandler.Server.WriteTimeoutконтекст не отменяет: он лишь ставит дедлайн на запись в сокет, а хендлер работает дальше. - Вызван
Server.Close: он рвёт соединения.Shutdownконтекст не отменяет, даже когда вышел его дедлайн, поэтому после него и зовутClose. ServeHTTPпросто вернул управление: контекст отменяется всегда, даже при успешном ответе.
- «Запущу фон и верну 202»:
go job(r.Context()), и задача умрёт сразу после ответа. Нуженcontext.WithoutCancel(r.Context())(Go 1.21) или контекст приложения. - Раньше для этого брали
CloseNotifier, но его объявили устаревшим, так что правильный ответ на собесе именноr.Context(). - Отмена не мгновенна: до Go 1.20 фоновое чтение при HTTP/1.1 могло заметить обрыв
с задержкой, а если клиент просто «замолчал» без FIN/RST, сервер узнает только по
своему таймауту. Поэтому
ReadHeaderTimeoutиWriteTimeoutставят всегда. - Запись в
wпосле отмены не паникует и не обязательно возвращает ошибку вEncode: данные просто уходят в никуда. Проверять надоctx.Err(), а не результат записи.
*Context-варианты вызовов:
QueryContext/ExecContext/BeginTx для
database/sql и http.NewRequestWithContext для HTTP. Отменяемость
обязана быть сквозной - один слой с context.Background() обнуляет всю цепочку.БД
// Правильно
rows, err := db.QueryContext(ctx, "select id, name from users where org = $1", orgID)
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
_, err = tx.ExecContext(ctx, "update balances set amount = amount - $1 where id = $2", sum, id)
// Неправильно: эти формы отмену вообще не видят
rows, err := db.Query(...) // внутри это QueryContext(context.Background(), ...)
Контекст в database/sql работает на трёх уровнях. Во-первых, ожидание
свободного соединения из пула прерывается по ctx.Done(): при исчерпанном пуле
запрос падает с context deadline exceeded вместо бесконечного ожидания.
Во-вторых, драйвер, реализующий QueryerContext, обрывает запрос, и дальше всё
зависит от драйвера: pgx и lib pq шлют серверу CancelRequest (отдельное
соединение с сообщением отмены), а go-sql-driver/mysql никакого KILL QUERY не
шлёт, просто закрывает сокет, и запрос на сервере может доработать до конца. В-третьих, это
цена отмены: соединение, на котором отменили запрос, все три драйвера закрывают, и
database/sql выбрасывает его из пула, а не возвращает. Частые отмены - это
частые переподключения.
Отменится ctx, переданный в BeginTx, и транзакция откатится
автоматически, а соединение вернётся в пул. Удобно, но есть сюрприз: длинная транзакция
под запросным контекстом умрёт вместе с уходом клиента, даже если бизнес-логика хотела бы её
докатить. Для «докатить в любом случае» берут context.WithoutCancel либо
отдельный контекст с собственным таймаутом.
HTTP-клиент
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
resp, err := client.Do(req)
При отмене Transport закрывает соединение (в HTTP/2 шлёт
RST_STREAM), а Do возвращает ошибку, обёрнутую
*url.Error. Проверять надо через errors.Is(err,
context.DeadlineExceeded), а не сравнением строк. И отдельно: resp.Body
закрывай всегда, иначе соединение не вернётся в пул и никакая отмена не поможет.
Иерархия таймаутов
- Общий бюджет запроса ставит
WithDeadlineв мидлвари, например 3 с. - Пер-вызов:
WithTimeoutна каждый внешний вызов, меньше бюджета. БД 500 мс, биллинг 800 мс. Дедлайн наследуется, так что суммарно всё равно не выйдет за 3 с. client.Timeout- грубая страховка на весь запрос, включая чтение тела; контекст точнее, и ставить его надо в дополнение, а не вместо.- Ретраи считают оставшийся бюджет:
d, ok := ctx.Deadline(), и если до дедлайна меньше, чем средняя длительность вызова, ретрай не начинают.
Любая функция, которая делает I/O или может занять больше миллисекунды, принимает
ctx первым аргументом и передаёт его дальше без замены на
Background(). Линтер contextcheck находит места, где
цепочка рвётся.
signal.NotifyContext ловит SIGTERM/SIGINT,
srv.Shutdown(ctx) закрывает слушателей и ждёт активные запросы, контекст в
Shutdown - это дедлайн ожидания, после которого нужен srv.Close().Канонический код
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err) // порт занят и подобное
}
case <-ctx.Done():
log.Info("получен сигнал, останавливаемся")
}
stop() // вернуть дефолтный обработчик: второй Ctrl+C убьёт сразу
shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
log.Warn("таймаут graceful, рвём соединения")
_ = srv.Close()
}
Что делает Shutdown по шагам
- Помечает сервер как закрывающийся и закрывает слушающие сокеты: новых соединений ОС уже не принимает (клиенты получат connection refused, поэтому балансировщик надо вывести из ротации ЗАРАНЕЕ).
- Сразу, не дожидаясь запросов, запускает зарегистрированные
RegisterOnShutdownхуки, каждый в своей горутине: там обычно закрывают WebSocket-соединения, которыеShutdownне видит. - Закрывает все idle keep-alive соединения.
- В цикле с растущим интервалом (от 1 мс до 500 мс) проверяет, остались ли активные запросы, и ждёт, пока их не станет ноль либо не истечёт переданный контекст.
- Не отменяет выполняющиеся хендлеры, даже когда истёк его контекст: он просто
вернёт ошибку. Их
r.Context()отменится, только когда вызовутsrv.Close(). Долгий хендлер без своего таймаута задержит выкатку на весь дедлайн. - Не ждёт перехваченные соединения (hijacked: хендлер забрал сырой сокет через
http.Hijacker, это WebSocket и апгрейды протокола), у них свой механизм остановки. - Не останавливает твои фоновые горутины: консьюмеры Kafka, крон-задачи, воркер-пулы гасишь сам, и обычно ПОСЛЕ HTTP, чтобы доехали запросы, которые эти воркеры используют.
Порядок в реальном сервисе
Сначала readiness-проба возвращает 503 (или сервис снимают из балансировщика), потом
несколько секунд ждут, чтобы трафик перестал приходить. Потом srv.Shutdown с
дедлайном чуть меньше, чем terminationGracePeriodSeconds в Kubernetes (иначе
прилетит SIGKILL посреди уборки). Потом останавливают консьюмеры и воркеры, потом закрывают
пулы БД и Redis, последним идёт flush метрик и трейсов.
done chan struct{} как сигнал
«всем стоп» + мьютекс со списком детей для каскада + таймер для дедлайна + поле ошибки.
Собственно, так context и устроен.Шаг 1: сигнал
Нужен примитив «оповестить неизвестное заранее число слушателей, один раз, навсегда». Таким
свойством обладает ровно одна операция с каналом: close. Закрытие будит всех
ожидающих, и любое последующее чтение возвращается мгновенно. Отправка значений не годится,
её прочитает ровно один получатель.
Шаг 2: иерархия
type myCtx struct {
mu sync.Mutex
done chan struct{}
err error
children map[*myCtx]struct{}
deadline time.Time
timer *time.Timer
}
func WithCancel(p *myCtx) (*myCtx, func()) {
c := &myCtx{done: make(chan struct{}), children: map[*myCtx]struct{}{}}
p.mu.Lock()
if p.err != nil { // родитель уже мёртв, и ребёнок рождается мёртвым
p.mu.Unlock()
c.cancel(p.err)
return c, func() {}
}
p.children[c] = struct{}{}
p.mu.Unlock()
return c, func() { c.cancel(errCanceled); p.remove(c) }
}
func (c *myCtx) cancel(err error) {
c.mu.Lock()
if c.err != nil { // идемпотентность
c.mu.Unlock()
return
}
c.err = err
close(c.done) // будим всех своих слушателей
kids := c.children
c.children = nil
if c.timer != nil {
c.timer.Stop()
}
c.mu.Unlock()
for k := range kids { // каскад вниз, вне лока, иначе дедлок на детях
k.cancel(err)
}
}
Шаг 3: дедлайн и значения
Таймаут собирается из time.AfterFunc(d, func(){ c.cancel(errDeadline) }) плюс
наследование: если у родителя дедлайн раньше, свой таймер не нужен вовсе. Значения живут в
отдельном типе-обёртке с одной парой ключ/значение и рекурсивным поиском вверх; он
неотменяемый и потому не участвует в дереве отмены.
Грабли, о которые спотыкаются на доске
- close, а не send: понимает ли кандидат разницу в семантике оповещения.
- Идемпотентность. Повторный
cancelне должен паниковать на двойномclose. - Отцепление от родителя, без него
childrenрастёт вечно и течёт память. - Каскад вне лока: зови
cancelдетей под своим мьютексом, и при обратных ссылках легко словишь дедлок. - Ребёнок уже отменённого родителя отменяется немедленно, а не живёт вечно.
- Ленивое создание канала не обязательно, но за упоминание плюс: в реальном context
doneсоздаётся только при первом вызовеDone(), экономя аллокацию.
ctx.Done(), останется висеть
навсегда, удерживая свой стек и всё, на что он ссылается.Типовой пример
// Плохо: получили ctx и не используем его
func fetch(ctx context.Context, urls []string) []Result {
ch := make(chan Result) // небуферизованный
for _, u := range urls {
go func(u string) {
ch <- get(u) // встанет навсегда, если читатель ушёл
}(u)
}
var out []Result
for range urls {
select {
case r := <-ch:
out = append(out, r)
case <-ctx.Done():
return out // вышли, а N горутин навсегда заперты на ch
}
}
return out
}
Утечка здесь ровно в том, что отправители не знают об отмене. Чинится двумя независимыми
способами: сделать канал буферизованным на len(urls), чтобы отправка никогда не
блокировала, либо научить отправителя смотреть на контекст.
// Хорошо, вариант 1: буфер под все результаты, отправка никогда не блокирует
ch := make(chan Result, len(urls))
// Хорошо, вариант 2: отправитель уважает отмену
go func(u string) {
select {
case ch <- get(u):
case <-ctx.Done(): // читателя больше нет, уходим
}
}(u)
Другие сценарии
- Долгий цикл без проверки. В
for _, row := range millionRows { heavy(row) }отмену не смотрят ни разу. Лечитсяselect { case <-ctx.Done(): return ctx.Err(); default: }в начале итерации (это неблокирующая проверка, стоит единицы наносекунд). time.Sleepвместо таймера. Спящая горутина не отменяема. Меняй наselect { case <-time.After(d): case <-ctx.Done(): }илиtimer := time.NewTimer(d); defer timer.Stop().- Вызов без Context-варианта.
db.Query,http.Get,conn.Readбез дедлайна: контекст есть, а отменять нечего. Для голых сокетов помогаетconn.SetReadDeadlineплюсcontext.AfterFunc(ctx, func(){ conn.Close() }). - Фон, переживший запрос. С
go audit(r.Context(), ev)проблема обратная: не утечка, а мгновенная смерть задачи. Нуженcontext.WithoutCancel.
Как ловить
- Метрика
runtime.NumGoroutine()в мониторинге: монотонный рост под ровной нагрузкой почти всегда означает утечку. go tool pprof http://host/debug/pprof/goroutine: в профиле видно, на какой строке заблокированы тысячи одинаковых горутин.go.uber.org/goleakвTestMainуронит тест, если после него остались лишние горутины. Дешевле всего ловить именно тут.
Каждый go func отвечает на вопрос «что заставит эту горутину завершиться?».
Ответов допустимо три: закрылся входной канал, сработал ctx.Done(), кончилась
сама работа. Ответа нет, значит код не проходит ревью.
Что стоит запомнить
- Отмена родителя распространяется потомкам.
- Deadline задаёт верхнюю границу ожидания.
- Контекст нужно передать до самого I/O вызова.
Частые ошибки
- Не хранить request context в долгоживущих структурах.
- Отмена не останавливает функцию, которая игнорирует ctx.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи выполнение
Дочерний context получил deadline родителя. Родитель отменили до deadline. Что прочитает worker после <-ctx.Done()?
parent, cancel := context.WithTimeout(context.Background(), time.Hour); defer cancel()
child, stop := context.WithCancel(parent); defer stop()
cancel(); <-child.Done(); fmt.Println(child.Err())
Ответ и разбор
Печатается context canceled: отмена родителя распространяется в child и происходит раньше deadline. Done закрывается, Err сообщает причину остановки.
Код-ревью
2. Найди проблему и исправь
Исходящий запрос не имеет дедлайна и не наследует отмену handler. Измени сигнатуру и вызов.
func call() { http.Get(url) }
Ответ и разбор
Передай контекст и используй его при создании запроса; также верни ошибку вызывающему коду:
func call(ctx context.Context, client *http.Client, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return fmt.Errorf("build request: %w", err) }
resp, err := client.Do(req)
if err != nil { return fmt.Errorf("call endpoint: %w", err) }
defer resp.Body.Close()
return nil
}
err := call(r.Context(), client, url)В handler передаётся r.Context(), а если нужен верхний срок вызова, оберни его в context.WithTimeout и вызови cancel.
Применение
3. Разбери рабочий сценарий
HTTP handler вызывает клиент с context.Background(), поэтому запрос клиента не прекращается при уходе пользователя. Исправь передачу context и задай предел времени исходящего вызова.
Ответ и разбор
Передай входящий r.Context() в клиент. Для отдельного верхнего предела создай ctx, cancel := context.WithTimeout(r.Context(), timeout) и defer cancel; клиент обязан использовать этот ctx в HTTP request. Так сохраняется отмена родителя и действует deadline вызова.
Применение
4. Разбери граничный сценарий
Входящий HTTP запрос отменили, когда клиентский запрос завис. Как проверить, что отмена дошла до сетевого клиента?
Ответ и разбор
Создай тестовый HTTP сервер, который ждёт r.Context().Done(). Передай в handler context из запроса, отмени его, проверь что сервер увидел отмену и клиент вернулся с context error в пределах заданного срока.