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

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

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

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

Start, Stop и жизненный цикл планировщика

Cron start stop — пара методов, на которой держится весь жизненный цикл планировщика: Start заводит тики в отдельной горутине, а Stop останавливает новые и возвращает контекст, который закроется после дожима текущих задач. Простота обманчива: из-за Start процессы умирают раньше первого тика, из-за Stop деплои обрывают задачи посреди записи в базу. В этом уроке пройдём весь цикл — регистрацию, запуск, добавление задач на живом планировщике, адресацию через Entry.ID и остановку, которая ничего не роняет.

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

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

Start асинхронен: вернул управление и живёт в горутине#

Метод Start запускает цикл планировщика в собственной горутине и немедленно возвращает управление — он не блокирует и не запускает задачи немедленно, даже если их момент уже наступил. Повторный вызов Start на работающем планировщике — безопасный no-op: случайно запустить второй цикл нельзя. Асинхронность порождает две классические ошибки. Первая — забыть Start вообще: задачи зарегистрированы, сервис живёт, тиков нет, потому что цикл планирования не заведён. Вторая тоньше и опаснее: main() после Start нечем задержать, функция возвращает управление, процесс завершается — и планировщик умирает вместе с ним, до первого тика. Поэтому после Start главный поток удерживают чем-то долгоживущим: select {} без веток, чтение канала сигналов или блокирующая работа HTTP-сервера. Если хочется блокирующего поведения сразу, есть метод Run: он делает то же, что Start, но в текущей горутине — удобно для программ без серверной части, неудобно, когда рядом нужно поднимать HTTP.

Start и удержание main

GO
func main() {
    c := cron.New()
    _, err := c.AddFunc("0 9 * * *", sendDigest)
    if err != nil {
        log.Fatal(err)
    }

    c.Start() // асинхронно: вернулась и живёт в своей горутине

    select {} // иначе main вернётся и процесс умрёт до первого тика
}

Start не блокирует: управление сразу возвращается в main, и без удержания процесс завершится, не дотянув ни до одного тика. select {} без веток держит главный поток вечно — грубый, но честный способ для утилит; в настоящих сервисах main обычно занят HTTP-сервером или ожиданием сигналов, что мы и разберём в уроке про graceful shutdown.

Проверка: почему тиков нет

Сервис зарегистрировал три задачи через AddFunc и работает, но тиков нет. Что забыли?

Остановка cron: Stop, контекст и дожим задач#

Метод Stop делает две вещи, и половина сервисов знает только первую. Первая — остановить цикл: новые тики вычисляться и запускаться перестают. Вторая — вернуть context.Context, который закрывается после того, как все уже запущенные задачи завершатся. Важно проговорить, чего Stop не делает: он не отменяет работающие задачи. Внутри задач нет отмены — планировщик не знает ни про контексты задач, ни про их HTTP-запросы; он просто ждёт, пока горутины закончат сами. Остановка cron, которая не роняет работу, выглядит так: получить контекст от Stop, дождаться его Done и лишь потом закрывать базу, соединения и выходить. Если задача может висеть неограниченно — дожим ограничивают дедлайном через select и time.After, а дальше решают по ситуации: логировать и уходить или принудительно завершать процесс. Полная схема с сигналами и приоритетами закрытия — в уроке про graceful shutdown. Отдельный нюанс для микросервисов: Stop стоит вызывать ровно из одного места — обработчика сигналов или ручки управления жизненным циклом. Два вызова из разных горутин безопасны технически, но дожим в двух местах превращает остановку в лотерею: кто закрыл соединения раньше — тот и определил судьбу задачи.

Остановка с дожимом и дедлайном

GO
// где-то в обработчике SIGTERM
ctx := c.Stop() // новые тики остановлены

select {
case <-ctx.Done():
    log.Println("задачи завершились штатно")
case <-time.After(10 * time.Second):
    log.Println("дедлайн дожима: задачи ещё работают")
}

Stop возвращает контекст, а не канал, поэтому дедлайн оформляется select с time.After. Первый закрытый канал завершает select: штатный дожим — по ctx.Done, зависшие задачи — по таймеру. Дедлайн обязателен в сервисах с внешними вызовами: без него один зависший HTTP-запрос задержит остановку пода до лимита оркестратора.

Проверка: что станет с работающей задачей

Ночная запись работает уже пять минут, вы вызвали c.Stop(). Что произойдёт с задачей?

В каком порядке пойдут строки?

Прочитайте код и выберите, что он выведет:

c := cron.New()
_, _ = c.AddFunc("* * * * *", func() {
    fmt.Println("тик")
})

fmt.Println("регистрация готова")
c.Start()
fmt.Println("старт вернулся")

Entry.ID: адрес задачи в планировщике#

