---
title: "Start, Stop и жизненный цикл планировщика"
description: "Cron start stop в robfig/cron v3: Start асинхронен, Stop ждёт дожима задач и возвращает контекст, Entry.ID для Remove, блокировка main и жизненный цикл."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/09-start-stop/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 9 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/09-start-stop/*

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

Деплой по пятницам — проверка жизненного цикла, которую мало кто планирует заранее. Оркестратор шлёт сервису 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 и работает, но тиков нет. Что забыли?

- ✅ Вызов c.Start(): без него цикл планирования не заведён
- Обёртку cron.Recover: без неё планировщик отказывается стартовать
- Второй аргумент выражения: пятиполе требует указания секунд
- Ничего: AddFunc сам запускает задачи, тиков просто надо подождать

AddFunc и AddJob только регистрируют задачи в планировщике; цикл, который сверяет время и запускает тики, заводится вызовом Start. Вторая половина той же ловушки — после Start нужен удерживающий main: процесс умирает, как только main вернётся, и тиков снова не будет.

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

Метод Stop делает две вещи, и половина сервисов знает только первую. Первая — остановить цикл: новые тики вычисляться и запускаться перестают. Вторая — вернуть context.Context, который закрывается после того, как все уже запущенные задачи завершатся. Важно проговорить, чего Stop не делает: он не отменяет работающие задачи. Внутри задач нет отмены — планировщик не знает ни про контексты задач, ни про их HTTP-запросы; он просто ждёт, пока горутины закончат сами. Остановка cron, которая не роняет работу, выглядит так: получить контекст от Stop, дождаться его Done и лишь потом закрывать базу, соединения и выходить. Если задача может висеть неограниченно — дожим ограничивают дедлайном через select и time.After, а дальше решают по ситуации: логировать и уходить или принудительно завершать процесс. Полная схема с сигналами и приоритетами закрытия — в [уроке про graceful shutdown](/courses/cron/lessons/19-graceful-cron/). Отдельный нюанс для микросервисов: 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(). Что произойдёт с задачей?

- Задача отменяется мгновенно: планировщик убивает горутину
- ✅ Задача продолжает работать, а контекст от Stop закроется после её завершения
- Планировщик ждёт ровно десять секунд, потом прибивает задачу
- Задача перезапустится с начала после остановки планировщика

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

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

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

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

**Что выведет программа?**

- регистрация готова, тик, старт вернулся
- ✅ регистрация готова, старт вернулся; тик — когда наступит минута
- тик, регистрация готова, старт вернулся
- Ошибку: Start нельзя вызывать после регистрации задач

Start асинхронен и немедленно возвращает управление, поэтому обе строки main печатаются подряд. Тик печатается не сразу, а на ближайшей границе минуты: регистрация задачи не запускает её, а время первого запуска вычисляет цикл планировщика. Именно ожидание мгновенного тика после Start — самый частый вопрос новичков.

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

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

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

```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 по тому же идентификатору безопасен, задача просто не найдётся. Держать идентификаторы стоит даже в статическом расписании: они стоят ноль строк и дают возможность вытащить задачу без рестарта.

> **Stop без ожидания дожима: деплой обрывает запись.** Частый вариант поломки: обработчик сигнала вызывает c.Stop() и немедленно завершает процесс, игнорируя возвращённый контекст. Планировщик честно перестал запускать новые тики, но работающая задача — запись отчёта, отправка пачки писем — обрывается вместе с процессом посреди транзакции. Симптомы: половина файлов, пустые письма, незакрытые транзакции ровно в момент каждого деплоя. Лечение стоит трёх строк: дожидаться ctx.Done от Stop с дедлайном через time.After, и только после этого закрывать зависимости. Дедлайн обязателен: без него один зависший сетевой вызов подвесит остановку пода до лимита оркестратора, который прибьёт контейнер жёстче, чем вы бы хотели.

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

Регистрация задач работает в обоих режимах. Задачи, добавленные до Start, стартуют вместе с планировщиком; добавленные на работающем — подхватываются без рестарта: цикл пересчитывает расписание и включит новую задачу в ближайший расчёт моментов. Это делает AddFunc безопасным из хендлеров и админских команд, но вспомните из уроков про состояние: регистрация из нескольких горутин должна идти под защитой, если вы ведёте учёт идентификаторов в общей карте. Отдельный плюс поздней регистрации — выкатка фичи флагом: задача регистрируется, только если флаг включён, и выключение флага при следующем старте не оставляет мёртвых записей в расписании. Перезапуска как метода в v3 нет: поменять зону или обёртки на живом планировщике нельзя — эти параметры фиксируются при создании. Схема замены одна: Stop со дожимом, собрать новый Cron с новыми задачами, Start. Так делают сервисы с динамическим расписанием из конфига — этому посвящены [урок про расписание из конфига](/courses/cron/lessons/14-raspisanie-iz-konfiga/) и [урок про добавление задач на лету](/courses/cron/lessons/15-izmenenie-raspisaniya/).

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

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

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

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

```go
c.___() // заводим цикл планирования в горутине

// ... сервис работает ...

ctx := c.___() // новые тики остановлены

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

Подсказка: Цикл заводит Start, новые тики останавливает Stop, а завершение задач сообщает ctx.Done.

Правильные ответы в пропусках: `Start`, `Stop`, `Done`

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

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

Правильный порядок строк:

1. `c := cron.New()`
2. `_, err := c.AddFunc("@daily", report)`
3. `c.Start()`
4. `ctx := c.Stop()`
5. `<-ctx.Done()`
6. `log.Println("планировщик остановлен, задачи завершены")`

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

- **Start** — Заводит цикл планирования в отдельной горутине и немедленно возвращается. Повторный вызов — no-op. Задачи на момент старта не запускаются: время первого тика считает цикл.
- **Run** — Блокирующий вариант: то же, что Start, но в текущей горутине. Подходит утилитам без серверной части; в сервисах main обычно занят HTTP или ожиданием сигналов.
- **Stop** — Останавливает новые тики и возвращает context.Context, который закроется после завершения уже запущенных задач. Работающие задачи не отменяет — они догоняют сами.
- **EntryID** — Целочисленный идентификатор записи расписания: возвращает AddFunc и AddJob. Адрес для Remove и Entry; стоит сохранять, если расписание живёт дольше регистрации.
- **Поля Entry** — Schedule — распарсенное расписание, Next — момент следующего тика, Prev — последнего (нулевой до первого). Дешёвая диагностика: лог по Entries отвечает, что запланировано.
- **Замена планировщика** — Перезапуска методом нет: зона и обёртки фиксируются при создании. Схема — Stop со дожимом, новый Cron с новыми задачами, Start.

> **Где запускать примеры.** Сам robfig/cron выполняется только локально — сторонних пакетов в песочнице сайта нет, там работают fmt, strings, strconv, math, sort, errors и time. Зато механику из этого урока можно прощупать в песочнице частично: горутины, каналы и select с time.After — стандартная библиотека, и дожим по контексту имитируется парой каналов. Полный сценарий со Start и Stop — локальный go run и лог с метками времени.

Жизненный цикл планировщика — четыре состояния и один контракт: создан, зарегистрирован, работает, останавливается. Start асинхронен и требует удержания main, Stop не отменяет задачи, а честно ждёт их через контекст, и только идентификаторы EntryID делают расписание управляемым. Следующий урок закрывает дыру, которая осталась в контракте Stop: [контекст в задачах](/courses/cron/lessons/10-kontekst-zadachi/) — как отменять долгие операции по-настоящему и почему Stop без контекстов в задачах способен ждать вечно. Дальше по маршруту — [graceful shutdown планировщика](/courses/cron/lessons/19-graceful-cron/) с сигналами и приоритетами закрытия, [инспекция расписания](/courses/cron/lessons/13-inspekciya-entry/) для работы с Entries. Если пропустили предыдущий — [секунды и NewWithSeconds](/courses/cron/lessons/08-sekundy-raspisaniya/) объясняют, откуда берётся шестипольный формат. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — сервис с дожимом и зависшей задачей.

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

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