MBL
Go / avito / Изучение / Context — максимально просто, с нуля
Go вводный

Context — максимально просто, с нуля

contextconcurrencybasics

Ты уже почти всё знаешь правильно — картина в целом такая: у контекста правда есть способ проверить, "закрылся" он или нет, правда можно его отменить вручную, с таймаутом или с конкретным дедлайном, и правда есть родительские/дочерние контексты. Один момент стоит поправить сразу, до всего остального: context — это не переменная, которую можно "прочитать" одним действием, а объект, который ты явно передаёшь из функции в функцию, и любой, у кого он есть, может независимо спросить "а не пора ли мне остановиться".

Зачем это вообще нужно — на пальцах

Представь: HTTP-хендлер получил запрос, вызвал сервис, тот вызвал репозиторий, репозиторий пошёл в базу данных. Четыре уровня вызовов. Пользователь закрыл вкладку браузера на середине ожидания ответа. Как об этом узнает репозиторий, который сейчас глубоко внутри и ждёт ответа от базы?

Без контекста — никак: репозиторий понятия не имеет, что происходит снаружи, и будет ждать базу данных до победного конца, даже если результат уже никому не нужен. Контекст решает это ровно так, как обычно решают такие задачи в программировании: явно передают "флажок отмены" внутрь, а не пытаются магически узнать снаружи. context.Context передаётся первым параметром в каждую функцию по цепочке — это просто соглашение в Go, но соблюдается почти всегда:

func handler(ctx context.Context) {
	service(ctx)
}

func service(ctx context.Context) {
	repository(ctx)
}

func repository(ctx context.Context) {
	// здесь мы можем проверить: а не отменили ли уже всё это выше?
}

ctx.Done() — не bool, а канал

Ты написал "проверить, закрылся контекст или нет, для этого у нас есть ctx.Done()" — это верно по сути, но Done() возвращает не true/false, а канал (<-chan struct{}). Канал закрывается в момент отмены — и чтение из закрытого канала не блокируется, а сразу отдаёт управление. Именно поэтому Done() почти всегда используют внутри select, а не в if:

на play.golang.org

select здесь ждёт ОДНО из двух: либо ctx.Done() закроется (отмена произошла), либо пройдёт секунда без отмены. Раз отмена случилась на 200мс — сработает первая ветка.

ctx.Err() — причина, доступная ПОСЛЕ отмены

Пока контекст живой (не отменён), ctx.Err() возвращает nil — спрашивать "а почему отменили" бессмысленно, если ещё ничего не произошло. После отмены Err() возвращает ОДНУ из двух конкретных ошибок, и по ней можно понять, что именно случилось:

  • context.Canceled — кто-то явно вызвал функцию cancel();
  • context.DeadlineExceeded — истекло время (таймаут или дедлайн), никто руками не отменял.

Три способа создать отменяемый контекст

Вот тут — ровно то, что ты уже описал, просто уточним детали. Все три функции принимают родительский контекст первым аргументом (обычно context.Background() — это "корень", контекст без родителя, с которого всё начинается) и возвращают новый, дочерний контекст плюс функцию cancel.

context.WithCancel(parent) — ты сам решаешь, КОГДА отменить, вызвав вернувшуюся функцию cancel() в любой момент. Ничего не отменяется автоматически.

context.WithTimeout(parent, duration) — отменится сам через duration от текущего момента (например, "через 3 секунды"), либо раньше, если ты сам вызовешь cancel().

context.WithDeadline(parent, time) — отменится сам в конкретный момент времени (например, "в 15:00:00 сегодня"), либо раньше, если вызвать cancel() вручную. По сути то же самое, что WithTimeout, только вместо "через сколько" ты называешь "когда именно" — WithTimeout внутри себя просто вызывает WithDeadline(parent, time.Now().Add(duration)).

на play.golang.org

Обрати внимание: даже у WithTimeout и WithDeadline, которые отменяются "сами", ВСЁ РАВНО нужно вызывать cancel() через defer — на всякий случай, если операция завершится успешно раньше таймаута. Без этого контекст продолжает висеть в памяти до истечения таймаута, даже когда уже никому не нужен, — маленькая, но реальная утечка ресурсов при частом вызове такого кода в цикле.

Родительские и дочерние контексты — дерево, не список

Ты правильно вспомнил, что есть контексты "выше" и "ниже". Точнее: каждый новый контекст (через любую из трёх функций выше) — это ребёнок того контекста, который передали первым аргументом. Так строится дерево. Правило дерева одно, но важное:

Отмена родителя автоматически отменяет ВСЕХ его потомков, на любую глубину. Отмена одного ребёнка НЕ трогает ни родителя, ни братские контексты того же уровня.

на play.golang.org

Практический смысл: если у HTTP-запроса один общий контекст на весь хендлер, а от него порождено пять дочерних контекстов на пять параллельных походов в разные сервисы — отмена ОДНОГО общего родительского контекста (например, клиент разорвал соединение) мгновенно останавливает все пять веток сразу, без ручной синхронизации между ними.

Итог одной строкой на каждый пункт

  • Context — объект, который передают явно, а не читают откуда-то магически.
  • Done() — канал, слушать через select, не через if.
  • Err() — nil до отмены, причина (Canceled или DeadlineExceeded) после.
  • WithCancel — отменяешь сам когда угодно; WithTimeout — сам отменится через N времени; WithDeadline — сам отменится в конкретный момент.
  • cancel() — всегда через defer, даже если контекст должен был отмениться сам.
  • Дерево: родитель отменяет всех детей вниз по дереву, ребёнок не трогает родителя и соседей.
Самопроверка 0 / 6
Понимаю, что context — это не переменная-флаг, а объект, который передают явно в каждую функцию
Знаю, что ctx.Done() — это канал, а не bool, и его "слушают" через select или range
Помню, что ctx.Err() возвращает nil ДО отмены и причину (Canceled/DeadlineExceeded) ПОСЛЕ
Могу назвать разницу между WithCancel (отменяю сам), WithTimeout (через N секунд) и WithDeadline (в конкретный момент времени)
Понимаю, что отмена родителя отменяет ВСЕХ его детей, а отмена ребёнка не трогает родителя
Знаю, что cancel() нужно вызывать через defer ВСЕГДА, даже если операция уже завершилась успешно сама
Как усвоено?