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

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

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

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

Долгие задачи: перекрытие запусков

Cron перекрытие задач — ситуация, когда очередной тик наступает раньше, чем закончился предыдущий запуск: планировщик молча заводит вторую горутину рядом с первой. В прошлом уроке мы научили задачи останавливаться по контексту; теперь противоположная сторона того же вопроса — что происходит, когда long running job в go-сервисе не уложился в интервал. Разберём механику запусков, оценим, когда перекрытие безвредно, а когда дорого, и соберём три защиты: ручной guard на TryLock, обёртки библиотеки и очередь воркеров.

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

Разберём инцидент из практики планировщиков. Задача чистит просроченные сессии каждую минуту по выражению *, и обычно управляется за две секунды. В ночь, когда маркетинг разослал большую рассылку, база притормозила, и одна чистка заняла полторы минуты. В 00:01:30 всё вернулось в норму, но задача к этому моменту запустилась дважды: тик 00:01 не стал ждать первую копию, он завёл вторую горутину, и обе поехали параллельно. Через час у сервиса плюс двести мегабайт памяти, у базы — конкуренция за одни и те же строки, а в логах — пары старта без финиша. Никто не поймал момент поломки, потому что не сломалось ничего: планировщик отработал как задуман. Перекрытие — не ошибка библиотеки, а свойство модели запуска, и у каждой задачи должно быть мнение, что с ним делать: пропускать тик, ставить в очередь или позволять работать параллельно, убедившись, что задача это переживёт.

Cron перекрытие задач: механика запуска в горутине#

Механика проста до однострочности: в цикле планировщика каждое срабатывание превращается в go j.Run() — горутину на один запуск, без ожидания, без семафора, без проверки, жива ли предыдущая. Cron перекрытие задач происходит именно поэтому: выражение задаёт частоту запусков, а не длительность работы, и планировщик не связывает одно с другим. Длинная задача по * получит новый тик через минуту, что бы она ни делала, и копий может накопиться сколько угодно — по одной горутине на каждый тик. Из этого два практических вывода. Первый: задача обязана быть готова к параллельному запуску с самой собой — идемпотентность из урока про задачи-структуры тут не украшение, а требование выживания. Второй: если параллельная работа недопустима, защиту нужно строить руками, планировщик её не предоставит. Механика горутин, на которой всё это держится, разобрана в уроке про горутины.

Задача без защиты: перекрытие не видно, пока не посчитать

GO
c.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 с диагностикой

GO
type 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; здесь важно понять, что выбор между ними — про продуктовую семантику, а не про вкус.

Стратегии при перекрытии#

СтратегияКак работаетКогда выбирать
Пустить как естькаждый тик — своя горутина, защиты нетзадача без состояния, эффекты устойчивы к повтору
Guard на TryLockмьютекс занят — тик пропускаетсяпериодические чистки, метрики, репортажи
SkipIfStillRunningто же самое силами библиотеки, с логомтот же класс задач, меньше своего кода
DelayIfStillRunningтик ждёт завершения предыдущего в очередикаждый тик несёт обязательства, деградации короткие
Очередь воркеровтики пишутся в канал фиксированной ёмкостинагрузка неравномерная, пропускать нельзя

Дедлайны и очередь: когда задачи обязаны успевать#

Защита от перекрытия лечит симптом; причина чаще в том, что задача не влезает в интервал. Первый инструмент — бюджет: контекст с дедлайном из урока про контекст заставляет задачу либо успевать, либо честно выходить по ошибке, вместо того чтобы висеть и накапливать копии. Второй — очередь воркеров: тик перестаёт быть горутиной с неограниченным параллелизмом и становится заданием в канале фиксированной ёмкости, которое разбирают N воркеров. Перекрытие превращается в управляемую очередь: переполнение видно по метрике заполнения канала, давление регулируется числом воркеров, а идемпотентность защищает от двойной обработки. Третий инструмент — пересмотр расписания: если задача стабильно работает дольше интервала, дело не в guard, а в слишком частом выражении. Выбор — пропуск, очередь или пересмотр частоты — стоит зафиксировать на уровне команды, а распределённые блокировки добавят к этому вопрос нескольких инстансов, где локальный мьютекс бессилен.

Соберите guard на TryLock

Впишите неблокирующую попытку захвата, выход при пропуске и гарантированное освобождение.

if !j.mu.() { log.Println("тик пропущен") } defer j.mu.() j.cleanup(ctx)

Соберите guard задачи

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

Строки

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

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

Карточки: перекрытие запусков

Термин

Перекрытие запусков

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

Следующий тик стартует раньше, чем закончился предыдущий запуск: каждое срабатывание — go j.Run() без ожидания и семафора. Свойство модели запуска, а не баг.

1 / 6

Перекрытие запусков — не авария, а вопрос к дизайну задачи, и планировщик честно оставляет ответ за вами. Модель проста: каждый тик — горутина, выражение задаёт частоту запусков, а не длительность работы. Задача без состояния и с повторяемыми эффектами может жить без защиты; всем остальным нужен выбор — пропуск через TryLock или SkipIfStillRunning, очередь через DelayIfStillRunning или воркеров, либо пересмотр самого расписания. Каким бы ни был выбор, чекпоинт до побочных эффектов и идемпотентность остаются страховкой: защита может дать сбой в момент деплоя, а данные дублироваться из-за этого не должны. Следующий шаг — конкурентность задач: перекрытие одной задачи — частный случай параллельной жизни всех задач сразу. Если возвращались с пропуска — Start и Stop и контекст в задачах дают базу этого урока. Полный маршрут — на странице курса. В домашнем задании — счётчик активных запусков и guard, который превращает перекрытие в наблюдаемый пропуск.

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

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

Вывод

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

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

  • Каждый тик — отдельная горутина: планировщик не ждёт предыдущий запуск и не защищает от перекрытия.
  • Перекрытие безвредно для задач без состояния и с повторяемыми эффектами; опасно там, где побочные эффекты и общие данные.
  • Guard на TryLock пропускает тик, если предыдущий запуск жив: три строки и честный лог пропусков.
  • SkipIfStillRunning повторяет guard силами библиотеки; DelayIfStillRunning ставит тики в очередь и копит догоняющие запуски при деградации.
  • Дедлайн на запуск и очередь воркеров лечат причину: задача обязана успевать или нагрузка обязана быть управляемой.
  • Чекпоинт обновляется до побочных эффектов: тогда ни перекрытие, ни обрыв не дублируют работу.

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

Возьмите любую задачу из своих проектов и добавьте диагностику: счётчик активных запусков и лог старта и финиша с длительностью. Погоните день и посмотрите, случались ли перекрытия. Затем закройте вопрос: guard на TryLock или обёртка SkipIfStillRunning через WithChain. После этого искусственно замедлите задачу sleep до полутора интервалов и проверьте по логам, что тики пропускаются, а не налагаются. Финал — обновите курсор обработанных данных до побочных эффектов и убедитесь, что двойной запуск вручную не приводит к дубликатам.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Долгие задачи: перекрытие запусков)

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

Планировщик может сам не запускать вторую копию задачи?

В robfig/cron v3 такой защиты у выражений нет: каждое срабатывание запускается горутиной безусловно. Пропуск или очередь добавляются обёртками SkipIfStillRunning и DelayIfStillRunning из цепочки WithChain либо ручным guard на TryLock внутри задачи.

Что произойдёт, если задача работает вечно?

Каждый тик добавит новую горутину: копии будут накапливаться, пока процесс не упрётся в память или в лимиты соединений с базой. Локальный мьютекс превращает накопление в пропуски, но причину стоит лечить дедлайном на запуск и пересмотром расписания: задача, которая систематически не успевает, спроектирована неверно.

TryLock или DelayIfStillRunning: что выбрать?

Зависит от семантики тика. Периодические чистки и репортажи живут с пропуском: следующий период подхватит работу, и это честнее очереди. Тики с обязательствами — напоминания, платежи — терять нельзя, но и копить очередь при деградации опасно; для них лучше очередь воркеров с фиксированной ёмкостью и метрикой переполнения.

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

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

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

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

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

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

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