Планировщик в Go: как гонять фоновые задачи правильно
Запустить горутину в цикле с time.Sleep кажется простым, но задачи наслаиваются, паникуют и не гасятся при остановке сервиса. Разбираем рабочие подходы к периодическим задачам в Go 1.22+.

Вам нужно раз в минуту чистить устаревшие сессии, раз в час выгружать отчёт, раз в сутки слать напоминания. Первое, что приходит в голову: горутина с for, внутри time.Sleep(time.Minute) и вызов функции. Работает на демо, ломается в проде. Задача, которая выполняется дольше интервала, начинает наслаиваться сама на себя. Паника в задаче кладёт всю горутину без следа в логах. При остановке сервиса задача обрывается на середине записи в базу. Разберём, как организовать периодические задачи в Go, чтобы они переживали медленные итерации, паники и graceful shutdown.
Статья про Go 1.22 и новее — там изменилось поведение time.After в сборщике мусора и появился прогретый пул таймеров. Планирование по cron-выражениям (сложные расписания вида «каждый второй вторник») — отдельная тема, здесь рассматриваем интервальные задачи и их корректную остановку.
Почему Sleep в цикле — плохая база
Наивный вариант выглядит так:
// анти-пример, так делать не надо
for {
cleanupSessions()
time.Sleep(time.Minute)
}Здесь три проблемы, которые проявятся не сразу.
- Дрейф интервала. Если
cleanupSessionsзаняла 8 секунд, следующий запуск будет через 68 секунд от предыдущего старта, а не через 60. За сутки набегает заметное смещение. - Нет остановки. Горутина живёт вечно. При завершении сервиса вы либо ждёте её неопределённое время, либо убиваете процесс на середине задачи.
- Паника роняет всё молча. Если внутри задачи возникнет паника, горутина умрёт, а цикл прекратится. Остальной сервис продолжит работать, и вы узнаете о пропаже задачи через день, когда сессии перестанут чиститься.
Базовый рабочий скелет на Ticker и Context
Правильная основа — time.Ticker для равномерного тика и context.Context для остановки. Ticker тикает от абсолютного времени, поэтому дрейф не накапливается так, как со Sleep.
package scheduler
import (
"context"
"log/slog"
"time"
)
// RunPeriodic вызывает task каждые interval, пока не отменят ctx.
// Первый запуск — по истечении первого интервала, не сразу.
func RunPeriodic(ctx context.Context, interval time.Duration, task func(context.Context)) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return // сервис останавливается, выходим чисто
case <-ticker.C:
runSafe(ctx, task)
}
}
}
// runSafe гасит панику внутри одной итерации,
// чтобы упавшая задача не убила весь цикл.
func runSafe(ctx context.Context, task func(context.Context)) {
defer func() {
if r := recover(); r != nil {
slog.Error("periodic task panicked", "recover", r)
}
}()
task(ctx)
}Что важно в этом коде:
- Задача получает тот же
ctx. Значит, при остановке сервиса долгий запрос в базу можно прервать черезctx.Done(), если сама задача это уважает. recoverобёрнут в отдельную функцию сdefer. Обычная ошибка новичка — поставитьdefer recover()прямо в цикле: тогдаrecoverвызывается сразу, а не при панике, и ничего не ловит.- Первый тик приходит через
interval, а не мгновенно. Если нужен запуск сразу при старте — вызовитеrunSafe(ctx, task)перед циклом.
Планировщик, который не умеет останавливаться, — это не планировщик, а утечка. Graceful shutdown должен гасить фоновые задачи так же надёжно, как HTTP-сервер закрывает соединения.
Наслоение задач: не запускать вторую, пока идёт первая
Ticker решает дрейф, но не решает наслоение. Если задача иногда длится дольше интервала, тики копятся. Канал ticker.C буферизован на один элемент: лишние тики отбрасываются, пока вы заняты в задаче. Это уже спасает от лавины, но следующий запуск начнётся сразу после текущего, а не через полный интервал.
Есть два честных варианта поведения, и надо выбрать осознанно.
| Подход | Что делает при долгой задаче | Когда брать |
|---|---|---|
| Ticker с пропуском тиков | Пропускает пропущенные интервалы, следующий запуск — на ближайшем тике после завершения | Важна привязка к сетке времени (каждую минуту по часам) |
| Sleep после завершения | Всегда выдерживает паузу после конца задачи, интервал «плавает» | Важна пауза между запусками, а не абсолютное расписание |
Если нужна гарантированная пауза после каждого прогона, а не привязка к часам, используйте time.Timer с пересозданием:
func RunWithGap(ctx context.Context, gap time.Duration, task func(context.Context)) {
for {
runSafe(ctx, task)
timer := time.NewTimer(gap)
select {
case <-ctx.Done():
timer.Stop() // освобождаем таймер при выходе
return
case <-timer.C:
// пауза выдержана, идём на следующий круг
}
}
}В Go 1.23+ несработавший Timer собирается сборщиком мусора без явного Stop, но привычку останавливать таймер лучше сохранить — на старых версиях и в горячих циклах это по-прежнему экономит ресурсы.
Остановка: дождаться завершения текущей итерации
Отмена ctx заставляет цикл выйти на следующем тике, но текущая задача может быть в разгаре. Чтобы shutdown дождался её окончания, оберните запуск в sync.WaitGroup.
func StartScheduler() (stop func()) {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
RunPeriodic(ctx, time.Minute, cleanupSessions)
}()
// stop отменяет контекст и ждёт, пока горутина реально завершится
return func() {
cancel()
wg.Wait()
}
}В main вызывайте stop() при получении сигнала. Так процесс не завершится, пока текущая итерация не отработает или не отреагирует на отмену контекста.
Задача должна уважать контекст
WaitGroup дождётся горутину, но если сама задача не проверяет ctx, при остановке вы будете ждать полный запрос к базе. Передавайте ctx дальше — в db.QueryContext(ctx, ...), в HTTP-клиент через http.NewRequestWithContext. Тогда при cancel зависший запрос оборвётся, а не заблокирует shutdown на таймаут базы.
Несколько задач и внешние библиотеки
Когда задач становится десяток, ручной набор горутин превращается в кашу. Здесь уместна готовая библиотека. По распространённости выделяются две.
- robfig/cron — планирование по cron-выражениям, если нужны расписания вроде «в 3:00 по будням». Даёт разбор cron-синтаксиса из коробки.
- go-co-op/gocron — интервалы и человекочитаемые расписания (
Every(1).Hour()), встроенная защита от наслоения через режим синглтона.
Точные имена методов и опции проверяйте в README нужной версии — API этих библиотек между мажорными версиями менялся, и подставлять вызовы по памяти рискованно. Своя реализация на Ticker оправдана, когда задач одна-три и не хочется тянуть зависимость ради цикла с select.
Что проверить перед выкатом
- Горутина планировщика завершается по отмене контекста — проверьте тестом с коротким интервалом и
cancel. - Паника внутри задачи ловится и логируется, цикл продолжает работать.
- Долгая задача не запускается второй раз параллельно самой себе.
- Задача передаёт
ctxв запросы к базе и внешним сервисам. - При SIGTERM процесс ждёт завершения текущей итерации, а не рвёт её.
Следующий шаг — наблюдаемость. Заведите метрики: время выполнения каждой итерации (гистограмма), счётчик паник, счётчик пропущенных тиков. Если задача под нагрузкой начнёт регулярно перекрывать интервал, вы увидите это по растущему числу пропусков раньше, чем по жалобам. Инвалидация и распределённая блокировка (чтобы задача не запускалась на всех репликах сразу) — отдельная тема, начните с одного экземпляра и Redis-лока, когда дойдёте до нескольких подов.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Package time — Go standard library, Package context — Go standard library
Частые вопросы
А если у меня Go старее 1.22?
Почему нельзя просто defer recover() прямо в цикле?
Задача иногда длится дольше интервала — что выбрать?
Как убедиться, что при остановке сервиса задача не оборвётся на середине?
Что будет, если запустить один и тот же планировщик на нескольких репликах?
Стоит ли сразу тянуть gocron или cron вместо своего цикла?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.