---
title: "Контекст в задачах: отмена и дедлайны"
description: "Cron context в задачах: почему AddFunc не передаёт контекст, WithTimeout на запуск, общий корень для shutdown, NewRequestWithContext и отмена долгих операций."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/10-kontekst-zadachi/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Контекст в задачах: отмена и дедлайны

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

Cron context — тема, которую библиотека обходит: robfig/cron не передаёт контекст в задачи и не отменяет их на Stop, поэтому отмена и дедлайны — часть вашей задачи. Пока задача короткая, разницы не видно; как только в ночную синхронизацию попадает внешний API без таймаута, Stop повисает, деплой упирается в лимит оркестратора, а процесс прибивают посреди записи. Разберём, как устроить отмену: дедлайны на запуск, общий корень для graceful shutdown и честные точки отмены внутри долгих операций.

Разберём инцидент, который случается в каждой второй команде с планировщиком. Ночная задача синхронизирует каталог с внешним API: двести запросов, каждый по клиенту без таймаута, потому что «сеть же внутренняя». В три ночи API залипает, один запрос висит двадцать минут, задача не заканчивается. В семь утра деплой: SIGTERM, c.Stop() — а задачи-то работают, контекст от Stop закроется только после их завершения, то есть не закроется. Под упирается в grace period, оркестратор прибивает контейнер жёстко, транзакция обрывается на середине. Ни один уровень этой истории не спасает сам по себе: Stop не умеет отменять, горутину в Go нельзя убить извне, а клиент без таймаута будет ждать вечно. Спасает только то, что задача сама умеет останавливаться — и механизм для этого в Go один: контекст с отменой. Причём уметь останавливаться должны все участники: и HTTP-запрос, и запрос к базе, и воркеры, которые задача сама породила, — отмена по цепочке обрывается там, где кто-то о ней не знает.

### Cron context: почему AddFunc его не передаёт

Сигнатура задачи — func() или метод Run без параметров: планировщик не передаёт в неё ни время тика, ни контекст. Это осознанное решение, а не недоработка: у библиотеки нет мнения, сколько должна жить задача и кто вправе её отменять — у кого-то это дедлайн на весь запуск, у кого-то отмена по сигналу сервиса, у кого-то ничего отменять нельзя. Поэтому cron context распределяется так: планировщик запускает горутину и больше не вмешивается, а задача сама создаёт свой контекст — обычно context.WithTimeout от корня сервиса — и проводит его во все блокирующие операции. Следствие важно зафиксировать: Stop не отменяет задачи, а ждёт их, поэтому без собственных контекстов внутри дожим может длиться вечно. Отмена задачи go-кода — это всегда её собственная дисциплина: таймауты на операции, проверка Done в циклах, передача контекста в запросы и запросы к базе. Зато и свобода полная: задача сама решает, что для неё значит «успеть» — бюджет на весь запуск, лимит на попытку или количество пакетов за тик, — и библиотека не навязывает ни одного из вариантов.

### Дедлайн на весь запуск задачи

```go
func (j *SyncJob) Run() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, j.apiURL, nil)
    resp, err := j.client.Do(req)
    if err != nil {
        j.log.Error("синхронизация: запрос", zap.Error(err))
        return
    }
    defer resp.Body.Close()

    j.process(resp.Body)
}
```

Задача сама выдаёт себе бюджет: WithTimeout на пять минут на весь запуск. Контекст идёт в HTTP-запрос через NewRequestWithContext — transport перестанет ждать ответ, как только дедлайн истечёт, и Do вернёт ошибку про deadline exceeded. Позже в уроке заменяем context.Background на корень сервиса — сейчас фиксируем механику отмены.

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

**Вопрос:** Дедлайн контекста истёк, пока http-запрос из задачи ещё ждал ответа. Что произойдёт?

- Горутина задачи убивается, процесс продолжает работать
- ✅ Do вернёт ошибку с context.DeadlineExceeded, горутина доживёт до return
- Планировщик перезапустит задачу с начала
- Ничего: контекст не влияет на сетевые вызовы

Контекст не убивает горутины — в Go их нельзя убить извне в принципе. Он кооперативен: блокирующая операция, в которую контекст передан, прерывается и возвращает ошибку ctx.Err, обычно context.DeadlineExceeded. Дальше задача обязана сама выйти через return — этим она отличается от процесса, который можно прибить.

### Общий корень: контекст в полях структуры

Собственный Background в каждой задаче решает дедлайны, но не связывает задачи с жизнью сервиса: отмены при остановке до них не доезжают. Мост — корневой контекст сервиса, который задача получает в поле через конструктор, точно так же, как стор и логгер. Сервис создаёт корень context.WithCancel на старте, задачи деривируют от него свои таймауты, а обработчик SIGTERM при остановке делает cancel корня — и все задачи одновременно видят сигнал сворачиваться. Рабочий порядок shutdown получается такой: отменить корневой контекст, затем c.Stop() — новые тики прекратятся, а стартовавшие в промежутке мгновенно выйдут по уже отменённому контексту, — затем дожим по контексту от Stop. Так остановка cron превращается из лотереи в последовательность: сначала задачи узнали, что пора, потом планировщик перестал подкидывать новую работу, потом процесс дождался и закрыл соединения. Полную схему с сигналами собираем в [уроке про graceful shutdown](/courses/cron/lessons/19-graceful-cron/).

### Задача с корнем сервиса и таймаутом на запуск

```go
type SyncJob struct {
    ctx    context.Context // корень сервиса, отменяется при shutdown
    store  *Store
    client *http.Client
    log    *zap.Logger
    limit  time.Duration
}

func (j *SyncJob) Run() {
    ctx, cancel := context.WithTimeout(j.ctx, j.limit)
    defer cancel()

    batches, err := j.store.LoadPending(ctx)
    if err != nil {
        j.log.Error("выборка", zap.Error(err))
        return
    }
    for _, b := range batches {
        if err := j.process(ctx, b); err != nil {
            j.log.Warn("пакет пропущен", zap.Error(err))
        }
    }
}
```

Поле ctx — корневой контекст сервиса: при отмене все деривации в задачах срабатывают одновременно. Run выдаёт себе таймаут на запуск от корня и проводит ctx дальше — в выборку из базы и обработку пакетов. Деривация через WithTimeout — правило гигиены: задача не знает, из чего собран корень, и добавляет свой бюджет поверх.

### Проверка: порядок остановки

**Вопрос:** Задачи хранят корневой контекст сервиса. Какой порядок остановки рабочий?

- Сначала Stop, потом дожим; корневой контекст отменять не нужно
- ✅ Сначала отменить корневой контекст, затем Stop, затем дожим по контексту от Stop
- Одновременно отменить контекст и вызвать Stop из разных горутин, дожим не нужен
- Сначала закрыть базу, потом Stop, потом отменить контекст

Отмена корня зажигает отмену во всех текущих задачах; Stop останавливает новые тики, и случайно стартовавшие в промежутке задачи мгновенно выходят по уже отменённому контексту; дожим по ctx.Done от Stop даёт задачам время спокойно завершить начатое. Закрывать соединения до дожима нельзя — задачи ещё пишут.

### Что напечатает задача?

```go
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

select {
case <-time.After(3 * time.Second):
    fmt.Println("работа сделана")
case <-ctx.Done():
    fmt.Println("отмена:", ctx.Err())
}
```

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

- работа сделана: select выберет первое готовое плечо
- ✅ отмена: context deadline exceeded
- отмена: context canceled
- Программа зависнет: оба плеча не готовы

Таймер дедлайна сработает раньше таймера работы: через секунду Done закроется, и select пойдёт по второму плечу. ctx.Err для истёкшего дедлайна — context.DeadlineExceeded; context.Canceled возвращается при ручном cancel. Обе строки печатаются за секунду — долгая операция сократилась до дедлайна, что и требуется от задачи.

### Отмена задачи: Done в циклах и *Context-методы

Отмена работает там, куда контекст дотянули, поэтому задача должна прокидывать его во все долгие места. Сетевые вызовы — NewRequestWithContext и клиент с таймаутами на уровне транспорта. Запросы к базе — методы с суффиксом Context: QueryContext, ExecContext, BeginTx; без них отмена обрывается на границе пакета драйвера. Собственные циклы — select между работой и ctx.Done: после каждого пакета короткая проверка, не пора ли сворачивать. Горутины, которые задача сама запускает — воркеры, пайплайны — получают контекст параметром и выходят по Done, иначе отмена родителя не дойдёт до детей. Итоговая дисциплина для долгой задачи: контекст везде, ошибки отмены обрабатываются отдельно от бизнес-ошибок — это штатный выход, а не авария, — и лог с причиной. Механику горутин и каналов, на которых это стоит, вы знаете из [урока про горутины](/courses/go-advanced/lessons/04-gorutiny/) и [урока про сам контекст](/courses/go-advanced/lessons/09-context/).

### Дедлайн запуска против таймаутов шагов

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

> **context.Background внутри Run: отмены не дойдут.** Коварная ошибка выглядит безобидно: контекст для HTTP-запроса создаётся от context.Background прямо внутри задачи. Дедлайны работают, тесты зелёные — а при остановке сервиса задачи продолжают работать, потому что их контексты не связаны с корнем и отмена сервиса до них не доходит. Реальный кейс: деплой висел весь grace period, задачи дописывали свои двадцать минут, оркестратор прибил под жёстко. Диагноз ставится поиском: context.Background внутри задач — маркер того, что связка с жизненным циклом отсутствует. Правило: Background живёт в main при создании корня, дальше в задачах — только деривации.

### Откуда брать контекст в задаче

| Источник | Как выглядит | Когда выбирать |
| --- | --- | --- |
| context.Background | context.WithTimeout(context.Background(), d) | прототипы и утилиты; в сервисе — маркер проблемы |
| Поле задачи от корня сервиса | context.WithTimeout(j.ctx, j.limit) | задачи, которые обязаны сворачиваться при shutdown |
| Деривация на операцию | context.WithTimeout(runCtx, 5*time.Second) | шаги задачи: запросы, выборки, внешние вызовы |

### Соберите цикл с проверкой отмены

Впишите создание контекста с дедлайном, проверку канала отмены и получение причины отмены.

```go
ctx, cancel := context.___(j.ctx, 30*time.Second)
defer cancel()

for _, batch := range batches {
    select {
    case <-ctx.___():
        return ctx.___()
    default:
    }
    j.process(ctx, batch)
}
```

Подсказка: Дедлайн выдаёт context.WithTimeout; канал отмены — ctx.Done; причину вернёт ctx.Err.

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

### Соберите Run задачи с отменой

Расставьте строки в порядке выполнения: контекст с дедлайном, выборка, проверка ошибки, обработка и финальный лог.

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

1. `ctx, cancel := context.WithTimeout(j.ctx, j.limit)`
2. `defer cancel()`
3. `batches, err := j.store.LoadPending(ctx)`
4. `if err != nil { j.log.Error("выборка", zap.Error(err)); return }`
5. `for _, b := range batches { j.process(ctx, b) }`
6. `j.log.Info("синхронизация завершена")`

### Карточки: контекст в задачах

- **Кто даёт контекст** — Никто: AddFunc и AddJob передают функцию без параметров. Контекст задача создаёт сама — деривацией от корня сервиса или Background в утилитах.
- **WithTimeout на запуск** — Бюджет всего запуска: ctx, cancel := context.WithTimeout(j.ctx, limit) и defer cancel(). Внутри — свои таймауты на шаги, деривированные от этого контекста.
- **Отмена кооперативна** — Горутины не убиваются: контекст прерывает только те операции, в которые передан. Циклы проверяют Done через select, ошибки отмены — штатный выход, а не авария.
- **Контекст в операции** — HTTP — NewRequestWithContext, база — QueryContext и ExecContext, свои воркеры — параметром. Куда контекст не дотянут, там отмена не работает.
- **Порядок shutdown** — Отменить корневой контекст, затем Stop — новые тики прекратятся, затем дожим по ctx.Done от Stop с дедлайном, и только после — закрытие соединений.
- **Обрыв посреди работы** — Задача обязана переживать отмену без последствий: пакеты до отмены зафиксированы, остальные подхватит следующий тик. За это отвечают идемпотентность и чекпоинты.

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

Контекст — это договор задачи с сервисом о том, как прекращаться. Планировщик со своей стороны даёт три вещи: запуск без отмены, Stop с дожимом и свободу в устройстве отмены. Задача отвечает дедлайном на запуск, прокидыванием контекста во все блокирующие места и честным выходом по Done. Связка через корень сервиса превращает набор независимых таймеров в единый жизненный цикл, где shutdown предсказуем, а отмена — не авария, а штатный режим, предусмотренный с первого дня. Следующий шаг — [перекрытие запусков](/courses/cron/lessons/11-perekrytie-zapuska/): что делать, когда задача не уложилась даже в свой дедлайн. Дальше по маршруту: [graceful shutdown](/courses/cron/lessons/19-graceful-cron/) — полная схема с сигналами, [утечки горутин](/courses/cron/lessons/20-utechki-gorutin/) — что происходит с горутинами, чей контекст никто не передал. Если возвращались — [Start и Stop](/courses/cron/lessons/09-start-stop/) дают базу этого урока. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — синхронизация, которая сворачивается по сигналу за фиксированный бюджет.

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

- AddFunc и AddJob контекст не передают: отмена и дедлайны — ответственность самой задачи.
- Бюджет запуска выдаёт WithTimeout от корня сервиса; внутри — таймауты на отдельные шаги.
- Отмена кооперативна: контекст прерывает только операции, в которые передан, горутины не убиваются.
- Контекст идёт во все долгие места: NewRequestWithContext, QueryContext, Done в циклах, воркеры параметром.
- Порядок shutdown: отменить корень, Stop, дожим по ctx.Done с дедлайном, затем закрытие зависимостей.
- Обрыв посреди работы должен быть штатным: идемпотентность и чекпоинты переживают отмену.

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

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

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

### Почему нельзя просто убить горутину задачи?

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

### Задача отменена посреди транзакции в базу. Что с данными?

Ровно то, что вы спроектировали: транзакция без коммита откатится сама при закрытии соединения, зафиксированными останутся только коммиты до отмены. Поэтому batch-задачи строят идемпотентно — короткие транзакции на пакет, курсор обработанного обновляется после коммита, а следующий тик продолжает с чекпоинта.

### Stop отменяет задачи или нет?

Нет: Stop прекращает запускать новые тики и возвращает контекст, который закроется, когда уже запущенные задачи завершатся сами. Отмена работающих задач — это отмена их контекстов, и связать её с остановкой сервиса можно порядком из урока: сначала cancel корня, затем Stop.
