voidlearn
 

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 значение
}
context.Background() корень, никогда не отменяется req A: WithTimeout 2s cancel() вызван -- отменён req B: WithTimeout 2s живёт, ничего не заметил запрос в БД ctx.Done() закрыт вызов gRPC ctx.Done() закрыт запрос в БД работает вызов gRPC работает Отмена идёт ТОЛЬКО вниз по дереву: отменили родителя -- отменились все потомки, рекурсивно, за один проход. Вверх отмена не идёт никогда: дочерний cancel() не влияет ни на родителя, ни на соседей.
Дерево контекстов. Каждый WithCancel/WithTimeout подвешивает узел к родителю. Отмена распространяется строго вниз, и потому «отменить весь запрос одним вызовом» не задевает соседние запросы.

Что внутри

type cancelCtx struct Context встроенный родитель mu sync.Mutex защищает всё ниже done atomic.Value chan struct{} children map[canceler] кто отменится вместе со мной err error Canceled / DeadlineExceeded cause error Go 1.20: настоящая причина done создаётся лениво: если Done() не звали, канала нет вообще 1. Взять mu 2. Если err уже не nil - отменён ранее, ничего не делаем (идемпотентность) 3. Записать err и cause 4. close(done) - будит ВСЕХ, кто ждёт на ctx.Done() 5. Пройти по children и рекурсивно отменить каждого 6. Удалить себя из children родителя - иначе родитель растёт вечно Шаг 6 и есть причина правила «всегда defer cancel()»: без него узел висит в дереве до отмены родителя.
cancelCtx. Отмена сводится к 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 и WithDeadline на практике

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)
}
Почему Cause добавили только в 1.20

Долгие годы ctx.Err() возвращал ровно два значения на всю экосистему: context.Canceled и context.DeadlineExceeded. В логах это выглядело как «context canceled» без единого намёка, кто и зачем отменил. Одна из самых частых жалоб на Go в проде. WithCancelCause закрывает вопрос: причина сохраняется в поле cause у самого верхнего отменённого узла и наследуется вниз, а ctx.Err() остаётся прежним ради обратной совместимости. В своих сервисах правило простое: отменяешь - объясняй.

Правила использования

Шесть правил, которые проверяют на ревью
  1. ctx - первый параметр, всегда с именем ctx: func Get(ctx context.Context, id int) (*User, error).
  2. Не хранить в полях структур. Контекст живёт столько же, сколько операция, а структура живёт дольше. Исключение одно и признано самим стандартом: http.Request, где контекст неотделим от запроса.
  3. Никогда не передавать nil. Если непонятно, что передать, ставь context.TODO().
  4. defer cancel() обязателен после любого WithCancel/WithTimeout/WithDeadline. Без него узел остаётся в children родителя, а таймер лежит в куче таймеров. go vet ловит это правилом lostcancel.
  5. WithValue - только для request-scoped данных: request id, trace id, user id, локаль. Не для зависимостей (логгер, БД, конфиг), их передают явно.
  6. Ключ 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)                // паника при рефакторинге, ноль проверок компилятора
Бюджет запроса 500 мс распространяется вниз по слоям HTTP handler: ctx := r.Context() отменится сам, если клиент разорвал соединение или сервер закрывается service: ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() -- иначе таймер и узел дерева переживут запрос db.QueryContext(ctx, ...) драйвер шлёт отмену на сервер БД http.NewRequestWithContext(ctx, ...) Transport рвёт соединение при отмене Разрыв цепочки в любом месте - и отмена дальше не идёт: горутина доработает до конца, займёт соединение и вернёт результат, который уже никому не нужен. Отменяемость должна быть сквозной.
Сквозная отменяемость. Контекст полезен ровно настолько, насколько его пробросили. Один слой, принимающий 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()
    }
}
Что именно делает Shutdown

Закрывает все слушающие сокеты (новых соединений сервер больше не принимает), закрывает все idle-соединения keep-alive, и ждёт, пока активные запросы доработают. Уже выполняющиеся хендлеры он не отменяет, даже когда истечёт shutCtx: он просто вернёт ошибку, а r.Context() отменится, только когда следом вызовут srv.Close() и соединения порвутся. И Shutdown не ждёт перехваченные соединения, на которых работают WebSocket и другие апгрейды протокола. Сервер про такое соединение уже ничего не знает, поэтому закрывать его приходится самому.

Go 1.26: по контексту стало видно, какой сигнал пришёл

Раньше из <-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
Суть: context - стандартизированный способ передать вниз по стеку вызовов сигнал «прекращай» плюс дедлайн плюс несколько request-scoped значений. Контексты образуют дерево, отмена идёт строго сверху вниз и рекурсивно.

Задача, которую он решает

Один входящий запрос порождает десятки горутин: сервисный слой, три параллельных запроса к БД, вызов внешнего 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()

  1. Клиент разорвал соединение или отменил стрим.
  2. Сработал http.TimeoutHandler. Server.WriteTimeout контекст не отменяет: он лишь ставит дедлайн на запись в сокет, а хендлер работает дальше.
  3. Вызван Server.Close: он рвёт соединения. Shutdown контекст не отменяет, даже когда вышел его дедлайн, поэтому после него и зовут Close.
  4. 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 по шагам

  1. Помечает сервер как закрывающийся и закрывает слушающие сокеты: новых соединений ОС уже не принимает (клиенты получат connection refused, поэтому балансировщик надо вывести из ротации ЗАРАНЕЕ).
  2. Сразу, не дожидаясь запросов, запускает зарегистрированные RegisterOnShutdown хуки, каждый в своей горутине: там обычно закрывают WebSocket-соединения, которые Shutdown не видит.
  3. Закрывает все idle keep-alive соединения.
  4. В цикле с растущим интервалом (от 1 мс до 500 мс) проверяет, остались ли активные запросы, и ждёт, пока их не станет ноль либо не истечёт переданный контекст.
Три вещи, которые Shutdown НЕ делает
  • Не отменяет выполняющиеся хендлеры, даже когда истёк его контекст: он просто вернёт ошибку. Их 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 в пределах заданного срока.