---
title: "Долгие задачи: перекрытие запусков"
description: "Cron перекрытие задач: что происходит, когда задача go не уложилась в интервал. Каждый тик — новая горутина, guard на TryLock, обёртки и очередь воркеров."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/11-perekrytie-zapuska/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

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

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

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

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

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

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

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

### Проверка: что сделает планировщик

**Вопрос:** Задача по выражению * * * * * работает уже девяносто секунд, наступил следующий тик. Что сделает планировщик?

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

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

### Мьютекс и 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. Что правильно сделать дальше?

- Ждать в цикле, пока TryLock не вернёт true
- Заснуть на секунду и повторять попытку до конца минуты
- ✅ Залогировать пропуск и выйти: следующий тик подхватит работу
- Вызвать обычный Lock и дождаться освобождения

Смысл guard на TryLock — пропуск, а не ожидание: горутина тика нужна только до момента запуска. Ожидание в цикле или обычный Lock превращают пропуск в накопление очереди горутин, которые затем выйдут одна за другой — перекрытие вернётся с задержкой. Если работу нельзя пропускать, это задача для очереди воркеров, а не для мьютекса.

### Что напечатают два тика?

```go
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)
```

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

- 1 работа, затем 2 работа
- ✅ 1 работа, затем 2 пропуск
- 1 пропуск, затем 2 работа
- tick(2) зависнет внутри TryLock до завершения первого

Первая горутина захватывает мьютекс и печатает работу; вторая приходит, пока её Sleep ещё идёт, TryLock возвращает false — в логе пропуск. TryLock не блокируется в принципе: либо захват, либо немедленный false. Поменяйте порядок задержек — и другие варианты станут реальностью: guard честно отражает, какая копия пришла первой.

### DelayIfStillRunning и SkipIfStillRunning: обёртки библиотеки

Robfig/cron v3 кладёт те же защиты в обёртки цепочки WithChain, и мьютекс руками писать не обязательно. SkipIfStillRunning делает ровно то, что наш guard: если предыдущий запуск ещё жив, тик пропускается, а если обёртке передан логгер, каждый пропуск попадает в лог без лишнего шума. DelayIfStillRunning — противоположная семантика: тик не пропускается, а ставится в очередь и выполнится после завершения предыдущего запуска. Разница стратегическая. Skip сохраняет периодичность: перекрытия нет, потерянные тики не копятся, работа дожидается следующего периода. Delay гарантирует исполнение каждого тика, но при длительной деградации очередь растёт: тики копятся, и после восстановления задачи посыпятся одна за другой — в худшем случае догоняющая серия на полчаса работы. Для периодических задач с дедлайнами честнее Skip: потерянный период безопаснее лавины догоняющих запусков. Обе обёртки, их сочетания с Recover и тонкости применения — тема [урока про wrappers](/courses/cron/lessons/16-wrappers/); здесь важно понять, что выбор между ними — про продуктовую семантику, а не про вкус.

> **Двойные письма: перекрытие плюс курсор в конце.** Задача напоминаний раз в пять минут брала из базы пачку адресатов и отправляла письма, а курсор обработанных обновляла в самом конце. В час пик выборка затянулась до шести минут, тик наложился, и обе копии прочитали одну и ту же пачку: часть пользователей получила напоминание дважды, поддержка узнала об этом из жалоб, а не из метрик. Диагноз — перекрытие плюс обновление курсора в конце вместо начала. Лечение двухслойное: чекпоинт обновляется по ходу обработки, до побочных эффектов, а запуск защищается SkipIfStillRunning или guard на TryLock. Любая из мер в одиночку устранила бы дубли, но вместе они дают и предсказуемость, и честность данных.

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

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

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