Каждый вызов AddFunc и AddJob возвращает EntryID — монотонно растущий целочисленный идентификатор записи расписания. В простых сервисах его выбрасывают, но как только расписание становится динамическим, идентификатор превращается в адрес: по нему снимают задачу через Remove, находят через Entry, а списком получают через Entries. У записи есть и содержимое: Schedule — распарсенное расписание, Next — вычисленный момент следующего запуска, Prev — момент последнего, нулевой до первого тика. Это дешёвая диагностика: один лог со списком Entries при старте и по запросу отвечает на вопрос «что вообще у нас запланировано», не поднимая дебаггер. Полный разбор инспекции, снапшотов и паттернов замены задач по идентификатору — в уроке про Entries и Remove; здесь важно закрепить привычку сохранять идентификаторы там, где задачи регистрируются не один раз.

Идентификаторы: снять задачу по адресу

GO
ids := make(map[string]cron.EntryID)

id, err := c.AddFunc("@daily", cleanupOldFiles)
if err != nil {
    log.Fatal(err)
}
ids["cleanup"] = id

// позже: задача больше не нужна
c.Remove(ids["cleanup"])

Идентификатор хранится рядом с именем задачи — обычно в карте или в полях сервиса. Remove снимает запись немедленно, и её тики прекращаются; повторный Remove по тому же идентификатору безопасен, задача просто не найдётся. Держать идентификаторы стоит даже в статическом расписании: они стоят ноль строк и дают возможность вытащить задачу без рестарта.

Жизненный цикл: добавление задач до и после Start#

Регистрация задач работает в обоих режимах. Задачи, добавленные до Start, стартуют вместе с планировщиком; добавленные на работающем — подхватываются без рестарта: цикл пересчитывает расписание и включит новую задачу в ближайший расчёт моментов. Это делает AddFunc безопасным из хендлеров и админских команд, но вспомните из уроков про состояние: регистрация из нескольких горутин должна идти под защитой, если вы ведёте учёт идентификаторов в общей карте. Отдельный плюс поздней регистрации — выкатка фичи флагом: задача регистрируется, только если флаг включён, и выключение флага при следующем старте не оставляет мёртвых записей в расписании. Перезапуска как метода в v3 нет: поменять зону или обёртки на живом планировщике нельзя — эти параметры фиксируются при создании. Схема замены одна: Stop со дожимом, собрать новый Cron с новыми задачами, Start. Так делают сервисы с динамическим расписанием из конфига — этому посвящены урок про расписание из конфига и урок про добавление задач на лету.

Шпаргалка жизненного цикла#

  • Создать планировщик: cron.New или NewWithSeconds, опции фиксируются навсегда.
  • Зарегистрировать задачи до Start и проверить err каждого вызова.
  • Start — асинхронный: удержать main сервером, сигналами или select.
  • Идентификаторы EntryID сохранить: Remove и Entry работают только по ним.
  • Добавление задач после Start работает: защита нужна вашему учёту, не планировщику.
  • Остановка: ctx от Stop, дожим по Done, дедлайн через time.After, потом закрытие зависимостей.

Соберите остановку с дожимом

Впишите запуск, остановку и ожидание завершения задач.

c.() // заводим цикл планирования в горутине // ... сервис работает ... ctx := c.() // новые тики остановлены select { case <-ctx.(): log.Println("задачи завершились штатно") case <-time.After(10 * time.Second): log.Println("дедлайн дожима") }

Соберите полный жизненный цикл

Расставьте строки в порядке выполнения: создание, регистрация, запуск, остановка и дожим.

Строки

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

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

Карточки: Start, Stop, Entry

Термин

Start

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

Заводит цикл планирования в отдельной горутине и немедленно возвращается. Повторный вызов — no-op. Задачи на момент старта не запускаются: время первого тика считает цикл.

1 / 6

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

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

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

Вывод

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

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

  • Start асинхронен: цикл живёт в горутине, main обязан удерживаться, иначе процесс умирает до первого тика.
  • Повторный Start — безопасный no-op; блокирующий вариант — Run в текущей горутине.
  • Stop останавливает новые тики и возвращает контекст: он закроется после завершения запущенных задач.
  • Stop не отменяет задачи: отмена делается контекстами внутри задач, а не планировщиком.
  • EntryID — адрес записи: Remove и Entry работают по нему, Entries даёт снапшот с Next и Prev.
  • Замена параметров планировщика — только пересоздание: Stop со дожимом, новый Cron, Start.

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

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

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Start, Stop и жизненный цикл планировщика)

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

Stop закрывает планировщик навсегда?

Метод останавливает цикл, но не портит объект: в v3 после Stop можно зарегистрировать новые задачи и снова вызвать Start — так делают перезапуск с заменой расписания. Тем не менее планируйте один планировщик на процесс: два живых цикла — источник редких и неприятных гонок.

Как остановить только одну задачу, а не весь планировщик?

Через идентификатор: сохраните EntryID при регистрации и вызовите c.Remove(id) — запись исчезнет из расписания, остальные задачи продолжат тикать. Вернуть задачу — новая регистрация с новым идентификатором; паттерны замены по адресу разобраны в уроке про Entries.

Почему после Start задача не выполнилась сразу, хотя её время уже прошло?

Start не догоняет пропущенные моменты: цикл вычисляет следующий момент от текущего времени. Задача, чей момент наступил во время простоя процесса, сработает в следующий раз по расписанию. Если пропуск критичен — добавьте проверку последнего выполнения при старте сервиса.

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

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

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

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

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

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

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