Деплой по пятницам — проверка жизненного цикла, которую мало кто планирует заранее. Оркестратор шлёт сервису SIGTERM, новый контейнер поднимается, а внутри старого в этот момент ночной отчёт записывает третью из семидесяти партиций. Если сервис при остановке просто завершится, отчёт оборвётся на середине, и утром выяснится, что дашборд пуст. Если процесс продержится до конца дожима — отчёт доехал, хоть и с задержкой. Значит, остановка cron go-сервиса должна уметь три вещи: перестать запускать новые тики, дать текущим задачам дописать своё и не зависнуть, если какая-то задача зажала соединение. Первые две проблемы выглядят как мелочи, пока не посчитаешь: каждая незаконченная запись — ручной разбор ночью, а каждое зависание — продлённый деплой на весь лимит оркестратора. В robfig/cron ровно для этого у Stop есть нетривиальный контракт: он возвращает контекст. Разберём оба метода и точки, где жизненный цикл ломается чаще всего.
Start асинхронен: вернул управление и живёт в горутине#
Метод Start запускает цикл планировщика в собственной горутине и немедленно возвращает управление — он не блокирует и не запускает задачи немедленно, даже если их момент уже наступил. Повторный вызов Start на работающем планировщике — безопасный no-op: случайно запустить второй цикл нельзя. Асинхронность порождает две классические ошибки. Первая — забыть Start вообще: задачи зарегистрированы, сервис живёт, тиков нет, потому что цикл планирования не заведён. Вторая тоньше и опаснее: main() после Start нечем задержать, функция возвращает управление, процесс завершается — и планировщик умирает вместе с ним, до первого тика. Поэтому после Start главный поток удерживают чем-то долгоживущим: select {} без веток, чтение канала сигналов или блокирующая работа HTTP-сервера. Если хочется блокирующего поведения сразу, есть метод Run: он делает то же, что Start, но в текущей горутине — удобно для программ без серверной части, неудобно, когда рядом нужно поднимать HTTP.
Start и удержание main
GOfunc 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; здесь важно закрепить привычку сохранять идентификаторы там, где задачи регистрируются не один раз.
Идентификаторы: снять задачу по адресу
GOids := 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 по тому же идентификатору безопасен, задача просто не найдётся. Держать идентификаторы стоит даже в статическом расписании: они стоят ноль строк и дают возможность вытащить задачу без рестарта.
Частый вариант поломки: обработчик сигнала вызывает c.Stop() и немедленно завершает процесс, игнорируя возвращённый контекст. Планировщик честно перестал запускать новые тики, но работающая задача — запись отчёта, отправка пачки писем — обрывается вместе с процессом посреди транзакции. Симптомы: половина файлов, пустые письма, незакрытые транзакции ровно в момент каждого деплоя. Лечение стоит трёх строк: дожидаться ctx.Done от Stop с дедлайном через time.After, и только после этого закрывать зависимости. Дедлайн обязателен: без него один зависший сетевой вызов подвесит остановку пода до лимита оркестратора, который прибьёт контейнер жёстче, чем вы бы хотели.
Жизненный цикл: добавление задач до и после Start#
Регистрация задач работает в обоих режимах. Задачи, добавленные до Start, стартуют вместе с планировщиком; добавленные на работающем — подхватываются без рестарта: цикл пересчитывает расписание и включит новую задачу в ближайший расчёт моментов. Это делает AddFunc безопасным из хендлеров и админских команд, но вспомните из уроков про состояние: регистрация из нескольких горутин должна идти под защитой, если вы ведёте учёт идентификаторов в общей карте. Отдельный плюс поздней регистрации — выкатка фичи флагом: задача регистрируется, только если флаг включён, и выключение флага при следующем старте не оставляет мёртвых записей в расписании. Перезапуска как метода в v3 нет: поменять зону или обёртки на живом планировщике нельзя — эти параметры фиксируются при создании. Схема замены одна: Stop со дожимом, собрать новый Cron с новыми задачами, Start. Так делают сервисы с динамическим расписанием из конфига — этому посвящены урок про расписание из конфига и урок про добавление задач на лету.
Шпаргалка жизненного цикла#
- Создать планировщик: cron.New или NewWithSeconds, опции фиксируются навсегда.
- Зарегистрировать задачи до Start и проверить err каждого вызова.
- Start — асинхронный: удержать main сервером, сигналами или select.
- Идентификаторы EntryID сохранить: Remove и Entry работают только по ним.
- Добавление задач после Start работает: защита нужна вашему учёту, не планировщику.
- Остановка: ctx от Stop, дожим по Done, дедлайн через time.After, потом закрытие зависимостей.
Соберите остановку с дожимом
Впишите запуск, остановку и ожидание завершения задач.
Соберите полный жизненный цикл
Расставьте строки в порядке выполнения: создание, регистрация, запуск, остановка и дожим.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: Start, Stop, Entry
Сам robfig/cron выполняется только локально — сторонних пакетов в песочнице сайта нет, там работают fmt, strings, strconv, math, sort, errors и time. Зато механику из этого урока можно прощупать в песочнице частично: горутины, каналы и select с time.After — стандартная библиотека, и дожим по контексту имитируется парой каналов. Полный сценарий со Start и Stop — локальный go run и лог с метками времени.
Жизненный цикл планировщика — четыре состояния и один контракт: создан, зарегистрирован, работает, останавливается. 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 без рестарта.
Личные заметкиПусто
Вопросы и ответы по уроку
Stop закрывает планировщик навсегда?
Метод останавливает цикл, но не портит объект: в v3 после Stop можно зарегистрировать новые задачи и снова вызвать Start — так делают перезапуск с заменой расписания. Тем не менее планируйте один планировщик на процесс: два живых цикла — источник редких и неприятных гонок.
Как остановить только одну задачу, а не весь планировщик?
Через идентификатор: сохраните EntryID при регистрации и вызовите c.Remove(id) — запись исчезнет из расписания, остальные задачи продолжат тикать. Вернуть задачу — новая регистрация с новым идентификатором; паттерны замены по адресу разобраны в уроке про Entries.
Почему после Start задача не выполнилась сразу, хотя её время уже прошло?
Start не догоняет пропущенные моменты: цикл вычисляет следующий момент от текущего времени. Задача, чей момент наступил во время простоя процесса, сработает в следующий раз по расписанию. Если пропуск критичен — добавьте проверку последнего выполнения при старте сервиса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь