Структурная конкурентность: TaskGroup, except*, asyncio.timeout и почему голый create_task — утечка
TaskGroup (3.11+) ограничивает жизнь задач скоупом: упал один — соседи отменяются, ошибки идут как ExceptionGroup под except*. asyncio.timeout превращает отмену в TimeoutError на границе. Дисциплина: перебросить CancelledError, чистить в finally, shield — лишь коммиту.
Нашли это не инженеры, а комплаенс: примерно у 0,3% завершённых платежей не было аудит-записи. Платёжный сервис писал аудит «асинхронно ради латентности» — asyncio.create_task(audit.write(event)), без сохранения ссылки, выстрелил и забыл. В этой одной строке прятались три разных бага. Часть задач сборщик мусора убивал посреди записи: цикл держит лишь слабую ссылку на задачу, а больше её не держал никто. Часть умирала с исключениями, которых никто не видел, — предупреждения Task exception was never retrieved ползли по логам в момент GC, через часы после неудавшейся записи, со стектрейсом, не указывающим никуда полезным. А во время деплоев сервис выключался с десятками аудит-записей в полёте; asyncio.run отменял их посреди INSERT, и строки просто не приземлялись. Лечением был не «более лучший» fire-and-forget — а признание, что задача, чью жизнь не владеет ни один скоуп, есть утечка ресурса со стектрейсом. TaskGroup сделал аудит частью запроса: хендлер не завершается, пока не завершатся его дети, а упавшая запись падает громко — внутри породившего её запроса.
Утечка голого create_task
asyncio.create_task возвращает Task и уходит — и у этой сироты три режима отказа, которые стоит назвать точно. Исчезновение: цикл событий держит на работающие задачи лишь слабую ссылку; если ваш код выбросил возвращённый Task, сборщик мусора может убить его посреди исполнения — документация прямым текстом велит сохранять ссылку. Тихий провал: исключение внутри fire-and-forget-задачи сохраняется на объекте Task и ждёт, что кто-то её await-нет; никто не await-ит, и оно всплывает только при GC задачи строкой Task exception was never retrieved — через минуты или часы, без привязки к запросу, trace ID или алерту. Пережить приложение: на выключении asyncio.run отменяет всё, что ещё работает, — полузаписанные данные, сокеты посреди хендшейка; пропавшие аудит-строки из вступления — ровно это в продакшен-одежде. В литературе по структурной конкурентности у паттерна есть имя — проблема go-statement: любая конструкция, позволяющая работе сбежать из скоупа вызывающего, делает время жизни ресурсов, распространение ошибок и shutdown вопросами без ответа. Структурный ответ: у каждой задачи есть родительский скоуп, который её await-ит, слышит её исключение и отменяет её, когда скоуп умирает.
TaskGroup: скоуп, владеющий своими детьми
Если три режима отказа выше звучат абстрактно, задайте себе конкретный вопрос: когда запись платежа умирает посреди INSERT во время деплоя, кто отвечает за её повтор? С голым create_task ответ — никто. TaskGroup делает ответ структурным.
asyncio.TaskGroup из Python 3.11 — асинхронный контекстный менеджер, владеющий каждой задачей, порождённой через него. В контракте три пункта. Ни один ребёнок не переживает скоуп: выход из блока async with await-ит всех детей — забыть невозможно. Отказ коллективен: когда любой ребёнок кидает (что угодно, кроме asyncio.CancelledError), группа отменяет всех оставшихся детей, дожидается их фактического завершения и затем перебрасывает. Ошибки агрегируются: поскольку во время одной волны отмены могут упасть несколько детей, группа кидает ExceptionGroup со всеми детскими исключениями — не только первым, — и обрабатывается это синтаксисом except*, который сопоставляет и извлекает подгруппы по типу. Заметьте, что except* меняет семантически: для одной ExceptionGroup могут отработать несколько клауз except*, каждая получит подгруппу совпавших исключений, а несовпавшее пропагирует дальше. Сравните с gather из прошлого урока: при отказе gather отдаёт вам первое исключение, пока выжившие соседи продолжают работать без присмотра — с побочными эффектами, открытыми соединениями и без того, кто их await-нет. Отмена-при-отказе TaskGroup — разница между ошибкой и ошибкой плюс утечкой.
import asyncio
async def enrich_order(order_id: int) -> dict:
try:
async with asyncio.TaskGroup() as tg:
user = tg.create_task(fetch_user(order_id))
items = tg.create_task(fetch_items(order_id))
fraud = tg.create_task(fraud_score(order_id))
# здесь: все три завершились успешно — результаты безопасно читать
return {"user": user.result(), "items": items.result(),
"fraud": fraud.result()}
except* FraudServiceError as eg: # подгруппа совпавших ошибок
for exc in eg.exceptions:
log.warning("fraud check failed: %s", exc)
return await enrich_without_fraud(order_id)
# любой другой ExceptionGroup передаётся вызывающему нетронутымВнутри TaskGroup ребёнок A кидает ValueError через 100 мс, пока дети B и C (с очисткой в finally) ещё работают. Что делает группа?
Таймауты и отмена: один механизм под капотом
asyncio.timeout (3.11+) заменил старый wait_for композируемым контекстным менеджером, и понять его — значит понять саму отмену. Отмена в asyncio — не рубильник: task.cancel() организует возбуждение CancelledError внутри задачи в её следующей точке await — задача, которая не await-ит, неотменяема (она заодно блокирует цикл, так что у неё проблемы посерьёзнее). Дальше исключение раскручивает стек корутины как любое другое, по пути исполняя finally-блоки и выходы async with. asyncio.timeout(5) строится поверх: он ставит таймер; если тело ещё работает, когда таймер сработал, таймер отменяет тело, а контекстный менеджер — на собственной границе — конвертирует этот CancelledError в TimeoutError. Дизайн «конверсия на краю» и делает вложенные таймауты композируемыми: внутренний таймаут конвертирует только отмены, которые вызвал сам; внешняя отмена проходит сквозь нетронутой. Доверие к этой машинерии держится на двух дисциплинах. Никогда не глотать CancelledError: голый except Exception безопасен (CancelledError наследуется от BaseException ровно затем, чтобы ускользать из этой сети), но except BaseException: pass или except asyncio.CancelledError: continue ломает таймауты, отмену TaskGroup и shutdown сервера одним махом — если перехватили ради очистки, перебросьте. Очистка должна быть устойчива к отмене: код в finally исполняется, пока задачу отменяют, и если очистка сама await-ит (закрытие async-соединения, откат транзакции), она может получить вторую отмену. Для очистки, которая обязана завершиться, в 3.11+ есть паттерн экранирования одного критичного await.
async def transfer(src, dst, amount):
async with asyncio.timeout(5): # cancels body at T+5s
await debit(src, amount)
# коммит не должен быть разорван таймаутом/отменой на полпути:
await asyncio.shield(credit_and_commit(dst, amount))
# таймаут сработал? CancelledError стал TimeoutError ЗДЕСЬ, на границе
async def handler():
try:
await transfer("a", "b", 100)
except TimeoutError:
... # the transfer either fully committed (shield) or fully debit-rolled-back▸Почему это работает
Почему asyncio.shield — скальпель, а не пожимание плечами? shield(coro) заворачивает работу в Task и поглощает внешнюю отмену: await-ящая корутина всё равно немедленно получает CancelledError, но внутренняя задача дорабатывает до конца, отцепившись. Это ровно то, что нужно двухфазному коммиту, который нельзя порвать, — и ровно не то, что нужно как общий «не отменяйте меня»: экранированная работа теперь сирота с теми же проблемами ненаблюдаемых исключений, что у голого create_task, shutdown всё равно отменит её при закрытии цикла, а экранирование всего подряд превращает таймауты в декорацию. Продакшен-правило: экранируйте наименьший await, который обязан быть атомарным, держите путь, который await-ит или логирует его результат, и трактуйте каждый shield на ревью как утверждение, требующее обоснования. С 3.13 TaskGroup корректно обрабатывает и коллизию — внешнюю отмену, прилетевшую, пока группа уже аварийно сворачивается, — не теряя ни один из сигналов.
Корутина оборачивает retry-цикл в `except BaseException: log_and_continue()`, чтобы переживать нестабильные апстримы. Теперь она работает под asyncio.timeout(5). Что ломается?
Миграция fire-and-forget
У аудит-бага из вступления есть структурное переписывание с явными компромиссами. Встроить — tg.create_task(audit.write(event)) внутри TaskGroup запроса — и запись под присмотром, с ретраями и на виду, ценой присоединения её латентности к запросу (часто это нормально: INSERT — единицы миллисекунд). Если латентность правда нельзя тратить, структурный fire-and-forget — это долгоживущая воркер-задача, принадлежащая скоупу времени жизни приложения и питающаяся из очереди: хендлеры делают queue.put_nowait(event) (микросекунды), воркер разгребает её внутри собственного TaskGroup, а shutdown закрывает скоуп, который опустошает очередь до смерти цикла. В обоих случаях у каждой задачи есть владелец, у каждого исключения — адрес, а shutdown — свойство структуры, а не молитва. Что ревью не переживает: create_task с комментарием, обещающим, что кто-нибудь когда-нибудь это await-нет. Теперь, когда видите голый create_task в PR, задаёте три вопроса: кто держит ссылку, кто видит исключение, кто гарантирует слив при shutdown — и не аппрувите, пока все три не закрыты.
- 01Назовите три режима отказа fire-and-forget create_task и как TaskGroup устраняет каждый.
- 02Проследите по шагам, что происходит, когда asyncio.timeout(5) истекает вокруг тела с открытой транзакцией БД — и где здесь shield и finally.
Неструктурная конкурентность отказывает тремя конкретными способами: слабая ссылка цикла позволяет GC собрать незакреплённый Task в полёте; ненаблюдаемое исключение лежит на объекте Task, пока сборка мусора не залогирует ‘Task exception was never retrieved’ часами позже; а shutdown отменяет сирот посреди записи — инцидент с аудит-дырой в одну строку create_task. TaskGroup (3.11+) делает владение структурным: дети, порождённые через tg.create_task, не переживают скоуп async with; первый не-отменочный отказ отменяет всех соседей, скоуп дожидается их смерти через finally, и всё поднятое приходит одной ExceptionGroup под except* — где несколько клауз могут получить каждая свою подгруппу, а несовпавшее пропагирует. gather, напротив, сообщает первую ошибку, пока соседи работают без присмотра, — ошибка плюс утечка. Отмена — один механизм от края до края: cancel() впрыскивает CancelledError в следующей точке await цели; он раскручивается как обычное исключение через finally и async with; asyncio.timeout ставит таймер, отменяющий тело, и конвертирует именно этот CancelledError в TimeoutError на собственной границе — поэтому вложенность композируется. Дисциплины следуют из механизма: не глотать CancelledError (он BaseException ровно затем, чтобы except Exception оставался безопасным; поймали ради очистки — перебросьте), писать устойчивые к отмене finally (await-ящую очистку могут отменить повторно) и экранировать только наименьший await, который обязан закоммититься, — shield это намеренная, проверяемая на ревью сирота. Fire-and-forget мигрирует либо во внутрискоуповый присмотр, либо в очередь, разгребаемую воркером со скоупом времени жизни приложения.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.