Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 10 из 25 ~18 мин +20 XP

Контекст в задачах: отмена и дедлайны

Cron context — тема, которую библиотека обходит: robfig/cron не передаёт контекст в задачи и не отменяет их на Stop, поэтому отмена и дедлайны — часть вашей задачи. Пока задача короткая, разницы не видно; как только в ночную синхронизацию попадает внешний API без таймаута, Stop повисает, деплой упирается в лимит оркестратора, а процесс прибивают посреди записи. Разберём, как устроить отмену: дедлайны на запуск, общий корень для graceful shutdown и честные точки отмены внутри долгих операций.

Содержание урока · 8 разделов

Разберём инцидент, который случается в каждой второй команде с планировщиком. Ночная задача синхронизирует каталог с внешним API: двести запросов, каждый по клиенту без таймаута, потому что «сеть же внутренняя». В три ночи API залипает, один запрос висит двадцать минут, задача не заканчивается. В семь утра деплой: SIGTERM, c.Stop() — а задачи-то работают, контекст от Stop закроется только после их завершения, то есть не закроется. Под упирается в grace period, оркестратор прибивает контейнер жёстко, транзакция обрывается на середине. Ни один уровень этой истории не спасает сам по себе: Stop не умеет отменять, горутину в Go нельзя убить извне, а клиент без таймаута будет ждать вечно. Спасает только то, что задача сама умеет останавливаться — и механизм для этого в Go один: контекст с отменой. Причём уметь останавливаться должны все участники: и HTTP-запрос, и запрос к базе, и воркеры, которые задача сама породила, — отмена по цепочке обрывается там, где кто-то о ней не знает.

Cron context: почему AddFunc его не передаёт#

Сигнатура задачи — func() или метод Run без параметров: планировщик не передаёт в неё ни время тика, ни контекст. Это осознанное решение, а не недоработка: у библиотеки нет мнения, сколько должна жить задача и кто вправе её отменять — у кого-то это дедлайн на весь запуск, у кого-то отмена по сигналу сервиса, у кого-то ничего отменять нельзя. Поэтому cron context распределяется так: планировщик запускает горутину и больше не вмешивается, а задача сама создаёт свой контекст — обычно context.WithTimeout от корня сервиса — и проводит его во все блокирующие операции. Следствие важно зафиксировать: Stop не отменяет задачи, а ждёт их, поэтому без собственных контекстов внутри дожим может длиться вечно. Отмена задачи go-кода — это всегда её собственная дисциплина: таймауты на операции, проверка Done в циклах, передача контекста в запросы и запросы к базе. Зато и свобода полная: задача сама решает, что для неё значит «успеть» — бюджет на весь запуск, лимит на попытку или количество пакетов за тик, — и библиотека не навязывает ни одного из вариантов.

Дедлайн на весь запуск задачи

GO
func (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.

Задача с корнем сервиса и таймаутом на запуск

GO
type 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. Логи здесь ведут двойную бухгалтерию: плановые отмены пишутся отдельным уровнем или полем, чтобы алерт не путал штатное сворачивание с настоящей ошибкой.

Откуда брать контекст в задаче#

ИсточникКак выглядитКогда выбирать
context.Backgroundcontext.WithTimeout(context.Background(), d)прототипы и утилиты; в сервисе — маркер проблемы
Поле задачи от корня сервисаcontext.WithTimeout(j.ctx, j.limit)задачи, которые обязаны сворачиваться при shutdown
Деривация на операциюcontext.WithTimeout(runCtx, 5*time.Second)шаги задачи: запросы, выборки, внешние вызовы

Соберите цикл с проверкой отмены

Впишите создание контекста с дедлайном, проверку канала отмены и получение причины отмены.

ctx, cancel := context.(j.ctx, 30*time.Second) defer cancel() for _, batch := range batches { select { case <-ctx.(): return ctx.() default: } j.process(ctx, batch) }

Соберите Run задачи с отменой

Расставьте строки в порядке выполнения: контекст с дедлайном, выборка, проверка ошибки, обработка и финальный лог.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: контекст в задачах

Термин

Кто даёт контекст

нажмите, чтобы перевернуть
Определение

Никто: AddFunc и AddJob передают функцию без параметров. Контекст задача создаёт сама — деривацией от корня сервиса или Background в утилитах.

1 / 6

Контекст — это договор задачи с сервисом о том, как прекращаться. Планировщик со своей стороны даёт три вещи: запуск без отмены, Stop с дожимом и свободу в устройстве отмены. Задача отвечает дедлайном на запуск, прокидыванием контекста во все блокирующие места и честным выходом по Done. Связка через корень сервиса превращает набор независимых таймеров в единый жизненный цикл, где shutdown предсказуем, а отмена — не авария, а штатный режим, предусмотренный с первого дня. Следующий шаг — перекрытие запусков: что делать, когда задача не уложилась даже в свой дедлайн. Дальше по маршруту: graceful shutdown — полная схема с сигналами, утечки горутин — что происходит с горутинами, чей контекст никто не передал. Если возвращались — Start и Stop дают базу этого урока. Полный маршрут — на странице курса. В домашнем задании — синхронизация, которая сворачивается по сигналу за фиксированный бюджет.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • AddFunc и AddJob контекст не передают: отмена и дедлайны — ответственность самой задачи.
  • Бюджет запуска выдаёт WithTimeout от корня сервиса; внутри — таймауты на отдельные шаги.
  • Отмена кооперативна: контекст прерывает только операции, в которые передан, горутины не убиваются.
  • Контекст идёт во все долгие места: NewRequestWithContext, QueryContext, Done в циклах, воркеры параметром.
  • Порядок shutdown: отменить корень, Stop, дожим по ctx.Done с дедлайном, затем закрытие зависимостей.
  • Обрыв посреди работы должен быть штатным: идемпотентность и чекпоинты переживают отмену.

Домашнее задание

Возьмите задачу синхронизации из урока и доведите её до shutdown-совместимости: корневой контекст в поле, дедлайн на запуск, контекст в выборке и в каждом пакете, чекпоинт последнего обработанного пакета в базе. Добавьте обработчик сигнала с порядком из урока и замерьте по логам, сколько занимает остановка при работающей задаче. Затем уберите прокидывание контекста в один вызов и найдите это место по зависшему дожиму — почувствуйте, как диагностируется забытый контекст. После верните всё на место и проверьте, что остановка снова укладывается в три секунды.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Контекст в задачах: отмена и дедлайны)

Вопросы и ответы по уроку

Почему нельзя просто убить горутину задачи?

В Go нет механизма убить горутину извне: она владеет своим стеком и могла держать блокировки и ресурсы, и насильственное прерывание оставило бы их в неопределённом состоянии. Единственный канал управления — кооперативная отмена через контекст: операция, в которую он передан, вернёт ошибку, а задача выйдет сама.

Задача отменена посреди транзакции в базу. Что с данными?

Ровно то, что вы спроектировали: транзакция без коммита откатится сама при закрытии соединения, зафиксированными останутся только коммиты до отмены. Поэтому batch-задачи строят идемпотентно — короткие транзакции на пакет, курсор обработанного обновляется после коммита, а следующий тик продолжает с чекпоинта.

Stop отменяет задачи или нет?

Нет: Stop прекращает запускать новые тики и возвращает контекст, который закроется, когда уже запущенные задачи завершатся сами. Отмена работающих задач — это отмена их контекстов, и связать её с остановкой сервиса можно порядком из урока: сначала cancel корня, затем Stop.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Slog25 уроков~9 чпрактика25 уроков уже доступны

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь