Перейти к содержанию

192 уроков, 14 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 10 из 20 Средний 30 мин 130 XP

Отмена задач: cancel и CancelledError

Задача больше не нужна — cancel() просит её остановиться. Отмена кооперативная: CancelledError возникает внутри корутины на её текущем await, а убирается задача сама — в finally.

Редакция Питоники

В прошлом уроке таймаут отменял корутину за кулисами. Теперь берём отмену в свои руки: пользователь закрыл вкладку — отменяем загрузку, пришёл новый апдейт — отменяем устаревший расчёт, кнопка «стоп» — останавливаем воркеры. У всего этого один механизм: метод task.cancel() у задачи и исключение CancelledError внутри корутины.

Главное, что стоит понять заранее: отмена в asyncio кооперативная. cancel() — это не выстрел, а вежливая просьба, доставляемая письмом с именем CancelledError. Задача сама решает, где остановится, — на ближайшем await — и сама прибирается. Отменить корутину силой нельзя; можно только попросить и дождаться.

Где вы встретитесь с отменой вживую? Почти в каждом асинхронном сервисе: пользователь нажал «стоп» — бот отменяет задачу рассылки; клиент закрыл вкладку — веб-сервер отменяет обработку запроса, чтобы не жечь ресурсы впустую; обход каталога нашёл нужное раньше конца — оставшиеся URL отменяются. И в каждом случае вопрос тот же: кто закроет файлы и снимет флаги, если работа остановилась на середине? На него и отвечает этот урок.

Первое знакомство: cancel и уборка в finally

Запустим «загрузку», которая собиралась работать 30 секунд, и отменим её через секунду. Уборку — закрытие файла, снятие флага — поместим в finally: этот блок выполняется и на успехе, и на падении, и на отмене:

отмена доходит до корутины, finally прибирает
import asyncio

async def downloader():
    try:
        print("Загрузка началась")
        await asyncio.sleep(30)               # очень долгая работа
        print("Загрузка завершилась сама")    # до этой строки не дойдёт
    finally:
        print("Уборка: закрыл файл и снял флаг")

async def main():
    task = asyncio.create_task(downloader())
    await asyncio.sleep(1)
    print("Передумали - отменяю")
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("Задача подтвердила отмену")

asyncio.run(main())
Вывод
Загрузка началась
Передумали - отменяю
Уборка: закрыл файл и снял флаг
Задача подтвердила отмену

Разбираем цепочку. task.cancel() доставляет CancelledError внутрь downloader — прямо на его await asyncio.sleep. Строка «Загрузка завершилась сама» не выполняется никогда: работа прервана. finally при этом честно отрабатывает — ресурс закрыт. Затем отменённая задача пробрасывает CancelledError дальше, и await task в main ловит его: так отменяющая сторона узнаёт, что задача действительно остановилась, а не успела завершиться.

Обрати внимание: await task нужен. cancel() только отправляет запрос — настоящий останов произойдёт, когда корутина дойдёт до своего await и получит исключение. Без await task вы не узнаете, когда уборка закончилась, а Python может ругаться на нечитанную отмену. Дождаться задачи после cancel() — та же гигиена, что закрыть файл после открытия.

Дожидание важно и для порядка запуска: следующий этап должен стартовать только после подтверждённой остановки предыдущего. Классика — перезапуск воркера с новыми настройками: отменили, дождались CancelledError (значит, соединения закрыты и флаги сняты), и только потом подняли новый. Если не дожидаться, два экземпляра воркера какое-то время живут параллельно и наступают друг другу на общий ресурс — баг, который воспроизводится «через раз» и отравляет жизнь именно этой непредсказуемостью.

Питфолл: проглоченная отмена

А что, если корутина поймает CancelledError и не будет его пробрасывать? Формально всё сработает: исключение перехвачено, программа не упала. По смыслу — катастрофа: отмена потеряна. Проверим, что увидит вызывающий код:

отмена проглочена — await прошёл как успешный
import asyncio

async def stubborn():
    try:
        await asyncio.sleep(30)
    except asyncio.CancelledError:
        print("Отмену поймал и проглотил")
        # нет raise - задача вернётся как успешная

async def main():
    task = asyncio.create_task(stubborn())
    await asyncio.sleep(1)
    task.cancel()
    try:
        await task
        print("await прошёл тихо: отмена потеряна")
    except asyncio.CancelledError:
        print("Отмена дошла до вызывающего")

asyncio.run(main())
Вывод
Отмену поймал и проглотил
await прошёл тихо: отмена потеряна

Правильный паттерн: уборка и raise

Канонический вид корутины, которую можно безопасно отменять: перехватить CancelledError, сохранить прогресс и обязательно raise. Напишем воркера, который шагает по секундам и на отмене печатает, где его остановили:

поймал - прибрался - пробросил
import asyncio

async def worker():
    try:
        for i in range(1, 6):
            print("шаг", i)
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        print("Останавливаюсь, сохраняю прогресс")
        raise                                 # отмена обязана продолжить путь

async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(2)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("Задача остановлена корректно")

asyncio.run(main())
Вывод
шаг 1
шаг 2
Останавливаюсь, сохраняю прогресс
Задача остановлена корректно

Два шага — и отмена: воркер успел напечатать шаг 1 и шаг 2, после чего вместо шага 3 получил CancelledError, оставил пометку о прогрессе и передал исключение дальше. Так строятся остановимые конвейеры: воркер ничего не знает о том, кто и зачем его отменяет, — он просто прибирается и подчиняется.

«Сохраняю прогресс» в настоящем коде — это не print, а запись куда-то: смещения обработанных страниц в файл или базу, отметки «документ недокачан» в общий словарь. Принцип тот же, что с флагами в конце урока: отмена — обычный исход, и состояние после неё должно быть консистентным. Скучное правило, которое окупается при первом же падении: после долгой операции спрашивай себя «если меня убьют прямо здесь, что увидит следующий запуск?» — и именно это приводи в порядок в except или finally.

Останавливаем пачку и проверяем честность

В реальных программах отменяют не по одной задаче, а пуулом: остановили бота — гасим всех воркеров. Каждому cancel(), затем общий await через gather с return_exceptions=True — флаг из урока 11 нужен, потому что отменённые задачи вернут CancelledError, и gather без флага упал бы на первой же:

останавливаем всех разом
import asyncio

async def worker(name):
    try:
        print(name, "работает")
        await asyncio.sleep(30)
    finally:
        print(name, "прибралась")

async def main():
    tasks = [asyncio.create_task(worker(n)) for n in ("Альфа", "Бета", "Гамма")]
    await asyncio.sleep(1)
    for t in tasks:
        t.cancel()
    await asyncio.gather(*tasks, return_exceptions=True)
    print("Все задачи остановлены")

asyncio.run(main())
Вывод
Альфа работает
Бета работает
Гамма работает
Альфа прибралась
Бета прибралась
Гамма прибралась
Все задачи остановлены

Полезно знать и обратную ситуацию: отменять нечего. cancel() по уже завершившейся задаче безвреден — он возвращает False, и ничего не происходит. Это позволяет писать код остановки без проверок «а жива ли ещё задача»:

Заглянем под капот массовой остановки: CancelledError доставляются всем задачам почти одновременно, поэтому и уборки идут параллельно — каждая задача прибирается в своём темпе, и gather собирает их по мере готовности. Никакой согласованной очереди выключения нет: если одной задаче важно выключиться после другой (например, сначала закрыть приём заданий, потом воркеров), этот порядок выстраивают вручную — отдельными await, как в конвейере из урока 13, где продюсера дожидают до отмены воркеров. cancel() ничего не знает о ваших зависимостях — он просто раздаёт просьбы остановиться.

У массовой остановки есть свой подвох: уборка может быть небыстрой. Если каждый воркер на отмене закрывает соединение и записывает итог, десяток воркеров остановятся не мгновенно. Поэтому остановку пачки тоже полезно ограничить по времени: обернуть собирающий gather в wait_for с разумным лимитом и по истечении принять решение — подождать ещё, выругаться в лог или уйти принудительно. Иначе остановка превращается в такую же вечную загрузку, против которой мы ставили таймауты в прошлом уроке.

cancel по завершённой задаче безвреден
import asyncio

async def quick():
    await asyncio.sleep(1)
    return "готово"

async def main():
    task = asyncio.create_task(quick())
    await asyncio.sleep(2)            # задача уже завершилась сама
    print("cancel вернул:", task.cancel())
    print("Результат:", task.result())

asyncio.run(main())
Вывод
cancel вернул: False
Результат: готово

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

отмена до старта: ни шага не сделано
import asyncio

async def job():
    print("эта строка не напечатается")
    await asyncio.sleep(1)

async def main():
    task = asyncio.create_task(job())
    task.cancel()                     # задача ещё не сделала ни шага
    try:
        await task
    except asyncio.CancelledError:
        print("Отменена до старта: ни шага не сделано")

asyncio.run(main())
Вывод
Отменена до старта: ни шага не сделано

Уборка с флагами: состояние после отмены

Брифинг перед боем: отменённая задача оставляет после себя состояние — открытые файлы, поднятые флаги, арендованные подключения. finally — место, где состояние приводится в порядок. Проверим это явно: заведём объект с двумя флагами и убедимся, что после отмены оба сняты:

после отмены файл закрыт, флаг снят
import asyncio

class Job:
    def __init__(self):
        self.file_open = True
        self.busy = True

async def worker(job):
    try:
        print("работаю, файл открыт:", job.file_open)
        await asyncio.sleep(30)
    finally:
        job.file_open = False
        job.busy = False
        print("уборка: файл закрыт, флаг снят")

async def main():
    job = Job()
    task = asyncio.create_task(worker(job))
    await asyncio.sleep(1)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass
    print("После отмены: файл открыт -", job.file_open, "| занят -", job.busy)

asyncio.run(main())
Вывод
работаю, файл открыт: True
уборка: файл закрыт, флаг снят
После отмены: файл открыт - False | занят - False

Та же гигиена в боевом коде: сессии, файлы и соединения закрываются в finally — независимо от причины выхода. Фрагмент настоящего загрузчика (сети в песочнице нет — это скелет для локального запуска):

Две детали делают такую уборку надёжной на практике. Первая — идемпотентность: finally обязан выдержать повторный вызов (закрытый файл можно закрывать снова, снятый флаг — снимать ещё раз), потому что в длинной цепочке try/finally один и тот же блок может отработать больше одного раза. Вторая — краткость: в finally не пишут бизнес-логику и не делают сетевых вызовов «на всякий случай» — каждая операция там может сама получить отмену и остаться недоделанной. Освободил ресурс, зафиксировал состояние — и всё.

боевой шаблон: ресурс освобождается при отмене
import asyncio
import aiohttp

async def download(url):
    session = aiohttp.ClientSession()
    try:
        async with session.get(url) as resp:
            return await resp.text()
    finally:
        await session.close()       # выполнится и при отмене

async def main():
    task = asyncio.create_task(download("https://example.com/"))
    await asyncio.sleep(2)
    task.cancel()

asyncio.run(main())
Нужен aiohttp и сеть. finally с session.close() — ключ: отмена прервёт await внутри with, но закрытие сессии всё равно случится. Это тот же принцип, что с флагами в примере выше.

Что дальше

Отмена — это исключение, летящее по обычным правилам. Значит, в конкурентном коде летают и обычные ошибки: ValueError из одной корутины способен уронить весь gather. Следующий урок — исключения в конкурентном коде: как изолировать падения, собирать статистику и не дать одной сломанной задаче топить пачку. А применение всего этого вживую — в разделе про aiogram, где остановка polling отменяет воркеры-хендлеры точно так же.

Перед тем как двинуться дальше, короткая самопроверка: возьмите свою последнюю корутину с ресурсом — файлом, сессией, флагом — и мысленно отмените её на самом середине. Что останется после? Если уверенного ответа «чистое состояние» нет, вы знаете, какую строку кода открыть первой. Отмена — навык, а не теория.

Отмена в asyncio кооперативная: cancel() лишь просит остановиться, а убирается задача сама — в finally, который работает при любом исходе.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import asyncio

async def job():
    try:
        await asyncio.sleep(30)
    finally:
        print("уборка")

async def main():
    task = asyncio.create_task(job())
    await asyncio.sleep(1)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("остановлена")

asyncio.run(main())
import asyncio

async def job():
    try:
        await asyncio.sleep(30)
    except asyncio.CancelledError:
        return "спасся"

async def main():
    task = asyncio.create_task(job())
    await asyncio.sleep(1)
    task.cancel()
    print(await task)

asyncio.run(main())
import asyncio

async def job():
    print("старт")
    await asyncio.sleep(1)
    print("не дойдёт")

async def main():
    task = asyncio.create_task(job())
    await asyncio.sleep(0)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("отменено")

asyncio.run(main())
Проверь себя
0 / 5

1. Что происходит внутри корутины после вызова task.cancel()?

2. Корутина поймала CancelledError, напечатала сообщение и не вызвала raise. Чем это кончится?

3. Зачем await task после task.cancel()?

4. Как корректно остановить список задач разом?

5. task.cancel() вызван по уже завершившейся задаче. Что вернёт cancel()?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери останавливаемого воркера: он раз в секунду печатает «работаю: шаг N» (всего планировал три шага). Главная корутина отменяет его через 2 секунды. Воркер обязан по любой причине выхода напечатать «Уборка после отмены» (подсказка: finally), а main — подтвердить печатью «Задача отменена».

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

Как отменить задачу в asyncio?

Вызвать task.cancel() и дождаться остановки через await task, поймав asyncio.CancelledError. Отмена кооперативная: исключение возникает внутри корутины на её текущем await, корутина успевает выполнить finally-уборку. По уже завершённой задаче cancel() безопасен — возвращает False.

Почему нельзя глотать CancelledError в asyncio?

Пойманный без raise CancelledError превращает отмену в обычное завершение: await вернёт результат (или None), и отменяющая сторона решит, что задача успешно доделала работу. Сломаются таймауты wait_for, остановка воркеров и логика «стоп-кнопки». Правильный шаблон: except CancelledError: уборка; raise.

Выполняется ли finally при отмене задачи?

Да: CancelledError — обычное исключение с точки зрения Python, поэтому finally и except отрабатывают штатно. Именно finally — каноническое место уборки при отмене: закрыть файл, снять флаг, закрыть сетевую сессию. После уборки отмену нужно пробросить дальше через raise.

Чем shield отличается от обычного await?

asyncio.shield(task) защищает внутреннюю задачу от отмены: отменить можно обёртку, но сама задача продолжит работать до конца. Применяется для критических секций, которые нельзя прерывать, — например, сохранения состояния. Без такой необходимости shield не используют: он усложняет остановку программы и её отладку.

Понравился урок? Сошлитесь на него

«Отмена в asyncio кооперативная: cancel() лишь просит остановиться, а убирается задача сама — в finally, который работает при любом исходе.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.