Context в Go: отмена только вниз, cancel, который не про отмену, и Value, который дорожает с глубиной
Собеседование идёт лестницей: зачем контекст — почему первым аргументом — что делает cancel и почему его вызывают всегда — чем Canceled отличается от DeadlineExceeded — что класть в Value. Замерено: забытый cancel оставляет 123 байта на потомка навсегда, а Value на двадцати слоях дороже в 18 раз.
Полное техническое изложение
TL;DR
Контекст решает одну задачу: работа, которая уже никому не нужна, о своей ненужности не знает. Клиент ушёл или срок истёк, а запрос в базе всё ещё выполняется и держит ресурсы. Контекст переносит по дереву вызовов три вещи — отмену, срок и значения, привязанные к запросу, — чтобы эта новость дошла до самого низа цепочки. Сам он при этом ничего не прерывает: отмена — сигнал, и действует она только там, где её читают.
Отсюда то, что удивляет. Связь в дереве односторонняя: отмена родителя
доходит до всех потомков, отмена потомка не трогает ни родителя, ни соседа —
проверено прогоном. cancel — не про отмену результата, а про освобождение: его
вызывают всегда, даже при успехе, иначе потомок остаётся в списке родителя.
Замерено: десять тысяч потомков без cancel оставляют 123 байта на каждого,
с cancel — 0. И Canceled с DeadlineExceeded — разные ошибки: после
таймаута errors.Is(err, context.Canceled) даёт false, а код, проверяющий
только Canceled, не отличит ушедшего клиента от собственного просроченного
дедлайна.
Дальше — числа, устройство и границы. Контекст передают первым аргументом, а
не хранят в структуре: у каждого вызова свой срок. В Value кладут данные
уровня запроса, проходящие через границы пакетов, — трассировку, дедлайн, данные
аутентификации, но не параметры функции. Класть дёшево (одно выделение), а
доставать дорого: поиск идёт вверх по цепочке родителей, и на двадцати слоях это
в 18 раз дороже, чем на нуле, при 2,21 нс у поля структуры. Кратность верна
для этого прогона на go1.24.7; правило из неё — доставать один раз на входе, а не
помнить число. И context.Background() никогда не отменяется, его Done()
равен nil.
- сервер получает запрос, что-то по нему делает и отвечает;
- работа по одному запросу может идти сразу в нескольких горутинах и уходить в другие сервисы;
- клиент не ждёт вечно: он может уйти, а у нас может истечь свой срок ожидания.
WithCancel,WithTimeout,WithValue,Done(),Err()— все разбираются по дороге;- чем
Canceledотличается отDeadlineExceeded, что такоеWithoutCancelиAfterFunc, и наносекунды из замеров.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Зачем контекст?» — разминка: отмена, дедлайн, значения уровня запроса.
- «Почему первым аргументом, а не полем структуры?» — здесь начинается содержание.
- «Что делает
cancelи почемуdefer cancel()пишут даже при успехе?» - «Чем
Canceledотличается отDeadlineExceeded?» — вопрос, на котором ломается обработка таймаутов. - «Что класть в
Value?» — и почему «всё, что удобно» неверно. - «Как отменить операцию, которая не принимает контекст?» — вопрос на понимание, что контекст ничего не прерывает сам.
Дальше урок идёт по этой лестнице. Спина у неё одна: контекст — это дерево сигналов, а не хранилище и не средство прерывания.
База: зачем работе знать, что запрос уже не нужен
Начнём с задачи, а не с пакета.
Клиент отправил запрос. Сервер принял его и пошёл работать: сходил в базу, полез в соседний сервис, что-то посчитал. А клиент за это время закрыл вкладку и ушёл. Ответ теперь никому не нужен — но работа по нему продолжается: запрос к базе выполняется, соединение занято, память держится. То же самое, если ответа ждать уже поздно: клиент отвалился по своему сроку ожидания, а мы всё ещё считаем.
Работа, которую никто не ждёт, — это не просто лишняя работа. Под нагрузкой она вытесняет нужную: каждая брошенная задача занимает место, которое досталось бы живому запросу.
Значит, до самого низа цепочки вызовов должна как-то доходить новость: это больше не нужно. Сама по себе ни одна функция внизу её не узнает — она не видит ни клиента, ни часов. Новость надо принести, и приносят её тем же путём, каким шёл запрос: сверху вниз по вызовам. Это и есть контекст.
Переносит он три вещи:
- отмену — новость, что работа больше не нужна;
- срок — момент, после которого она не нужна в любом случае;
- значения, привязанные к запросу, — то, что относится к нему целиком и должно быть видно на всём пути.
Первые две — одна и та же новость, только приходит она по разным причинам: кто-то отменил или вышло время. Третья вещь устроена иначе и разбирается отдельно.
И главное: новость сама ничего не останавливает. Она доступна для чтения — а смотреть на неё обязана сама работа. Минимальный образец выглядит так: работа ждёт двух событий сразу — «контекст закончился» и «результат готов», — и что случится раньше, то и определит исход.
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return nil, ctx.Err()
}Функция, которая так написана, отменяемая. Функция, которая просто считает в цикле и никуда не смотрит, — нет, сколько контекстов ей ни передай.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше — про то, почему новость идёт только вниз и никогда вверх, почему функцию отмены вызывают даже после успеха, чем «отменили» отличается от «вышло время» и во что обходится третья вещь — значения.
Механизм 1: четыре назначения, которые путают
context делает четыре разных дела, и половина путаницы в теме — оттого,
что их считают одним:
| назначение | чем задаётся | кто им пользуется |
|---|---|---|
| отмена — сообщить, что работа больше не нужна | WithCancel | тот, кто читает Done() |
| срок — ограничить время сверху | WithTimeout, WithDeadline | он же |
| распространение — донести и то и другое через все вызовы | первый аргумент ctx | каждая функция по пути |
| значения уровня запроса | WithValue | тот, кому нужна трассировка |
Первые три работают вместе и разбираются механизмами ниже. Четвёртое устроено
иначе и стоит отдельно — в разделе «Глубже»; смешивать их не стоит, потому что
Value не имеет отношения ни к отмене, ни к сроку.
И общая рамка, из которой выводится всё остальное: контекст — это дерево сигналов. Не хранилище и не средство прерывания.
Дерево, и связь односторонняя
Три контекста: корень и два потомка. Нажмите «отменить A» и посмотрите, что не изменится:
Прогон bench/gocontext/internals.go печатает то же самое:
отменили A: A=context canceled B=nil корень=nil
отменили корень: A=context canceled B=context canceled корень=context canceled
Первая строка — вся суть. Отмена потомка не трогает ни родителя, ни соседа. Вторая — обратное направление: отмена родителя доходит до всех.
Отсюда сразу два практических вывода:
- Обработчик может отменить свою подзадачу, не роняя весь запрос: сделал
ctx, cancel := context.WithCancel(parent), отменил — родитель не заметил. - Отменить чужую работу можно, только держа её контекст. Поэтому его и передают явно.
Do not store Contexts inside a struct type; instead, pass a Context explicitly
to each function that needs it. The Context should be the first parameter,
typically named ctx.
Не храните контексты внутри структурного типа; вместо этого передавайте контекст явно в каждую функцию, которой он нужен. Контекст должен быть первым параметром, обычно называемым ctx.
Причина не в стиле. Контекст у каждого вызова свой: у одного запроса — пятисекундный дедлайн, у другого — секундный, третий уже отменён. Поле структуры хранит один контекст на все вызовы, и первый же параллельный запрос получит чужой дедлайн.
Механизм 2: cancel — про освобождение, а не про отмену
Самый частый вопрос на понимание: зачем defer cancel(), если операция и так
завершилась успешно?
Документация отвечает прямо:
Failing to call the CancelFunc leaks the child and its children until the parent
is canceled or the timer fires.
Невызов CancelFunc приводит к утечке потомка и его потомков до тех пор, пока не будет отменён родитель или не сработает таймер.
Слово «утечка» стоит понимать буквально, и замер показывает, чего именно. Десять тысяч потомков долгоживущего родителя:
| осталось в куче на потомка | |
|---|---|
без cancel | 123 байта |
с cancel | 0 |
И это не про горутины. Первая редакция замера считала
runtime.NumGoroutine() и получила ноль прибавившихся горутин в обоих случаях:
WithTimeout заводит таймер рантайма, а не горутину. Утверждение «забытый
cancel течёт горутинами» замер не подтвердил.
Течёт другое: пока cancel не вызван, потомок остаётся в списке потомков
родителя, и ни он сам, ни то, на что он ссылается, собрано быть не может.
Родитель в реальном коде живёт долго — это контекст сервиса или соединения, —
поэтому список растёт вместе с числом забытых cancel.
Отсюда формулировка, которую и хотят услышать: cancel не «отменяет
результат», а вычёркивает потомка из списка. Поэтому он безопасен после
успеха и поэтому его пишут через defer сразу после создания контекста.
go vet это ловит проверкой lostcancel: она требует, чтобы функция отмены,
возвращённая из context.WithCancel, WithTimeout и WithDeadline, была
вызвана, — иначе новый контекст остаётся живым, пока не отменят родителя.
Механизм 3: срок — это тоже отмена, но по другой причине
WithTimeout(ctx, d) — это ровно WithDeadline(ctx, time.Now().Add(d)), а
дедлайн — это отмена, которую назначили заранее. На шкале видно, что общего и
что разного:
Слева и справа происходит одно и то же — закрывается Done(). Отличается
только Err(), и вот на этом отличии ломается обработка таймаутов.
Ещё одно свойство шкалы, которое спрашивают: дедлайн потомка не может быть
позже родительского. Рантайм возьмёт меньший из двух. Поэтому цепочка
WithTimeout на каждом уровне не продлевает срок, а только сокращает его, — и
это ровно то поведение, которого от распространения и ждут.
Canceled и DeadlineExceeded — разные ошибки
Вопрос выглядит формальным, а ломается на нём обработка таймаутов.
If Done is closed, Err returns a non-nil error explaining why: DeadlineExceeded
if the context's deadline passed, or Canceled if the context was canceled for
some other reason.
Если Done закрыт, Err возвращает ненулевую ошибку с объяснением причины: DeadlineExceeded, если истёк дедлайн контекста, или Canceled, если контекст был отменён по какой-то другой причине.
Прогон подтверждает: после таймаута errors.Is(err, context.DeadlineExceeded)
даёт true, а errors.Is(err, context.Canceled) — false.
Почему это важно на практике. Две ситуации требуют разного поведения:
- Клиент ушёл (
Canceled) — работать дальше незачем, отвечать некому, логировать как ошибку не надо. - Не уложились в срок (
DeadlineExceeded) — это уже симптом: либо зависимость медленная, либо дедлайн занижен. Такое пишут в метрики и в алерты.
Код, проверяющий только Canceled, сваливает оба случая в один — и в графиках
пропадает именно то, ради чего дедлайны и ставят.
Отдельно про Background и TODO. Оба — пустые контексты, и Done() у них
равен nil (чтение из nil-канала блокируется навсегда, поэтому такой контекст
никогда не «сработает»). Разница между ними чисто сигнальная: Background —
корень в main и в тестах, TODO — пометка «здесь потом будет настоящий
контекст». Инструменты статического анализа умеют искать TODO.
Механизм 4: контекст ничего не прерывает
Самое частое заблуждение темы, и его стоит уметь сформулировать.
Отмена контекста — это сигнал, а не прерывание. ctx.Done() закрывается,
и всё. Горутина, которая его не читает, продолжит работать как ни в чём не
бывало. Никакого механизма «убить операцию» в языке нет — как нет и способа
убить горутину.
Отсюда правило: контекст работает только там, где его читают. Три места, где это происходит:
// 1. Явная проверка в цикле.
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(item)
}
// 2. Ожидание с отменой.
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return nil, ctx.Err()
}
// 3. Библиотека, которая принимает контекст сама.
rows, err := db.QueryContext(ctx, query)
resp, err := client.Do(req.WithContext(ctx))Третий пункт — самый важный на практике: если библиотека не принимает контекст, отменить её вызов нечем. Обёртка вида «запустить в горутине и вернуться по таймауту» отменяет только ожидание, а сама операция продолжает работать и держать ресурсы. Это законный приём, но называть его отменой нельзя, и на собеседовании эту разницу проверяют.
Механизм 5: как это выглядит на сквозном пути запроса
Всё, что разобрано выше, стоит один раз увидеть целиком — на пути, который проходит настоящий запрос:
Три сценария на этом пути, и каждый спрашивают.
Клиент отвалился. http.Server закрывает Done() контекста запроса;
сигнал доходит вниз до QueryContext, и тот прерывает запрос в базе. Ошибка
у обработчика — Canceled. Отвечать некому, логировать как сбой не надо.
Не уложились в две секунды. Сработал таймер обработчика, Done() закрыт,
Err() — DeadlineExceeded. Это симптом: либо база медленная, либо срок
занижен. Такое пишут в метрики.
Обработчик закончил раньше срока. defer cancel() вычёркивает потомка из
списка родителя. Ничего не «отменяется» — освобождается место, и именно поэтому
вызов обязателен и при успехе.
И главное, что видно только на целой картинке: контекст никто не «применяет»
— его передают. Работу прерывает не он, а QueryContext, который его читает.
Уберите из этой цепочки одно звено, передающее ctx дальше, — и всё, что ниже,
перестанет отменяться, хотя код будет выглядеть правильным.
Глубже: Value — дёшево класть, дорого доставать
Правило из документации звучит строже, чем его обычно пересказывают:
Use context Values only for request-scoped data that transits processes and
APIs, not for passing optional parameters to functions.
Используйте значения контекста только для данных уровня запроса, проходящих через процессы и интерфейсы, а не для передачи необязательных параметров функциям.
Два условия сразу: данные уровня запроса и проходящие через границы. Идентификатор трассировки, данные аутентификации, идентификатор арендатора — да. Настройки функции, зависимости, конфигурация — нет: это параметры, и они должны быть видны в сигнатуре.
И вторая половина, про цену. Класть дёшево — одно выделение. Доставать
дороже, и цена растёт с глубиной, потому что Value идёт вверх по цепочке
родителей:
| слоёв над значением | Value | |
|---|---|---|
| 0 | 6,13 нс | ×1,0 |
| 5 | 30,30 | ×4,9 |
| 10 | 51,80 | ×8,4 |
| 20 | 110,58 | ×18,0 |
| поле структуры | 2,21 | для сравнения |
Последняя строка — мера того, насколько это не замена полю. Практический вывод:
значения из контекста достают один раз на входе в обработчик, а дальше
передают явно. Читать ctx.Value в горячем цикле — это платить восемнадцать раз
там, где хватило бы одного.
И тут стоит удержаться от обратного перегиба. Восемнадцать — не свойство
Value, а свойство двадцати слоёв: столько же слоёв над значением надо ещё
суметь набрать. Правило пишется не от кратности, а от её причины: раз поиск
идёт вверх по цепочке, время чтения зависит от глубины — значит, читают на
входе, где глубина известна и мала, а не в цикле, где она какая получится.
И ещё одно, про типобезопасность: ключ должен быть своим неэкспортируемым типом, а не строкой.
type ctxKey string
const userKey ctxKey = "user"Со строковым ключом два независимых пакета могут случайно взять одно и то же имя и молча перезаписать друг друга. Со своим типом это невозможно: типы разные, даже если строки совпадают.
Как отвечать на собеседовании
Короткий ответ: контекст переносит по дереву вызовов отмену, срок и значения,
привязанные к запросу — чтобы работу, которая больше никому не нужна, можно
было остановить, а не доводить до конца. Отмена при этом сигнал, а не
прерывание: она действует только там, где Done() читают. И связь в дереве
односторонняя — вниз отмена идёт, вверх и вбок нет.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «зачем контекст» отвечайте тремя словами и сразу четвёртым. «Отмена, дедлайн, значения уровня запроса — и всё это дерево с односторонней связью».
Про первый аргумент называйте причину, а не правило. «У каждого вызова свой дедлайн; поле структуры хранит один контекст на все вызовы, и параллельный запрос получит чужой».
Про cancel скажите «освобождает, а не отменяет». «Он вычёркивает потомка
из списка потомков родителя; без него потомок висит там, пока жив родитель — у
меня выходило 123 байта на потомка».
Про Canceled и DeadlineExceeded назовите разное поведение. «Клиент ушёл
— работать незачем; не уложились в срок — это симптом, его пишут в метрики. Код,
проверяющий только Canceled, сваливает их в одно».
Про отмену скажите, что это сигнал. «Контекст ничего не прерывает: горутина,
которая не читает Done(), продолжит работать. Если библиотека не принимает
контекст, отменить её вызов нечем».
Про Value назовите оба условия и цену. «Данные уровня запроса, проходящие
через границы; ключ — свой неэкспортируемый тип. И доставать дорого: на двадцати
слоях в восемнадцать раз дороже, чем на одном, поэтому достают один раз на
входе».
Дальше спросят
Как дописать данные, когда запрос уже отменён?
Для этого в Go 1.21 появился context.WithoutCancel: он возвращает копию
контекста, которая не отменяется вместе с исходным, но сохраняет его
значения. Типичный случай — записать метрику или аудит-лог после того, как
клиент отвалился.
Важно не перепутать это с «игнорировать отмену». Значения (трассировка,
идентификатор арендатора) сохраняются, а дедлайн — нет, поэтому такой операции
обычно ставят свой, короткий: context.WithTimeout(context.WithoutCancel(ctx), time.Second).
Чем WithTimeout отличается от WithDeadline?
Ничем, кроме формы записи: WithTimeout(ctx, d) — это ровно
WithDeadline(ctx, time.Now().Add(d)). Оба возвращают контекст, который
отменится сам, и оба требуют cancel.
Разница в том, что удобнее выражать. WithDeadline естественнее, когда срок
задан снаружи и его надо пронести через несколько вызовов: дедлайн у
потомка не может быть позже родительского — рантайм возьмёт меньший. Поэтому
цепочка WithTimeout на каждом уровне не «продлевает» срок, а только сокращает
его.
Можно ли передавать nil вместо контекста?
Нельзя: документация говорит про это прямо, а на практике nil-контекст даст
панику при первом же обращении к Done() или Value(). Если контекста ещё нет
— это context.TODO().
Разница между TODO и Background не техническая: оба пустые. TODO — это
пометка для человека и для линтера: «здесь должен быть настоящий контекст, но
пока непонятно, откуда его взять». В готовом коде TODO быть не должно.
Безопасен ли контекст для нескольких горутин?
Да, и это прямо сказано в блоге Go: контексты безопасны для одновременного
использования несколькими горутинами. Причина в том, что контекст неизменяем
— WithValue и WithCancel не меняют существующий, а создают новый узел
дерева.
Отсюда, кстати, и происходит цена Value: раз узлы неизменяемы, поиск идёт
вверх по цепочке, а не по одной карте.
Что делает context.AfterFunc?
Регистрирует функцию, которая выполнится при отмене контекста, — появился в Go
1.21. До него ту же задачу решали горутиной с select { case <-ctx.Done(): ... },
и это стоило горутины на каждое ожидание.
Практический случай: закрыть соединение или освободить ресурс, когда запрос
отменён, не заводя ради этого отдельную горутину. Возвращает функцию отмены
регистрации — её тоже надо вызывать, иначе получается та же утечка, что и с
забытым cancel.
Контекст в структуре — совсем нельзя?
Правило есть, и оно однозначно, но у него есть одно признанное исключение: структура, которая сама представляет одну операцию с собственным сроком жизни, — например, запрос в очередь или задание воркера. Там контекст — часть состояния этой операции, а не спрятанный параметр.
Признак, по которому отличают допустимый случай от недопустимого: если у структуры несколько методов и они вызываются в разное время разными вызывающими, контекст в поле — ошибка. Если структура живёт ровно столько, сколько операция, — это ещё один способ записать тот же самый «первый аргумент».
Частые заблуждения
отмена контекста прерывает выполнение
Отмена — это сигнал, а не прерывание: Done() закрывается, и всё. Горутина, которая его не читает, продолжит работать как ни в чём не бывало. Если библиотека не принимает контекст, отменить её вызов нечем — обёртка «запустить в горутине и уйти по таймауту» отменяет только ожидание.
cancel нужен только чтобы отменить работу
Он вычёркивает потомка из списка потомков родителя, поэтому вызывается всегда — и при успехе тоже. Замерено: десять тысяч потомков без cancel оставляют 123 байта на каждого, с cancel — 0. Отсюда идиома defer cancel() сразу после создания.
забытый cancel течёт горутинами
Замер этого не подтверждает: число горутин не изменилось ни в одном случае — WithTimeout заводит таймер рантайма, а не горутину. Течёт память: потомок остаётся в списке родителя, и всё, на что он ссылается, удерживается вместе с ним.
context.Canceled покрывает и таймаут
Нет: после таймаута errors.Is(err, context.Canceled) даёт false, а DeadlineExceeded — true. Это разные случаи и разное поведение: клиент ушёл — работать незачем; не уложились в срок — это симптом для метрик.
отмена потомка отменит и родителя
Связь односторонняя: прогон печатает, что после отмены потомка у родителя и у соседа Err() остался nil. Вниз отмена идёт, вверх и вбок — нет. Именно поэтому подзадачу можно отменить, не роняя весь запрос.
контекст можно хранить в структуре, так удобнее
У каждого вызова свой дедлайн и своя отмена, а поле структуры хранит один контекст на все вызовы — первый же параллельный запрос получит чужой. Исключение одно: структура, которая сама представляет одну операцию со своим сроком жизни.
в context.Value удобно класть что угодно
Документация требует двух условий сразу: данные уровня запроса и проходящие через границы процессов и интерфейсов. Зависимости и настройки — это параметры, и они должны быть видны в сигнатуре. А ключ обязан быть своим неэкспортируемым типом: со строковым два пакета молча перезапишут друг друга.
Value дешёвый, можно читать в цикле
Дёшево класть — одно выделение. Доставать дорого и тем дороже, чем глубже: 6,13 нс на нулевой глубине против 110,58 на двадцати слоях, при 2,21 нс у обычного поля структуры. Значения достают один раз на входе в обработчик и дальше передают явно.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
<-child.Done() fmt.Println(errors.Is(child.Err(), context.Canceled)) _, cancelC2 := context.WithCancel(p2) cancelC2() fmt.Println(p2.Err() == nil) <-tctx.Done() fmt.Println(errors.Is(tctx.Err(), context.DeadlineExceeded)) fmt.Println(errors.Is(tctx.Err(), context.Canceled)) fmt.Println(withUser.Value(userKey)) fmt.Println(base.Value(userKey))
Практика · оцените
Проверка знаний
Горутина выполняет длинный цикл и не читает ctx.Done(). Контекст отменили. Что произойдёт?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Контекст решает одну задачу: работа, которая уже никому не нужна, о своей ненужности не знает. Клиент ушёл или срок истёк, а запрос в базе всё ещё выполняется и держит ресурсы. Контекст переносит по дереву вызовов три вещи — отмену, срок и значения, привязанные к запросу, — чтобы эта новость дошла до самого низа цепочки. Сам он при этом ничего не прерывает: отмена — сигнал, и действует она только там, где её читают.
- Отсюда то, что удивляет. Связь в дереве односторонняя: отмена родителя доходит до всех потомков, отмена потомка не трогает ни родителя, ни соседа — проверено прогоном.
cancel— не про отмену результата, а про освобождение: его вызывают всегда, даже при успехе, иначе потомок остаётся в списке родителя. Замерено: десять тысяч потомков безcancelоставляют 123 байта на каждого, сcancel— 0. ИCanceledсDeadlineExceeded— разные ошибки: после таймаутаerrors.Is(err, context.Canceled)даёт false, а код, проверяющий толькоCanceled, не отличит ушедшего клиента от собственного просроченного дедлайна. - Дальше — числа, устройство и границы. Контекст передают первым аргументом, а не хранят в структуре: у каждого вызова свой срок. В
Valueкладут данные уровня запроса, проходящие через границы пакетов, — трассировку, дедлайн, данные аутентификации, но не параметры функции. Класть дёшево (одно выделение), а доставать дорого: поиск идёт вверх по цепочке родителей, и на двадцати слоях это в 18 раз дороже, чем на нуле, при 2,21 нс у поля структуры. Кратность верна для этого прогона на go1.24.7; правило из неё — доставать один раз на входе, а не помнить число. Иcontext.Background()никогда не отменяется, егоDone()равенnil.
На самом деле
- Отмена — это сигнал, а не прерывание:
Done()закрывается, и всё. Горутина, которая его не читает, продолжит работать как ни в чём не бывало. Если библиотека не принимает контекст, отменить её вызов нечем — обёртка «запустить в горутине и уйти по таймауту» отменяет только ожидание. - Он вычёркивает потомка из списка потомков родителя, поэтому вызывается всегда — и при успехе тоже. Замерено: десять тысяч потомков без cancel оставляют 123 байта на каждого, с cancel — 0. Отсюда идиома
defer cancel()сразу после создания. - Замер этого не подтверждает: число горутин не изменилось ни в одном случае —
WithTimeoutзаводит таймер рантайма, а не горутину. Течёт память: потомок остаётся в списке родителя, и всё, на что он ссылается, удерживается вместе с ним. - Нет: после таймаута
errors.Is(err, context.Canceled)даёт false, аDeadlineExceeded— true. Это разные случаи и разное поведение: клиент ушёл — работать незачем; не уложились в срок — это симптом для метрик. - Связь односторонняя: прогон печатает, что после отмены потомка у родителя и у соседа
Err()остался nil. Вниз отмена идёт, вверх и вбок — нет. Именно поэтому подзадачу можно отменить, не роняя весь запрос. - У каждого вызова свой дедлайн и своя отмена, а поле структуры хранит один контекст на все вызовы — первый же параллельный запрос получит чужой. Исключение одно: структура, которая сама представляет одну операцию со своим сроком жизни.
- Документация требует двух условий сразу: данные уровня запроса и проходящие через границы процессов и интерфейсов. Зависимости и настройки — это параметры, и они должны быть видны в сигнатуре. А ключ обязан быть своим неэкспортируемым типом: со строковым два пакета молча перезапишут друг друга.
- Дёшево класть — одно выделение. Доставать дорого и тем дороже, чем глубже: 6,13 нс на нулевой глубине против 110,58 на двадцати слоях, при 2,21 нс у обычного поля структуры. Значения достают один раз на входе в обработчик и дальше передают явно.
Что разобрано
- Что здесь на самом деле спрашивают
- База: зачем работе знать, что запрос уже не нужен
- Механизм 1: четыре назначения, которые путают
- Механизм 2: cancel — про освобождение, а не про отмену
- Механизм 3: срок — это тоже отмена, но по другой причине
- Механизм 4: контекст ничего не прерывает
- Механизм 5: как это выглядит на сквозном пути запроса
- Глубже: Value — дёшево класть, дорого доставать
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Пакет contextОфициальная документация. Правила, которые чаще всего цитируют неточно. Про место контекста: «Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx» (Не храните контексты внутри структурного типа; вместо этого передавайте контекст явно в каждую функцию, которой он нужен. Контекст должен быть первым параметром, обычно называемым ctx). Про cancel: «Failing to call the CancelFunc leaks the child and its children until the parent is canceled or the timer fires» (Невызов CancelFunc приводит к утечке потомка и его потомков до тех пор, пока не будет отменён родитель или не сработает таймер). Про Value: «Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions» (Используйте значения контекста только для данных уровня запроса, проходящих через процессы и интерфейсы, а не для передачи необязательных параметров функциям).https://pkg.go.dev/context
- Пакет context — Err, Canceled, DeadlineExceededОфициальная документация. Разница, из-за которой ломается обработка таймаутов: «If Done is not yet closed, Err returns nil. If Done is closed, Err returns a non-nil error explaining why: DeadlineExceeded if the context's deadline passed, or Canceled if the context was canceled for some other reason» (Если Done ещё не закрыт, Err возвращает nil. Если Done закрыт, Err возвращает ненулевую ошибку с объяснением причины: DeadlineExceeded, если истёк дедлайн контекста, или Canceled, если контекст был отменён по какой-то другой причине). И про Background: «Background returns a non-nil, empty Context. It is never canceled, has no values, and has no deadline» (Background возвращает ненулевой пустой контекст. Он никогда не отменяется, не содержит значений и не имеет дедлайна).https://pkg.go.dev/context#Context
- Go Concurrency Patterns: Context — блог GoОфициальная документация. Исходное обоснование модели: «At Google, we require that Go programmers pass a Context parameter as the first argument to every function on the call path between incoming and outgoing requests» (В Google мы требуем, чтобы программисты на Go передавали параметр Context первым аргументом каждой функции на пути вызовов между входящим и исходящим запросами). Там же про смысл отмены: «Contexts are safe for simultaneous use by multiple goroutines» (Контексты безопасны для одновременного использования несколькими горутинами).https://go.dev/blog/context
- Заметки к выпуску Go 1.21 — context.WithoutCancel и AfterFuncОфициальная документация. Дополнения, о которых спрашивают на позициях повыше: «WithoutCancel returns a copy of a context that is not canceled when the original is canceled» (WithoutCancel возвращает копию контекста, которая не отменяется при отмене исходного) и «AfterFunc registers a function to run when a context is canceled» (AfterFunc регистрирует функцию, которая будет выполнена при отмене контекста). Первое закрывает частый практический вопрос: как дописать данные в базу, когда запрос уже отменён.https://go.dev/doc/go1.21