1. Контекст как «дерево операций»
После того как вы пару раз поймали зависшую горутину или «вечный» запрос к сети, появляется естественное желание: «Хочу красную кнопку “СТОП”». В Go эта кнопка почти всегда выглядит как context.Context. Важно понимать, что контексты не «плоские»: они образуют дерево, где дочерний контекст наследует отмену и дедлайн родителя. То есть если сверху «операцию закрыли», снизу она тоже должна закрыться. Это не магия — это дисциплина, которую стандартная библиотека делает удобной.
Представьте это так: пользовательский запрос (в CLI-команде или HTTP-handler’е) — это корень операции. Внутри вы делаете подоперации: чтение из хранилища, поход в сеть, импорт файла. Каждая подоперация должна жить не «сама по себе», а внутри времени жизни запроса. Именно поэтому мы почти всегда «растим» новый контекст от входного, а не начинаем новый мир с Background().
Наглядная схема:
flowchart TD
A[Корневой ctx операции] --> B[Подоперация: storage]
A --> C[Подоперация: http client]
C --> D[Подоперация: json decode]
A --> E[Подоперация: параллельные шаги]
И вот тут на сцену выходят три героя: WithCancel, WithTimeout, WithDeadline. Они все создают производный контекст от родителя.
2. WithCancel, WithTimeout, WithDeadline: когда какой
WithCancel: ручная отмена, когда вы точно знаете момент «хватит»
Когда вы пишете программу, иногда хочется отменить работу не потому, что «время вышло», а потому что логика решила остановиться. Например, вы нашли нужную задачу в кеше и больше не хотите читать диск. Или одна из параллельных попыток уже дала результат, и остальные можно гасить. Для такого сценария существует context.WithCancel(parent) — он возвращает новый ctx и функцию cancel(), которую вы вызываете сами.
Ключевая идея: cancel() закрывает канал ctx.Done(), и всё, что «уважает контекст», должно это заметить и закончиться. Это стандартный паттерн: один компонент подаёт сигнал остановки, остальные корректно выходят.
Мини-пример «на пальцах»:
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
<-ctx.Done()
fmt.Println("reason:", ctx.Err()) // reason: context canceled
}
Здесь важно увидеть две вещи. Во-первых, Done() — это именно сигнал, по которому мы прекращаем работу. Во-вторых, причину прекращения мы читаем через Err() (а не гадаем по кофейной гуще).
В прикладном коде WithCancel чаще всего встречается там, где вы хотите преждевременно остановить подоперации: «уже достаточно», «нашли ответ», «пользователь отменил действие», «дальше не имеет смысла».
WithTimeout: ограничение «не дольше N секунд от текущего момента»
Если WithCancel — это «ручная кнопка», то WithTimeout — это «таймер на духовке»: по истечении времени всё выключается, даже если вы забыли. Вызов выглядит так: ctx, cancel := context.WithTimeout(parent, d). Он ставит дедлайн «сейчас + d». И как только время проходит, ctx.Done() закрывается, а ctx.Err() становится context.DeadlineExceeded.
Самое типичное место для WithTimeout — операции I/O: сеть, база данных, файловая система. То есть всё, что может «подвиснуть» по внешним причинам.
Мини-пример:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
<-ctx.Done()
fmt.Println("reason:", ctx.Err()) // reason: context deadline exceeded
}
Обратите внимание на defer cancel(). Да, таймаут и так «сработает». Но cancel() всё равно нужно вызывать как дисциплину освобождения ресурсов, потому что внутри контекста живут таймеры и связанная инфраструктура, и её лучше отпускать сразу, когда она больше не нужна.
Практический пример в нашем приложении задач
Представим, что у нас есть команда task list, которая ходит в хранилище. Мы хотим: если хранилище тормозит дольше 2 секунд — прекращаем и возвращаем понятную ошибку.
package main
import (
"context"
"time"
)
func ListTasks() error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return storageList(ctx) // допустим, функция уже существует в проекте
}
Тут мысль простая: таймаут задаёт верхний слой, потому что именно он отвечает за UX операции («сколько мы готовы ждать?»). Внутри storageList вы обязаны периодически проверять ctx.Done() или передавать ctx дальше в функции, которые умеют его уважать (например, драйвер БД, HTTP-клиент и т.д.).
WithDeadline: «не позже конкретного времени»
После WithTimeout возникает логичный вопрос: «А зачем тогда WithDeadline?» Затем, что иногда у нас есть не «продолжительность», а точка во времени, после которой операция не имеет смысла. Например: запрос должен быть завершён до определённого дедлайна (условно, до конца окна обработки), или вы синхронизируетесь с расписанием.
Технически: ctx, cancel := context.WithDeadline(parent, t), где t — это time.Time.
Мини-пример:
package main
import (
"context"
"fmt"
"time"
)
func main() {
deadline := time.Now().Add(200 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
<-ctx.Done()
fmt.Println("err:", ctx.Err()) // err: context deadline exceeded
}
Главный смысловой критерий выбора между WithTimeout и WithDeadline такой: если в требованиях звучит «не позже 18:00» — это дедлайн; если звучит «не дольше 2 секунд» — это таймаут.
3. Как читать контекст и выбирать ограничение
Done(), Err() и Deadline()
Когда вы только начинаете, очень хочется писать код в стиле «ну если Done() закрылся, значит всё плохо». Но контекст специально даёт вам информацию о причине, и лучше ей пользоваться. В Go-блогах про контекст это прямо подчёркивается: Done() — сигнал отмены, Err() — причина завершения, Deadline() — возможность узнать дедлайн и принять решение, стоит ли вообще начинать работу.
Сведём это в маленькую таблицу, чтобы мозгу было легче:
| Метод | Что даёт | Когда полезен |
|---|---|---|
|
канал-сигнал завершения | в select, чтобы “уметь остановиться” |
|
nil или причина (Canceled / DeadlineExceeded) | чтобы различать «отменили руками» и «время вышло» |
|
(time.Time, bool) | чтобы понять, есть ли дедлайн и сколько времени осталось |
Мини-пример проверки дедлайна «есть ли вообще смысл начинать»:
package main
import (
"context"
"time"
)
func ShouldStart(ctx context.Context) bool {
d, ok := ctx.Deadline()
if !ok {
return true
}
return time.Until(d) > 50*time.Millisecond
}
Это выглядит почти смешно, но на практике спасает от бессмысленной работы: если осталось 5 миллисекунд, а операция обычно занимает 200 — лучше сразу вернуть ctx.Err() и не создавать лишнюю нагрузку.
Короткий «навигатор выбора»
В реальном проекте самая частая проблема не «как написать WithTimeout», а «куда его поставить, чтобы не получилось три таймаута один внутри другого, и всё “иногда” отваливается». Поэтому держим в голове очень приземлённую логику.
WithCancel уместен там, где у вас есть логическое событие отмены, не связанное со временем: нашли результат, пользователь передумал, получили первую ошибку и гасим остальное (аккуратно, без фанатизма). Контекст в Go безопасен для использования несколькими горутинами, то есть один ctx реально может быть общим «рубильником» для группы работ.
WithTimeout ставится там, где вы хотите ограничить длительность: обычно это граница операции (CLI-команда, HTTP-handler) или конкретный «опасный» вызов к внешнему ресурсу.
WithDeadline ставится там, где дедлайн задан в абсолютном времени: например, когда у вас есть заранее вычисленная точка завершения, и вы хотите, чтобы вложенные операции знали именно её, а не «сколько осталось по пути».
Если попытаться изобразить это как компактную схему выбора:
flowchart TD
A[Нужно ограничить работу] --> B{Ограничение по времени?}
B -->|нет| C[WithCancel]
B -->|да| D{Есть конкретное время?}
D -->|да| E[WithDeadline]
D -->|нет, нужна длительность| F[WithTimeout]
4. Почему defer cancel() — это реальная гигиена
На этом месте у новичков появляется ощущение: «Я же и так сделал WithTimeout, зачем ещё cancel()? Оно само умрёт». И вот здесь Go немного как спортзал: если делать правильно, больно меньше.
Дело в том, что производный контекст может держать внутренние ресурсы (таймеры, ссылки на родителя и прочее). Если вы завершили операцию раньше дедлайна, cancel() позволяет освободить эти ресурсы сразу, а не ждать, пока «прозвенит будильник». Это часть практики работы с производными контекстами.
Правильная привычка выглядит так: создали WithTimeout/WithDeadline — сразу написали defer cancel(). Даже если дальше в функции будет десять return и три ветки if — вы не забудете.
5. Паттерны для приложения задач: “оборачиваем” подоперации
Когда вы встраиваете контекст в прикладной код (например, в менеджер задач), важно не превращать всё в «контекстный суп»: ctx1, ctx2, ctx3, и каждый со своим таймаутом, а где-то ещё time.After для красоты. Начните с маленьких, понятных паттернов.
Паттерн: короткий таймаут на внешний вызов
Представим, что у вас есть функция LoadFromRemote(ctx) (например, синхронизация задач). На уровне сервиса вы хотите: общий запрос может жить до 2 секунд, но сетевой шаг — максимум 300 миллисекунд (потому что «подвисшие сети» — отдельный вид боли).
package main
import (
"context"
"time"
)
func Sync(ctx context.Context) error {
netCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
return loadFromRemote(netCtx)
}
Обратите внимание: дочерний контекст строится от входного ctx, а не от Background(). Это сохраняет дерево отмены: если пользовательская операция отменена, сетевой шаг тоже остановится.
Паттерн: ручная отмена, когда результат уже найден
Иногда в приложении задач есть «быстрый путь», например, поиск задачи сначала в памяти, потом в файле. Когда вы нашли задачу в памяти, вы хотите дать сигнал остальным подоперациям: «ребята, спасибо, расходимся».
package main
import (
"context"
)
func FindTask(ctx context.Context, id int) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if err := findInMemory(ctx, id); err == nil {
cancel()
return nil
}
return findInFile(ctx, id)
}
Это упрощённый пример (в реальности вы часто делаете это конкурентно), но смысл WithCancel передан честно: отмена — по решению логики.
6. Типичные ошибки
Ошибка №1: забыли вызвать cancel() у WithTimeout или WithDeadline.
Код часто работает и без этого, поэтому ошибка коварная: она не “падает”, а медленно накапливает технический долг в виде лишних ресурсов и трудноуловимых эффектов. Лечится механически: создали контекст — сразу пишите defer cancel() рядом, не откладывая на «чуть позже допишу».
Ошибка №2: внутри функции берут context.Background() вместо того, чтобы использовать входной ctx.
Так вы обрываете дерево отмены: родительская операция может завершиться (например, пользователь отменил запрос), а ваша подоперация продолжит жить своей жизнью, как кот, который не признал правила дома. Производные контексты должны строиться от родителя, чтобы отмена и дедлайны наследовались.
Ошибка №3: путают смысл WithTimeout и WithDeadline.
В результате появляются странные конструкции: “таймаут” считается через time.Until(deadline), или дедлайн вычисляется как time.Now().Add(d) в десяти местах. Если ограничение задано длительностью — берите WithTimeout. Если задано конкретным временем — WithDeadline. Это не про «как удобнее», а про читаемость намерения.
Ошибка №4: после <-ctx.Done() не смотрят ctx.Err() и теряют причину остановки.
Когда операция завершается, вам часто важно различить: это пользователь отменил (context.Canceled) или время вышло (context.DeadlineExceeded). Если вы это не различаете, вы начинаете печатать одинаковые сообщения и в UX получается «просто не получилось», без объяснения. А ещё сложнее отлаживать: вы не понимаете, кто “виноват” — логика отмены или слишком короткий таймаут.
Ошибка №5: ставят таймауты “наугад” и слишком короткие.
Пять миллисекунд на чтение файла иногда работает… пока ваш ноутбук не решит обновить антивирус, а SSD — не заняться философией. Таймаут должен быть осмысленным: исходите из реальных ожиданий (и лучше чуть с запасом), а если операция важная — различайте “мягкие” и “жёсткие” ограничения (например, короткий таймаут на сеть и более длинный на всю команду).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