Содержание урока · 9 разделов
- Cron перекрытие задач: механика запуска в горутине
- Когда перекрытие безвредно, а когда дорого
- Мьютекс и TryLock: пропуск тика руками
- DelayIfStillRunning и SkipIfStillRunning: обёртки библиотеки
- Стратегии при перекрытии
- Дедлайны и очередь: когда задачи обязаны успевать
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Разберём инцидент из практики планировщиков. Задача чистит просроченные сессии каждую минуту по выражению *, и обычно управляется за две секунды. В ночь, когда маркетинг разослал большую рассылку, база притормозила, и одна чистка заняла полторы минуты. В 00:01:30 всё вернулось в норму, но задача к этому моменту запустилась дважды: тик 00:01 не стал ждать первую копию, он завёл вторую горутину, и обе поехали параллельно. Через час у сервиса плюс двести мегабайт памяти, у базы — конкуренция за одни и те же строки, а в логах — пары старта без финиша. Никто не поймал момент поломки, потому что не сломалось ничего: планировщик отработал как задуман. Перекрытие — не ошибка библиотеки, а свойство модели запуска, и у каждой задачи должно быть мнение, что с ним делать: пропускать тик, ставить в очередь или позволять работать параллельно, убедившись, что задача это переживёт.
Cron перекрытие задач: механика запуска в горутине#
Механика проста до однострочности: в цикле планировщика каждое срабатывание превращается в go j.Run() — горутину на один запуск, без ожидания, без семафора, без проверки, жива ли предыдущая. Cron перекрытие задач происходит именно поэтому: выражение задаёт частоту запусков, а не длительность работы, и планировщик не связывает одно с другим. Длинная задача по * получит новый тик через минуту, что бы она ни делала, и копий может накопиться сколько угодно — по одной горутине на каждый тик. Из этого два практических вывода. Первый: задача обязана быть готова к параллельному запуску с самой собой — идемпотентность из урока про задачи-структуры тут не украшение, а требование выживания. Второй: если параллельная работа недопустима, защиту нужно строить руками, планировщик её не предоставит. Механика горутин, на которой всё это держится, разобрана в уроке про горутины.
Задача без защиты: перекрытие не видно, пока не посчитать
GOc.AddFunc("* * * * *", func() {
log.Println("чистка: старт")
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
if err := cleanup(ctx, store); err != nil {
log.Println("чистка: ошибка", err)
}
log.Println("чистка: финиш")
})Каждый тик — своя горутина со своим контекстом. Пока cleanup укладывается в сорок секунд, перекрытий не видно; стоит базе притормозить, и лог превращается в чередование стартов и финишей без пар. Диагностируется элементарно: счётчик активных запусков в поле структуры или уникальный идентификатор запуска в логе — перекрытие станет видно в первую же деградацию.
Когда перекрытие безвредно, а когда дорого#
Перекрытие само по себе не приговор: у него есть мирные сценарии. Задача, которая только читает данные и пишет агрегат в отдельный лог, спокойно переживёт две параллельные копии. Опасность начинается там, где появляются общие ресурсы и побочные эффекты. Двойные письма: задача напоминаний взяла одну пачку адресатов дважды, потому что первая копия ещё не обновила курсор. Нарушенный порядок: две копии экспорта пишут один файл по очереди. Гонка на состоянии: обе копии увеличивают счётчик в структуре задачи — вспомните value- и pointer-receiver из урока про AddJob, добавьте сюда конкурентную запись, и получится паника concurrent map writes. Долгая деградация: перекрытий всё больше, горутины копятся, память растёт, и сервис умирает через сутки после инцидента, когда причину по логам уже не найти. Правило отбора: задача без общего изменяемого состояния и с эффектами, устойчивыми к повтору, может работать с перекрытием; всё остальное — под защиту из этого урока.
Проверка: что сделает планировщик
Задача по выражению * * * * * работает уже девяносто секунд, наступил следующий тик. Что сделает планировщик?
Мьютекс и TryLock: пропуск тика руками#
Самый экономный guard — блокировка с неблокирующей попыткой захвата. С версии Go 1.18 у sync.Mutex есть метод TryLock: он возвращает false, если мьютекс занят, вместо того чтобы ждать. Задача оборачивает работу в проверку: захватили — работаем и отпускаем через defer Unlock; не захватили — тик пропущен, и в логе появляется строка о пропуске. Стоить это будет три строки, а закроет проблему целиком: вторая копия физически не войдёт в защищённый участок. Семантика важна: здесь новый тик отбрасывается, а не встаёт в очередь, то есть периодическая работа просто пропускает период. Для чистки, метрик и репортажей это ровно то, что нужно: данные не убегут, следующий тик подхватит. Для задач, где каждый тик несёт обязательства — напоминания, списания, — пропуск так же плох, как перекрытие, и там нужна очередь из конца урока.
Guard на TryLock с диагностикой
GOtype CleanupJob struct {
mu sync.Mutex
store *Store
live int32 // активные запуски: диагностика перекрытий
}
func (j *CleanupJob) Run() {
if !j.mu.TryLock() {
log.Println("чистка: тик пропущен, предыдущий запуск ещё работает")
return
}
defer j.mu.Unlock()
atomic.AddInt32(&j.live, 1)
defer atomic.AddInt32(&j.live, -1)
j.cleanup(context.Background())
}TryLock не ждёт: мьютекс занят — вернулся false, горутина вышла и планировщик свободен. defer Unlock освобождает блокировку при любом выходе, включая панику внутри cleanup — впрочем, паника без Recover из урока про wrappers уронит процесс целиком, поэтому обёртки разбираются отдельно. Счётчик live — дешёвая диагностика: если он регулярно больше единицы, guard работает, а длительность задачи и интервал выражения перестали соответствовать друг другу.
Проверка: семантика пропуска
Первая копия задачи держит мьютекс. Вторая горутина вызвала TryLock и получила false. Что правильно сделать дальше?
Что напечатают два тика?
Прочитайте код и выберите, что он выведет:
var mu sync.Mutex
func tick(n int) {
if !mu.TryLock() {
fmt.Println(n, "пропуск")
return
}
defer mu.Unlock()
fmt.Println(n, "работа")
time.Sleep(10 * time.Millisecond)
}
// tick(1) запущен в горутине,
// через 5 миллисекунд из другой горутины — tick(2)DelayIfStillRunning и SkipIfStillRunning: обёртки библиотеки#
Robfig/cron v3 кладёт те же защиты в обёртки цепочки WithChain, и мьютекс руками писать не обязательно. SkipIfStillRunning делает ровно то, что наш guard: если предыдущий запуск ещё жив, тик пропускается, а если обёртке передан логгер, каждый пропуск попадает в лог без лишнего шума. DelayIfStillRunning — противоположная семантика: тик не пропускается, а ставится в очередь и выполнится после завершения предыдущего запуска. Разница стратегическая. Skip сохраняет периодичность: перекрытия нет, потерянные тики не копятся, работа дожидается следующего периода. Delay гарантирует исполнение каждого тика, но при длительной деградации очередь растёт: тики копятся, и после восстановления задачи посыпятся одна за другой — в худшем случае догоняющая серия на полчаса работы. Для периодических задач с дедлайнами честнее Skip: потерянный период безопаснее лавины догоняющих запусков. Обе обёртки, их сочетания с Recover и тонкости применения — тема урока про wrappers; здесь важно понять, что выбор между ними — про продуктовую семантику, а не про вкус.
Задача напоминаний раз в пять минут брала из базы пачку адресатов и отправляла письма, а курсор обработанных обновляла в самом конце. В час пик выборка затянулась до шести минут, тик наложился, и обе копии прочитали одну и ту же пачку: часть пользователей получила напоминание дважды, поддержка узнала об этом из жалоб, а не из метрик. Диагноз — перекрытие плюс обновление курсора в конце вместо начала. Лечение двухслойное: чекпоинт обновляется по ходу обработки, до побочных эффектов, а запуск защищается SkipIfStillRunning или guard на TryLock. Любая из мер в одиночку устранила бы дубли, но вместе они дают и предсказуемость, и честность данных.
Стратегии при перекрытии#
| Стратегия | Как работает | Когда выбирать |
|---|---|---|
| Пустить как есть | каждый тик — своя горутина, защиты нет | задача без состояния, эффекты устойчивы к повтору |
| Guard на TryLock | мьютекс занят — тик пропускается | периодические чистки, метрики, репортажи |
| SkipIfStillRunning | то же самое силами библиотеки, с логом | тот же класс задач, меньше своего кода |
| DelayIfStillRunning | тик ждёт завершения предыдущего в очереди | каждый тик несёт обязательства, деградации короткие |
| Очередь воркеров | тики пишутся в канал фиксированной ёмкости | нагрузка неравномерная, пропускать нельзя |
Дедлайны и очередь: когда задачи обязаны успевать#
Защита от перекрытия лечит симптом; причина чаще в том, что задача не влезает в интервал. Первый инструмент — бюджет: контекст с дедлайном из урока про контекст заставляет задачу либо успевать, либо честно выходить по ошибке, вместо того чтобы висеть и накапливать копии. Второй — очередь воркеров: тик перестаёт быть горутиной с неограниченным параллелизмом и становится заданием в канале фиксированной ёмкости, которое разбирают N воркеров. Перекрытие превращается в управляемую очередь: переполнение видно по метрике заполнения канала, давление регулируется числом воркеров, а идемпотентность защищает от двойной обработки. Третий инструмент — пересмотр расписания: если задача стабильно работает дольше интервала, дело не в guard, а в слишком частом выражении. Выбор — пропуск, очередь или пересмотр частоты — стоит зафиксировать на уровне команды, а распределённые блокировки добавят к этому вопрос нескольких инстансов, где локальный мьютекс бессилен.
Соберите guard на TryLock
Впишите неблокирующую попытку захвата, выход при пропуске и гарантированное освобождение.
Соберите guard задачи
Расставьте строки в порядке выполнения: проверка, лог пропуска, выход, закрытие блока, освобождение и работа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: перекрытие запусков
Пакетов cron и sync в песочнице сайта нет — там работают fmt, strings, strconv, math, sort, errors и time, так что guard и обёртки проверяются локально из модуля второго урока. Механику перекрытия можно смоделировать в песочнице: канал ёмкости один как семафор, select с default вместо TryLock, горутина с time.Sleep как долгая задача — time и горутины она поддерживает. Но настоящий планировщик и цепочки WithChain гоняйте только в локальном go run: там и детектор гонок, и честные тайминги.
Перекрытие запусков — не авария, а вопрос к дизайну задачи, и планировщик честно оставляет ответ за вами. Модель проста: каждый тик — горутина, выражение задаёт частоту запусков, а не длительность работы. Задача без состояния и с повторяемыми эффектами может жить без защиты; всем остальным нужен выбор — пропуск через TryLock или SkipIfStillRunning, очередь через DelayIfStillRunning или воркеров, либо пересмотр самого расписания. Каким бы ни был выбор, чекпоинт до побочных эффектов и идемпотентность остаются страховкой: защита может дать сбой в момент деплоя, а данные дублироваться из-за этого не должны. Следующий шаг — конкурентность задач: перекрытие одной задачи — частный случай параллельной жизни всех задач сразу. Если возвращались с пропуска — Start и Stop и контекст в задачах дают базу этого урока. Полный маршрут — на странице курса. В домашнем задании — счётчик активных запусков и guard, который превращает перекрытие в наблюдаемый пропуск.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каждый тик — отдельная горутина: планировщик не ждёт предыдущий запуск и не защищает от перекрытия.
- Перекрытие безвредно для задач без состояния и с повторяемыми эффектами; опасно там, где побочные эффекты и общие данные.
- Guard на TryLock пропускает тик, если предыдущий запуск жив: три строки и честный лог пропусков.
- SkipIfStillRunning повторяет guard силами библиотеки; DelayIfStillRunning ставит тики в очередь и копит догоняющие запуски при деградации.
- Дедлайн на запуск и очередь воркеров лечат причину: задача обязана успевать или нагрузка обязана быть управляемой.
- Чекпоинт обновляется до побочных эффектов: тогда ни перекрытие, ни обрыв не дублируют работу.
Домашнее задание
Возьмите любую задачу из своих проектов и добавьте диагностику: счётчик активных запусков и лог старта и финиша с длительностью. Погоните день и посмотрите, случались ли перекрытия. Затем закройте вопрос: guard на TryLock или обёртка SkipIfStillRunning через WithChain. После этого искусственно замедлите задачу sleep до полутора интервалов и проверьте по логам, что тики пропускаются, а не налагаются. Финал — обновите курсор обработанных данных до побочных эффектов и убедитесь, что двойной запуск вручную не приводит к дубликатам.
Личные заметкиПусто
Вопросы и ответы по уроку
Планировщик может сам не запускать вторую копию задачи?
В robfig/cron v3 такой защиты у выражений нет: каждое срабатывание запускается горутиной безусловно. Пропуск или очередь добавляются обёртками SkipIfStillRunning и DelayIfStillRunning из цепочки WithChain либо ручным guard на TryLock внутри задачи.
Что произойдёт, если задача работает вечно?
Каждый тик добавит новую горутину: копии будут накапливаться, пока процесс не упрётся в память или в лимиты соединений с базой. Локальный мьютекс превращает накопление в пропуски, но причину стоит лечить дедлайном на запуск и пересмотром расписания: задача, которая систематически не успевает, спроектирована неверно.
TryLock или DelayIfStillRunning: что выбрать?
Зависит от семантики тика. Периодические чистки и репортажи живут с пропуском: следующий период подхватит работу, и это честнее очереди. Тики с обязательствами — напоминания, платежи — терять нельзя, но и копить очередь при деградации опасно; для них лучше очередь воркеров с фиксированной ёмкостью и метрикой переполнения.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Горутины и конкурентность задач
- Инспекция расписания: Entries и Remove
- Хранение расписания: конфиг и база
- Динамическое расписание: добавление на лету
- Контекст в задачах: отмена и дедлайны
- Start, Stop и жизненный цикл планировщика
- Секунды: cron.NewWithSeconds
- Часовые пояса: Timezone и локальное время
- Все курсы по Go
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь