Задача это единица конкурентности в asyncio: корутина, которую цикл событий выполняет независимо от той, что её создала. С задачами связаны два класса ошибок, которые регулярно доезжают до прода: задача, о которой забыли, и отмена, которую проглотили. Разберём жизненный цикл задачи от создания до завершения, с особым вниманием к отмене, потому что именно она отличает надёжный сервис от сервиса, который «иногда зависает при деплое».
Создать и дождаться
asyncio.create_task(coro) ставит корутину в цикл и возвращает объект Task. Корутина начнёт выполняться на следующем обороте цикла, а создавший код продолжит работу без ожидания.
async def enrich_order(order_id: int) -> Order:
customer_task = asyncio.create_task(customers.get(order_id))
items_task = asyncio.create_task(items.list_for(order_id))
customer = await customer_task
items = await items_task
return Order(customer, items)
Два запроса ушли одновременно, и общее время равно самому долгому, а не сумме. То же самое короче пишется через asyncio.gather(customers.get(order_id), items.list_for(order_id)), который принимает корутины, сам оборачивает их в задачи и возвращает список результатов в порядке аргументов.
Правило, о котором легко забыть: цикл событий хранит только слабые ссылки на задачи. Если создать задачу «в фоне» и не сохранить ссылку, сборщик мусора может удалить её на середине выполнения. Фоновые задачи складывают в множество и убирают по завершении, либо используют TaskGroup, о котором следующая статья.
background: set[asyncio.Task] = set()
def fire_and_forget(coro):
task = asyncio.create_task(coro)
background.add(task)
task.add_done_callback(background.discard)
Что возвращает задача
У задачи три исхода: результат (task.result()), исключение (task.exception(), и await task его перебросит) и отмена (task.cancelled() равно True, await task поднимет CancelledError). Исключение внутри задачи, которую никто не ждёт, не теряется молча, но и не останавливает программу: при удалении задачи цикл напишет в лог Task exception was never retrieved. Это сообщение в логах прода всегда означает забытую задачу с ошибкой внутри.
gather с несколькими задачами по умолчанию перебрасывает первое исключение, но не отменяет остальные задачи: они продолжают работать, пока кто-нибудь не дождётся их или не отменит. Проверено на Python 3.14: после исключения из gather соседняя задача остаётся не завершённой и не отменённой. Параметр return_exceptions=True вместо переброса кладёт исключения в список результатов, и тогда их нужно проверить руками.
Отмена шаг за шагом
task.cancel() не убивает задачу. Он просит её завершиться: на ближайшем await, на котором задача приостановлена, внутри неё будет поднято исключение asyncio.CancelledError. Если задача сейчас выполняется без ожидания, отмена подождёт её следующего await. Если задача уже завершилась, cancel() ничего не делает и возвращает False.
Дальше всё зависит от кода задачи. Если он не перехватывает CancelledError, исключение поднимается наружу через все await, выполняются блоки finally и контекстные менеджеры, и задача завершается отменённой. Тот, кто делает await task, тоже получает CancelledError.
async def worker(queue: asyncio.Queue):
async with session_factory() as session: # закроется при отмене
while True:
job = await queue.get() # здесь прилетит CancelledError
try:
await handle(session, job)
finally:
queue.task_done()
Корректная задача это та, которая на отмену освобождает ресурсы и завершается. Для этого достаточно писать обычные with и try/finally: они срабатывают при CancelledError так же, как при любом исключении.
Почему CancelledError нельзя глотать
С Python 3.8 CancelledError наследуется от BaseException, а не от Exception. Это сделано специально, чтобы except Exception: его не ловил. Но код вида except BaseException: или голый except: отмену проглотит, и задача продолжит работать, как будто её не просили остановиться. Результат: при остановке сервиса asyncio.run ждёт такие задачи, uvicorn упирается в таймаут завершения, Kubernetes убивает под по SIGKILL, и запись в базу обрывается на середине.
Если отмену нужно перехватить (записать в лог, сделать компенсацию), её перебрасывают дальше:
try:
await do_work()
except asyncio.CancelledError:
log.info("worker cancelled, rolling back")
await rollback()
raise
Вторая ловушка: await внутри обработчика отмены. Если во время компенсации задачу отменят повторно, CancelledError прилетит снова уже в компенсацию. С Python 3.11 у задачи есть task.cancelling(), счётчик запрошенных отмен, и task.uncancel(); на них построены asyncio.timeout и TaskGroup, в прикладном коде они нужны редко.
Защитить от отмены и ограничить ожидание
asyncio.shield(coro) защищает внутреннюю операцию: если отменят внешний await shield(...), внешний код получит CancelledError, а внутренняя операция продолжится до конца. Это нужно для операций, которые нельзя оборвать на середине: фиксация платежа, запись в журнал аудита. Но защищённая операция живёт дальше без присмотра, поэтому её результат или ошибку должен кто-то забрать, иначе появится то самое exception was never retrieved.
asyncio.wait_for(coro, timeout) ждёт не дольше заданного и при превышении отменяет внутреннюю корутину и поднимает TimeoutError. Проверено: wait_for(shield(task), 0.01) поднимает TimeoutError, а защищённая задача продолжает выполняться и возвращает результат. С Python 3.11 вместо wait_for чаще пишут блок async with asyncio.timeout(seconds):, который удобнее для нескольких await подряд; о нём статья про таймауты.
Остановка приложения
При завершении asyncio.run собирает все незавершённые задачи, отменяет их, ждёт завершения и только потом закрывает цикл. Во FastAPI это происходит после выхода из lifespan: сначала uvicorn перестаёт принимать соединения и ждёт обработчики в полёте, затем выполняется код после yield в lifespan, затем отменяются оставшиеся задачи. Фоновый потребитель Kafka или цикл опроса, запущенный через create_task в lifespan, получит CancelledError именно здесь, и он должен завершиться быстро: закрыть потребитель, зафиксировать смещения. Как уложить это в бюджет завершения пода, рассказывает статья про завершение в Kubernetes.
Глубже: порядок выполнения и имена задачрасширенное
Задача, созданная через create_task, не начинает выполняться немедленно: она встаёт в очередь и получит управление, когда создавший её код дойдёт до await, которому есть чего ждать. Поэтому последовательность task = create_task(f()); x = compute(); await task выполнит compute до первой строки f. Это важно для кода, который рассчитывает на побочный эффект задачи «сразу после создания»: его не будет до ближайшего await. Исключение составляет eager_task_factory из Python 3.12, при котором задача выполняется сразу до первого настоящего ожидания.
Задачам стоит давать имена: asyncio.create_task(consume(), name="kafka-consumer-orders"). Имя попадает в repr задачи, в сообщения Task exception was never retrieved и в дамп asyncio.all_tasks(), и при разборе зависшего завершения оно отличает «висит потребитель Kafka» от «висит анонимная Task-17». Там же полезен task.get_coro() для понимания, какая корутина внутри, и task.print_stack() для текущего места ожидания. Как снять такой дамп с живого процесса, разбирает статья про отладку.
Коротко
create_taskпланирует корутину и возвращает управление,gatherзапускает несколько и ждёт все; результаты в порядке аргументов.- Цикл держит слабые ссылки: фоновую задачу сохраняют в множестве или в
TaskGroup, иначе её может собрать сборщик мусора. - Исключение в никем не ожидаемой задаче даёт в логах
Task exception was never retrieved;gatherпри ошибке не отменяет соседей. cancel()поднимаетCancelledErrorна ближайшемawaitвнутри задачи;finallyиwithсрабатывают, этого достаточно для корректного завершения.CancelledErrorэтоBaseException:except Exceptionего не ловит, а голыйexceptилиexcept BaseExceptionбезraiseломает остановку сервиса.shieldзащищает неделимую операцию от внешней отмены,wait_forиasyncio.timeoutограничивают ожидание и отменяют внутреннюю корутину.- При остановке
asyncio.runотменяет оставшиеся задачи послеlifespan; фоновые потребители должны завершаться по отмене быстро. - Задача стартует на следующем
awaitсоздателя, не мгновенно; давайте задачам имена ради дампов и логов.
Что почитать дальше
- Структурная конкурентность —
TaskGroup, который сам хранит ссылки, отменяет соседей и собирает ошибки. - Таймауты и обратное давление —
asyncio.timeoutи ограничение числа одновременных вызовов. - Завершение Python-сервиса в Kubernetes — куда попадает отмена задач при остановке пода.