Защита от перекрытия лечит симптом; причина чаще в том, что задача не влезает в интервал. Первый инструмент — бюджет: контекст с дедлайном из [урока про контекст](/courses/cron/lessons/10-kontekst-zadachi/) заставляет задачу либо успевать, либо честно выходить по ошибке, вместо того чтобы висеть и накапливать копии. Второй — очередь воркеров: тик перестаёт быть горутиной с неограниченным параллелизмом и становится заданием в канале фиксированной ёмкости, которое разбирают N воркеров. Перекрытие превращается в управляемую очередь: переполнение видно по метрике заполнения канала, давление регулируется числом воркеров, а идемпотентность защищает от двойной обработки. Третий инструмент — пересмотр расписания: если задача стабильно работает дольше интервала, дело не в guard, а в слишком частом выражении. Выбор — пропуск, очередь или пересмотр частоты — стоит зафиксировать на уровне команды, а [распределённые блокировки](/courses/cron/lessons/21-raspredelennye-blokirovki/) добавят к этому вопрос нескольких инстансов, где локальный мьютекс бессилен.

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

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

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

j.cleanup(ctx)
```

Подсказка: Неблокирующая попытка — TryLock; при пропуске горутина выходит через return; освобождение — Unlock в defer.

Правильные ответы в пропусках: `TryLock`, `return`, `Unlock`

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

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

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

1. `if !j.mu.TryLock() {`
2. `    log.Println("предыдущий запуск ещё работает")`
3. `    return`
4. `}`
5. `defer j.mu.Unlock()`
6. `j.cleanup(ctx)`

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

- **Перекрытие запусков** — Следующий тик стартует раньше, чем закончился предыдущий запуск: каждое срабатывание — go j.Run() без ожидания и семафора. Свойство модели запуска, а не баг.
- **TryLock** — Неблокирующая попытка захвата sync.Mutex, доступна с Go 1.18: занято — false. Guard из трёх строк превращает перекрытие в наблюдаемый пропуск тика.
- **SkipIfStillRunning** — Обёртка библиотеки: если запуск ещё жив, тик пропускается. Сохраняет периодичность, потерянные тики не копятся — работа дожидается следующего периода.
- **DelayIfStillRunning** — Обёртка библиотеки: тик ставится в очередь и выполняется после предыдущего. Гарантирует исполнение, но при деградации копит догоняющие запуски.
- **Когда перекрытие допустимо** — Задача без общего изменяемого состояния, а эффекты устойчивы к повтору: чтение, агрегаты, логи. Остальные случаи — под guard, обёртку или очередь.
- **Чекпоинт до эффектов** — Курсор обработанного обновляется по ходу работы, до писем и списаний: тогда ни перекрытие, ни обрыв при деплое не дублируют побочные эффекты.

> **Где запускать примеры.** Пакетов cron и sync в песочнице сайта нет — там работают fmt, strings, strconv, math, sort, errors и time, так что guard и обёртки проверяются локально из модуля второго урока. Механику перекрытия можно смоделировать в песочнице: канал ёмкости один как семафор, select с default вместо TryLock, горутина с time.Sleep как долгая задача — time и горутины она поддерживает. Но настоящий планировщик и цепочки WithChain гоняйте только в локальном go run: там и детектор гонок, и честные тайминги.

Перекрытие запусков — не авария, а вопрос к дизайну задачи, и планировщик честно оставляет ответ за вами. Модель проста: каждый тик — горутина, выражение задаёт частоту запусков, а не длительность работы. Задача без состояния и с повторяемыми эффектами может жить без защиты; всем остальным нужен выбор — пропуск через TryLock или SkipIfStillRunning, очередь через DelayIfStillRunning или воркеров, либо пересмотр самого расписания. Каким бы ни был выбор, чекпоинт до побочных эффектов и идемпотентность остаются страховкой: защита может дать сбой в момент деплоя, а данные дублироваться из-за этого не должны. Следующий шаг — [конкурентность задач](/courses/cron/lessons/12-konkurentnost-zadach/): перекрытие одной задачи — частный случай параллельной жизни всех задач сразу. Если возвращались с пропуска — [Start и Stop](/courses/cron/lessons/09-start-stop/) и [контекст в задачах](/courses/cron/lessons/10-kontekst-zadachi/) дают базу этого урока. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — счётчик активных запусков и guard, который превращает перекрытие в наблюдаемый пропуск.

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

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

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

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

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

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

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

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

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

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

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