Разберём инцидент, который случается в каждой второй команде с планировщиком. Ночная задача синхронизирует каталог с внешним API: двести запросов, каждый по клиенту без таймаута, потому что «сеть же внутренняя». В три ночи API залипает, один запрос висит двадцать минут, задача не заканчивается. В семь утра деплой: SIGTERM, c.Stop() — а задачи-то работают, контекст от Stop закроется только после их завершения, то есть не закроется. Под упирается в grace period, оркестратор прибивает контейнер жёстко, транзакция обрывается на середине. Ни один уровень этой истории не спасает сам по себе: Stop не умеет отменять, горутину в Go нельзя убить извне, а клиент без таймаута будет ждать вечно. Спасает только то, что задача сама умеет останавливаться — и механизм для этого в Go один: контекст с отменой. Причём уметь останавливаться должны все участники: и HTTP-запрос, и запрос к базе, и воркеры, которые задача сама породила, — отмена по цепочке обрывается там, где кто-то о ней не знает.
Cron context: почему AddFunc его не передаёт#
Сигнатура задачи — func() или метод Run без параметров: планировщик не передаёт в неё ни время тика, ни контекст. Это осознанное решение, а не недоработка: у библиотеки нет мнения, сколько должна жить задача и кто вправе её отменять — у кого-то это дедлайн на весь запуск, у кого-то отмена по сигналу сервиса, у кого-то ничего отменять нельзя. Поэтому cron context распределяется так: планировщик запускает горутину и больше не вмешивается, а задача сама создаёт свой контекст — обычно context.WithTimeout от корня сервиса — и проводит его во все блокирующие операции. Следствие важно зафиксировать: Stop не отменяет задачи, а ждёт их, поэтому без собственных контекстов внутри дожим может длиться вечно. Отмена задачи go-кода — это всегда её собственная дисциплина: таймауты на операции, проверка Done в циклах, передача контекста в запросы и запросы к базе. Зато и свобода полная: задача сама решает, что для неё значит «успеть» — бюджет на весь запуск, лимит на попытку или количество пакетов за тик, — и библиотека не навязывает ни одного из вариантов.
Дедлайн на весь запуск задачи
GOfunc (j *SyncJob) Run() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, j.apiURL, nil)
resp, err := j.client.Do(req)
if err != nil {
j.log.Error("синхронизация: запрос", zap.Error(err))
return
}
defer resp.Body.Close()
j.process(resp.Body)
}Задача сама выдаёт себе бюджет: WithTimeout на пять минут на весь запуск. Контекст идёт в HTTP-запрос через NewRequestWithContext — transport перестанет ждать ответ, как только дедлайн истечёт, и Do вернёт ошибку про deadline exceeded. Позже в уроке заменяем context.Background на корень сервиса — сейчас фиксируем механику отмены.
Проверка: что происходит при отмене
Дедлайн контекста истёк, пока http-запрос из задачи ещё ждал ответа. Что произойдёт?
Общий корень: контекст в полях структуры#
Собственный Background в каждой задаче решает дедлайны, но не связывает задачи с жизнью сервиса: отмены при остановке до них не доезжают. Мост — корневой контекст сервиса, который задача получает в поле через конструктор, точно так же, как стор и логгер. Сервис создаёт корень context.WithCancel на старте, задачи деривируют от него свои таймауты, а обработчик SIGTERM при остановке делает cancel корня — и все задачи одновременно видят сигнал сворачиваться. Рабочий порядок shutdown получается такой: отменить корневой контекст, затем c.Stop() — новые тики прекратятся, а стартовавшие в промежутке мгновенно выйдут по уже отменённому контексту, — затем дожим по контексту от Stop. Так остановка cron превращается из лотереи в последовательность: сначала задачи узнали, что пора, потом планировщик перестал подкидывать новую работу, потом процесс дождался и закрыл соединения. Полную схему с сигналами собираем в уроке про graceful shutdown.
Задача с корнем сервиса и таймаутом на запуск
GOtype SyncJob struct {
ctx context.Context // корень сервиса, отменяется при shutdown
store *Store
client *http.Client
log *zap.Logger
limit time.Duration
}
func (j *SyncJob) Run() {
ctx, cancel := context.WithTimeout(j.ctx, j.limit)
defer cancel()
batches, err := j.store.LoadPending(ctx)
if err != nil {
j.log.Error("выборка", zap.Error(err))
return
}
for _, b := range batches {
if err := j.process(ctx, b); err != nil {
j.log.Warn("пакет пропущен", zap.Error(err))
}
}
}Поле ctx — корневой контекст сервиса: при отмене все деривации в задачах срабатывают одновременно. Run выдаёт себе таймаут на запуск от корня и проводит ctx дальше — в выборку из базы и обработку пакетов. Деривация через WithTimeout — правило гигиены: задача не знает, из чего собран корень, и добавляет свой бюджет поверх.
Проверка: порядок остановки
Задачи хранят корневой контекст сервиса. Какой порядок остановки рабочий?
Что напечатает задача?
Прочитайте код и выберите, что он выведет:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
select {
case <-time.After(3 * time.Second):
fmt.Println("работа сделана")
case <-ctx.Done():
fmt.Println("отмена:", ctx.Err())
}Отмена задачи: Done в циклах и *Context-методы#
Отмена работает там, куда контекст дотянули, поэтому задача должна прокидывать его во все долгие места. Сетевые вызовы — NewRequestWithContext и клиент с таймаутами на уровне транспорта. Запросы к базе — методы с суффиксом Context: QueryContext, ExecContext, BeginTx; без них отмена обрывается на границе пакета драйвера. Собственные циклы — select между работой и ctx.Done: после каждого пакета короткая проверка, не пора ли сворачивать. Горутины, которые задача сама запускает — воркеры, пайплайны — получают контекст параметром и выходят по Done, иначе отмена родителя не дойдёт до детей. Итоговая дисциплина для долгой задачи: контекст везде, ошибки отмены обрабатываются отдельно от бизнес-ошибок — это штатный выход, а не авария, — и лог с причиной. Механику горутин и каналов, на которых это стоит, вы знаете из урока про горутины и урока про сам контекст.
Дедлайн запуска против таймаутов шагов#
Два уровня лимитов путать нельзя. Таймаут на шаг — на один HTTP-запрос или один запрос к базе — не даёт одному месту съесть весь бюджет. Дедлайн на весь запуск — бюджет задачи: сколько всего ей позволено. Практичная схема: дедлайн от корня на запуск, внутри — свои таймауты на операции, деривированные от контекста запуска. Отдельный вопрос — что делать с работой, которую отмена оборвала посередине. Ответ зависит от природы задачи: batch-обработка должна переживать обрыв без последствий — пакеты, обработанные до отмены, зафиксированы, остальные подхватит следующий тик; для этого нужна идемпотентность и, в идеале, чекпоинты — курсор последнего обработанного пакета, который обновляется по ходу. Задача, которая падает от отмены в неконсистентном состоянии и не может стартовать заново, — это задача с ошибкой дизайна, а не планировщика: тик может прерваться и по другим причинам, не только по вашему cancel. Логи здесь ведут двойную бухгалтерию: плановые отмены пишутся отдельным уровнем или полем, чтобы алерт не путал штатное сворачивание с настоящей ошибкой.
Коварная ошибка выглядит безобидно: контекст для HTTP-запроса создаётся от context.Background прямо внутри задачи. Дедлайны работают, тесты зелёные — а при остановке сервиса задачи продолжают работать, потому что их контексты не связаны с корнем и отмена сервиса до них не доходит. Реальный кейс: деплой висел весь grace period, задачи дописывали свои двадцать минут, оркестратор прибил под жёстко. Диагноз ставится поиском: context.Background внутри задач — маркер того, что связка с жизненным циклом отсутствует. Правило: Background живёт в main при создании корня, дальше в задачах — только деривации.
Откуда брать контекст в задаче#
| Источник | Как выглядит | Когда выбирать |
|---|---|---|
| context.Background | context.WithTimeout(context.Background(), d) | прототипы и утилиты; в сервисе — маркер проблемы |
| Поле задачи от корня сервиса | context.WithTimeout(j.ctx, j.limit) | задачи, которые обязаны сворачиваться при shutdown |
| Деривация на операцию | context.WithTimeout(runCtx, 5*time.Second) | шаги задачи: запросы, выборки, внешние вызовы |
Соберите цикл с проверкой отмены
Впишите создание контекста с дедлайном, проверку канала отмены и получение причины отмены.
Соберите Run задачи с отменой
Расставьте строки в порядке выполнения: контекст с дедлайном, выборка, проверка ошибки, обработка и финальный лог.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: контекст в задачах
Пакета context в песочнице сайта нет — там работают fmt, strings, strconv, math, sort, errors и time, так что контекст и HTTP проверяются локально из модуля второго урока. Имитацию отмены можно собрать в песочнице на каналах: закрытый канал — это Done, таймер — time.After, select — то же самое; предикт этого урока, например, переносится туда дословно. Но настоящие дедлайны и связь с Stop гоняйте в локальном go run: разница проявляется в блокирующих вызовах, которых у песочницы нет.
Контекст — это договор задачи с сервисом о том, как прекращаться. Планировщик со своей стороны даёт три вещи: запуск без отмены, Stop с дожимом и свободу в устройстве отмены. Задача отвечает дедлайном на запуск, прокидыванием контекста во все блокирующие места и честным выходом по Done. Связка через корень сервиса превращает набор независимых таймеров в единый жизненный цикл, где shutdown предсказуем, а отмена — не авария, а штатный режим, предусмотренный с первого дня. Следующий шаг — перекрытие запусков: что делать, когда задача не уложилась даже в свой дедлайн. Дальше по маршруту: graceful shutdown — полная схема с сигналами, утечки горутин — что происходит с горутинами, чей контекст никто не передал. Если возвращались — Start и Stop дают базу этого урока. Полный маршрут — на странице курса. В домашнем задании — синхронизация, которая сворачивается по сигналу за фиксированный бюджет.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- AddFunc и AddJob контекст не передают: отмена и дедлайны — ответственность самой задачи.
- Бюджет запуска выдаёт WithTimeout от корня сервиса; внутри — таймауты на отдельные шаги.
- Отмена кооперативна: контекст прерывает только операции, в которые передан, горутины не убиваются.
- Контекст идёт во все долгие места: NewRequestWithContext, QueryContext, Done в циклах, воркеры параметром.
- Порядок shutdown: отменить корень, Stop, дожим по ctx.Done с дедлайном, затем закрытие зависимостей.
- Обрыв посреди работы должен быть штатным: идемпотентность и чекпоинты переживают отмену.
Домашнее задание
Возьмите задачу синхронизации из урока и доведите её до shutdown-совместимости: корневой контекст в поле, дедлайн на запуск, контекст в выборке и в каждом пакете, чекпоинт последнего обработанного пакета в базе. Добавьте обработчик сигнала с порядком из урока и замерьте по логам, сколько занимает остановка при работающей задаче. Затем уберите прокидывание контекста в один вызов и найдите это место по зависшему дожиму — почувствуйте, как диагностируется забытый контекст. После верните всё на место и проверьте, что остановка снова укладывается в три секунды.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя просто убить горутину задачи?
В Go нет механизма убить горутину извне: она владеет своим стеком и могла держать блокировки и ресурсы, и насильственное прерывание оставило бы их в неопределённом состоянии. Единственный канал управления — кооперативная отмена через контекст: операция, в которую он передан, вернёт ошибку, а задача выйдет сама.
Задача отменена посреди транзакции в базу. Что с данными?
Ровно то, что вы спроектировали: транзакция без коммита откатится сама при закрытии соединения, зафиксированными останутся только коммиты до отмены. Поэтому batch-задачи строят идемпотентно — короткие транзакции на пакет, курсор обработанного обновляется после коммита, а следующий тик продолжает с чекпоинта.
Stop отменяет задачи или нет?
Нет: Stop прекращает запускать новые тики и возвращает контекст, который закроется, когда уже запущенные задачи завершатся сами. Отмена работающих задач — это отмена их контекстов, и связать её с остановкой сервиса можно порядком из урока: сначала cancel корня, затем Stop.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Долгие задачи: перекрытие запусков
- Горутины и конкурентность задач
- Инспекция расписания: Entries и Remove
- Хранение расписания: конфиг и база
- Start, Stop и жизненный цикл планировщика
- Секунды: cron.NewWithSeconds
- Часовые пояса: Timezone и локальное время
- AddJob: задачи-структуры и интерфейс Job
- Все курсы по Go
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь