async/await: вызов ничего не выполняет — и отсюда три ошибки, которые не падают
Вызов async-функции не запускает её тело: он создаёт объект и возвращает управление. Из этого следует забытый await, который превращает любую проверку в истину; блокирующий вызов, который останавливает не одну задачу, а весь цикл событий вместе со всеми его задачами; и gather, у которого второе исключение остаётся при своей задаче, а вызывающему не достаётся.
Полное техническое изложение
TL;DR
Объявление, вызов, ожидание и задача — четыре разные вещи. async def
создаёт функцию-сопрограмму; её вызов не выполняет тело, а строит объект и
возвращает управление; await запускает этот объект и ждёт результата — и там
же уступает управление другим работам; задача — сопрограмма, отданная циклу
событий: она идёт сама.
Отсюда главное следствие: три ошибки, которые не падают. Забытый await
превращает if is_allowed(user) в if <объект>, то есть в истину при любых
данных — проверено: пользователь chuck, которого нет в списке, проходит
проверку. Один time.sleep внутри корутины останавливает не свою работу, а
весь цикл событий: пять задач по 0,1 с заняли 0,50 с вместо 0,1 с, а
соседняя задача, обязанная просыпаться каждые 10 мс, проснулась 2 раза из 51.
А gather отдаёт вызывающему первое проброшенное исключение и соседей не
отменяет — они продолжают работать, и их последующие ошибки этим вызовом не
собираются: вторая ошибка остаётся при своей задаче и достаётся тому, кто
сохранил ссылку и спросил task.exception().
Дальше — числа, версии и границы. Тип самой async-функции обычный
function, отличается только флаг CO_COROUTINE в коде. RuntimeWarning про
«never awaited» печатается при сборке мусора, а не в момент ошибки; даже
-W error::RuntimeWarning не останавливает выполнение, не ловится через
except и не меняет код возврата — проверено, возврат 0. TaskGroup (3.11)
устроен иначе: отменяет соседей и отдаёт оба исключения одним ExceptionGroup.
Цена самого механизма (3.13.7): обычный вызов 60,0 нс, await корутины
119,4 нс — вдвое, await asyncio.sleep(0) 2,42 мкс — в сорок раз.
- что такое функция и что её вызов обычно сразу выполняет тело;
- что программа часто ждёт ответа снаружи — от сети, диска, другой службы, — и всё это время ничего не считает;
- что исключение можно перехватить и что у
tryбываетfinally.
- как устроен цикл событий внутри, что такое
CO_COROUTINEиGET_AWAITABLE; gather,TaskGroup,ExceptionGroupиexcept*,asyncio.timeout,shield,CancelledError.
База: четыре сущности и очередь работ
Разговор про async/await чаще всего путается в одном месте: четыре разные
вещи называют одним словом «корутина». Разведём их сразу, обычными словами.
- Объявление.
async def work(...)заводит функцию особого рода — функцию-сопрограмму. Это ещё не работа, а описание работы. - Вызов.
work(1)ничего не выполняет. Он создаёт объект — приостановленное вычисление, готовое начаться, но не начатое. - Ожидание.
await work(1)запускает этот объект и ждёт результата. И ровно здесь происходит вторая, менее заметная вещь: на время ожидания работа уступает управление. - Задача.
asyncio.create_task(work(1))отдаёт объект циклу событий. Дальше работа идёт сама, рядом с текущей, а результат забирают у объекта задачи.
Кому она уступает управление — вторая половина модели. Цикл событий не поток и не процесс: это распорядитель, у которого на руках список начатых работ. Правило у него одно, и его можно рассказать одной фразой: одна работа дошла до ожидания, отдала управление, распорядитель дал его следующей. Пока первая ждёт ответа снаружи, вторая считает; когда ответ придёт, первую продолжат с того места, где она остановилась.
Отсюда видно, на чём всё держится: работы обязаны отдавать управление сами. Никто не отбирает его силой, и пока текущая работа его не отдала, все остальные стоят — включая таймеры и проверку живости.
И вот вопрос, на который отвечает остальной урок: что происходит, когда управление не отдают — или когда ожидание просто забыли написать? Ни то ни другое не выглядит как ошибка: программа не падает и в логах ничего не пишет.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, что вызов без ожидания даёт истинный объект вместо ответа, что один синхронный вызов останавливает не свою работу, а весь список, и что при запуске нескольких работ разом ошибки достаются вызывающему не все.
Механизм 1: вызов не выполняет тело
Начнём с того, что проверяется двумя строками и объясняет всё остальное.
async def work(x):
return x + 1
result = work(1)result — это не 2. Это объект типа coroutine, а тело функции не
выполнялось вовсе. PEP 492 говорит об этом прямо, сравнивая с генераторами:
Regular generators, when called, return a generator object; similarly,
coroutines return a coroutine object
(Обычные генераторы при вызове возвращают объект-генератор; точно так же корутины возвращают объект-корутину).
Сама функция при этом — обычная функция:
| что смотрим | async def work | def plain |
|---|---|---|
type(...) | function | function |
type(work) is type(plain) | True | |
bool(co_flags & CO_COROUTINE) | True | False |
| что вернёт вызов | coroutine | int |
Отличие ровно одно и лежит не в типе функции, а во флаге её кода. Компилятор,
увидев async def, ставит CO_COROUTINE, и вызов такой функции идёт по
другому пути: не выполнить, а создать.
Слово await в байт-коде — это GET_AWAITABLE и цикл отправки значений
через SEND. На 3.12 и новее рядом появился END_SEND; на 3.11 его нет.
Разбирать этот цикл не нужно, важно одно: await — это инструкция, а не
вызов, и без неё объект корутины так и останется объектом.
Дождаться корутины можно один раз. Второй await того же объекта на всех
четырёх версиях даёт RuntimeError: cannot reuse already awaited coroutine.
Поэтому корутину нельзя положить в переменную и переиспользовать: переиспользуют
не корутину, а функцию, которая её создаёт.
Состояния корутины и вторая вещь, которую с ней путают
У объекта-корутины те же четыре состояния, что у генератора, — с точностью до
имён, и это не совпадение: механизм один. Прогон bench/iterators/states.py
проходит по ним и заодно разводит корутину с задачей:
Разница между ними — не в удобстве, а в том, кто чем владеет:
| объект-корутина | задача (Task) | |
|---|---|---|
| что это | приостановленное вычисление | обёртка, которую ведёт цикл событий |
| кто выполняет | никто, пока не отдали циклу | цикл событий, сам |
| можно ждать дважды | нет: RuntimeError | да: результат хранится в задаче |
| можно отменить | нечего отменять | task.cancel() |
| знает ли о результате после конца | нет | да, task.result() отвечает и потом |
Отсюда практическое следствие, которое обычно узнают через отладку: await coro
и await asyncio.create_task(coro) — не одно и то же. Первое выполняет
корутину последовательно внутри текущей задачи; второе отдаёт её циклу
отдельной задачей, и она начинает двигаться параллельно с текущей ещё до
await. Собственно, весь раздел про gather ниже — про то, что происходит с
такими отдельными задачами, когда одна из них падает.
Механизм 2: ошибка, которая не падает
Проверка прав. Девять строк, каких в любом сервисе десяток:
ALLOWED = {"anna", "boris"}
async def is_allowed(user: str) -> bool:
... # поход в базу
return user in ALLOWED
async def handle(user, action):
if is_allowed(user): # <- забыли await
await action()Функция возвращает bool, результат подставлен в if — всё выглядит
правильно. Но is_allowed(user) возвращает не bool, а объект корутины, а
любой объект в if истинен. Проверка перестала зависеть от данных:
| пользователь | await is_allowed(user) | is_allowed(user) |
|---|---|---|
anna (в списке) | True | True |
chuck (не в списке) | False | True |
Ни исключения, ни падения, ни расхождения в тестах, если тесты проверяют
только разрешённого пользователя. chuck проходит.
Почему предупреждение не спасает
На RuntimeWarning: coroutine ... was never awaited рассчитывать нельзя, и
причин две.
Первая: оно печатается не тогда, когда сделана ошибка. PEP 492 привязывает
его к сборке мусора: When a native coroutine is garbage collected, a
(Когда встроенная корутина собирается сборщиком мусора, возбуждается RuntimeWarning is raised if it was never awaited onRuntimeWarning, если её так и не дождались). Замер это
подтверждает: пока на корутины есть ссылки, предупреждений ноль, сколько ни
зови gc.collect(). Они появляются только когда объект уничтожается — а в
живом сервисе это может случиться позже, в другом запросе, в другой строке
лога.
Вторая, и она хуже. Предупреждение возникает внутри финализатора объекта, а исключения оттуда интерпретатор проглатывает. Поэтому даже самая строгая настройка, какая есть, не превращает его в ошибку:
python -W error::RuntimeWarning never_awaited.py
| что ожидается | что происходит |
|---|---|
поймается except RuntimeWarning | нет |
| выполнение остановится | нет, доходит до конца |
| код возврата станет ненулевым | нет, 0 |
В stderr при этом печатается Exception ignored in: <coroutine object ...>
(на 3.14 формулировка другая: Exception ignored while finalizing coroutine),
и на этом всё. То есть CI, настроенный «падать на любом предупреждении», такую
ошибку не поймает — он поймает всё, кроме неё.
Что ловит — и тут нужна точность, потому что «поставьте mypy» помогает не везде. В присваивании он ловит по умолчанию:
ok: bool = is_allowed(user)
error: Incompatible types in assignment (expression has type
"Coroutine[Any, Any, bool]", variable has type "bool")
note: Maybe you forgot to use "await"?
А в if is_allowed(user) — том самом случае, с которого начался раздел, —
mypy молчит даже при --strict. Нужен отдельный код проверки:
mypy --enable-error-code truthy-bool
Тогда он скажет: returns "Coroutine[Any, Any, bool]" which does not implement
(возвращает Coroutine[Any, Any, bool], у которого нет ни __bool__ or __len__ so it could always be true in boolean context__bool__, ни __len__, — поэтому в логическом контексте он всегда истинен).
ruff забытый await не ловит вовсе, но ловит две другие ошибки этого урока:
RUF006 — задачу, ссылку на которую не сохранили, ASYNC251 — блокирующий
вызов внутри async def.
Механизм 3: один блокирующий вызов останавливает всё
Второе следствие того же факта. await — это место, где корутина уступает
управление циклу событий. Обычный вызов управления не уступает, и цикл о
нём не узнаёт.
Пять «запросов» по 0,1 с, а рядом задача-сердцебиение, обязанная просыпаться каждые 10 мс:
Смотреть тут надо не на первый переключатель, а на два средних: нажмите
time.sleep, потом «последовательный await», и сравните три полосы.
gather с time.sleep внутри занял 0,50 с. Последовательный await без
всякого gather занял 0,51 с — то же самое. По общему времени эти два случая
неразличимы, и жалоба «сервис тормозит» их не различает тоже.
А с приложением они делают противоположное. При последовательном await
сердце ударило 51 раз, худшая пауза — 10 мс: медленно, но живо. При
блокирующем вызове — 2 удара из 51 и пауза в полсекунды. Замерли не пять
задач, а всё: другие запросы, таймеры, проверка живости, отправка метрик.
Балансировщик в этот момент видит мёртвый экземпляр.
Лечится передачей блокирующего вызова в поток:
await asyncio.to_thread(legacy_client.fetch, url)Это возвращает 0,1 с и нормальное сердцебиение. Цена — поток на каждый вызов,
поэтому to_thread — лекарство от чужого синхронного кода, а не способ
писать свой.
Механизм 4: где gather и TaskGroup расходятся
Три задачи, две из них падают. Разница между двумя способами их запустить проявляется ровно в этот момент — и она не в удобстве записи.
У gather вызывающий получил ValueError второй задачи. KeyError третьей
ему не достался, а первая задача продолжила работать — уже после того, как
обработка ошибки началась, то есть, вполне возможно, поверх начатого отката.
Но «исчезла» — слово неточное, и разница практическая. Вторая ошибка остаётся
при своей задаче; исчезает она только тогда, когда никто не сохранил на эту
задачу ссылку. Прогон bench/async-await/gather_semantics.py показывает обе
половины на одном наборе задач:
вызывающий получил: ValueError: ошибка из падающий первым
сразу после gather: медленный done=False, поздний done=False
медленный: cancelled=False, результат='медленный готов'
поздний: cancelled=False, исключение=RuntimeError('ошибка из падающий позже')
Соседи не отменены, медленный дошёл до конца и вернул результат, а ошибка
позднего лежит и ждёт вопроса. Второй блок того же прогона — тот же gather,
но ссылок на задачи никто не держит; чтобы «потерялась» перестало быть словом
на слух, подменён обработчик ошибок цикла событий и посчитаны его вызовы:
сообщений от цикла событий о незабранных ошибках: 0
Ноль — и это сильнее, чем «мы её не увидели»: обычно про незабранное
исключение задачи цикл сообщает сам — сообщением Task exception was never retrieved
(исключение задачи так и не было забрано).
Здесь не сообщает, потому что забрал его gather — и выбросил. Вывод
одинаков на 3.11, 3.12, 3.13 и 3.14: файлы прогонов совпадают построчно.
Документация описывает первую половину прямо: Other awaitables in the aws
sequence won't be cancelled and will continue to run
(Остальные ожидаемые объекты в последовательности aws не отменяются и продолжают работать). О втором исключении,
которое этим вызовом не собирается, там не сказано, но именно оно и было
мотивом PEP 654: There isn't
currently a good way for such libraries to handle situations where multiple
tasks raise exceptions
(Сейчас у таких библиотек нет хорошего способа обработать случай, когда исключение возникает сразу в нескольких задачах).
TaskGroup (3.11) отдаёт оба исключения одним ExceptionGroup и отменяет
соседей: The first time any of the tasks belonging to the group fails with an
exception other than
(Как только любая задача группы падает с исключением, отличным от asyncio.CancelledError, the remaining tasks in the group
are cancelledasyncio.CancelledError, остальные задачи группы отменяются). Разбирать группу принято через except*.
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch(a)) # допустим, упадёт с ValueError
tg.create_task(fetch(b)) # а эта — с KeyError
except* ValueError as eg:
# eg — не одно исключение, а ExceptionGroup со ВСЕМИ ValueError группы
for err in eg.exceptions:
log("значение:", err)
except* KeyError as eg:
# отдельная ветка под KeyError; если упали задачи обоих типов,
# за один выход из TaskGroup сработают ОБЕ ветки
for err in eg.exceptions:
log("ключ:", err)
except* Exception as eg:
# «всё остальное» — чтобы ни один тип не улетел необработанным;
# без этой ветки нераспознанные ошибки полетят дальше — своей ExceptionGroup
for err in eg.exceptions:
log("прочее:", err)Каждая ветка except* получает подгруппу — ExceptionGroup только со своими
ошибками; несколько веток могут сработать за один выход из TaskGroup, а типы,
не пойманные ни одной веткой, продолжат распространяться группой. Подробный
разбор except* — в уроке «Исключения и finally».
Рекомендация записана в «Что нового в 3.11» дословно: For new code this is
recommended over using
(Для нового кода это рекомендуется вместо прямого использования create_task() and gather() directlycreate_task() и gather()). Оговорка одна
и существенная: gather возвращает результаты в порядке аргументов, а
TaskGroup не возвращает ничего — результаты забирают у объектов задач.
Поэтому замена не механическая.
Механизм 5: задача, на которую никто не ссылается
Ещё одно место, где документация предупреждает, а проверить предупреждение нечем.
У asyncio.create_task стоит: The event loop only keeps weak references to
tasks. A task that isn't referenced elsewhere may get garbage collected at any
time, even before it's done
(Цикл событий держит только слабые ссылки на задачи. Задача, на которую больше никто не ссылается, может быть собрана сборщиком мусора в любой момент — даже до того, как она завершится). Совет — сохранять ссылку.
Замер пытается поймать потерю в лоб: двести задач, ссылки на которые
немедленно выброшены, и gc.collect() между шагами. Не воспроизвелось:
все двести доработали, ни одна не собрана. Причина видна там же — пока задача
ждёт, на неё ссылается TaskStepMethWrapper, а его самого держит таймер цикла, и
обе ссылки сильные.
Отрицательный результат здесь и есть содержание. Совет из документации верен, но проверяется он не тестом: раз потеря не воспроизводится по заказу, значит, она проявится однажды и не там, где её будут искать. Ссылку надо сохранять не потому, что иначе задача исчезнет сегодня, а потому, что заметить её исчезновение будет нечем.
background: set[asyncio.Task] = set()
task = asyncio.create_task(worker())
background.add(task)
task.add_done_callback(background.discard)В коде на 3.11 и новее вместо этого обычно достаточно TaskGroup: он держит
ссылки сам и дожидается всех на выходе.
Механизм 6: отмена — это поток управления, а не ошибка
Задача, которую отменили, получает внутрь CancelledError. Дальше начинается
то, из-за чего отмена и стоит отдельного разговора: это исключение ведёт себя
не как ошибка.
Первое — где оно в иерархии:
__mro__ ('CancelledError', 'BaseException', 'object')
issubclass(CancelledError, Exception) False
CancelledError наследуется напрямую от BaseException, и с 3.8 это записано
в документации отдельной пометкой об изменении:
Changed in version 3.8: CancelledError is now a subclass of BaseException
rather than Exception.
Изменено в версии 3.8: CancelledError теперь наследник BaseException, а не Exception.
Причина та же, по которой вне Exception живут KeyboardInterrupt и
SystemExit, и она разобрана в уроке про исключения: чтобы отмену не поймал
случайно тот, кто ловит ошибки. Отсюда две ловушки, и обе видны запуском
(bench/cancellation/01_cancellederror_is_baseexception.py):
except Exception пропустил
except BaseException ПОЙМАЛ
Первая ловушка — except Exception отмену не видит. Код очистки, написанный
как «поймаем всё и приберёмся», при отмене не сработает: до него не дойдёт.
Вторая — противоположная и хуже. Если поймать отмену и не пробросить её дальше, задача считается успешно завершённой:
б) try/except BaseException/finally, БЕЗ raise
task.cancelled() False
итог await task вернул: 'вернул значение, как будто ничего не было'
Тот, кто её отменял, получит результат вместо отмены. Документация говорит про это прямо:
In case asyncio.CancelledError is explicitly caught, it should generally be
propagated when clean-up is complete.
Если asyncio.CancelledError перехвачен явно, его, как правило, следует пробросить дальше по завершении очистки.
И там же — почему это не вопрос вкуса:
The asyncio components that enable structured concurrency, like
asyncio.TaskGroup and asyncio.timeout, are implemented using cancellation
internally and might misbehave if a coroutine swallows
asyncio.CancelledError.
Компоненты asyncio, обеспечивающие структурную одновременность, — asyncio.TaskGroup и asyncio.timeout — внутри устроены на отмене и могут повести себя неправильно, если корутина проглотит asyncio.CancelledError.
У finally нет бюджета
Расхожее «на уборку даётся мгновение» неверно. await внутри finally работает
как обычно, и цикл ждёт ровно столько, сколько уборка попросила
(bench/cancellation/02_finally_budget.py):
| уборка просила | от cancel() до конца задачи |
|---|---|
| 0,05 с | 0,05 с |
| 0,30 с | 0,30 с |
Никакого предела нет. Он появляется только тогда, когда отменяющая сторона
отменяет ещё раз: повторный cancel() обрывает уборку прямо на await.
Отсюда практическое следствие для выхода из программы. asyncio.run на выходе
отменяет всё, что осталось, и ждёт — без ограничения по времени
(Lib/asyncio/runners.py:198). Задача с долгой уборкой задержит завершение
процесса ровно на длину этой уборки.
Рядом стоит вещь, которую обычно принимают за флаг, а это счётчик:
старт 0
cancel() 1
cancel() ещё раз 2
uncancel() вернул 1
Task.cancelling() считает запросы на отмену, Task.uncancel() их вычитает.
Нужны они не прикладному коду, а asyncio.timeout и TaskGroup — по этому
счётчику они отличают «отменил я сам» от «нас отменили снаружи»
(Lib/asyncio/timeouts.py:112).
Таймаут: внутри отмена, снаружи ошибка
asyncio.timeout не «прерывает» блок. Он отменяет задачу, а на выходе
подменяет отмену на TimeoutError:
| изнутри блока прилетел CancelledError
| снаружи блока: TimeoutError
| цепочка: TimeoutError <-cause- CancelledError
The asyncio.timeout context manager is what transforms the
asyncio.CancelledError into a TimeoutError, which means the TimeoutError
can only be caught outside of the context manager.
Именно контекстный менеджер asyncio.timeout превращает asyncio.CancelledError в TimeoutError, а значит, TimeoutError можно поймать только снаружи контекстного менеджера.
Практически это значит, что одно и то же событие внутри и снаружи блока ловится
разными ветками: внутри — только except BaseException, снаружи — обычным
except Exception, потому что TimeoutError наследуется от OSError.
А если отмену внутри блока проглотить, таймаут молча не сработает
(bench/cancellation/03_timeout_vs_wait_for.py):
| проглотил отмену от таймаута
| тело досчиталось до конца, хотя таймаут истёк
| TimeoutError НЕ прилетел
| блок с лимитом 0.05 с занял 0.15 с
Не «сработал позже» и не «сорвался» — его просто не стало. Превратить отмену в
TimeoutError можно только если __aexit__ увидел исключение; проглотили —
видеть нечего.
shield защищает работу, а не ожидающего
Самое частое недоразумение с shield — думать, что он отменяет отмену. Он
отменяет её только для внутренней операции:
outer.cancelled() True
inner.cancelled() False
inner.result() 'внутренняя-результат'
Ожидающий получил CancelledError и ждать перестал; защищённая задача
доработала до конца. Документация формулирует это без обиняков:
From the point of view of something(), the cancellation did not happen.
Although its caller is still cancelled, so the "await" expression still raises a
CancelledError.
С точки зрения something() отмены не случилось. Однако вызывающая сторона всё равно отменена, поэтому выражение await всё-таки возбуждает CancelledError.
Отсюда ловушка, которая в проде выглядит как «таймаут сработал, а запрос всё
равно ушёл»: под asyncio.timeout защищённая операция доработала за 0,25 с
после того, как снаружи уже прилетел TimeoutError.
И вторая: shield поверх голой корутины заводит новую задачу, а цикл держит
на задачи только слабые ссылки. Ссылку надо хранить самому — иначе получится тот
же случай, что разобран выше в разделе про задачу, на которую никто не ссылается.
Что происходит при отмене в TaskGroup
Здесь три вещи, каждая из которых удивляет отдельно
(bench/cancellation/05_taskgroup_cancellation.py).
CancelledError от ребёнка группу не роняет. Ребёнок, отменённый сам по
себе, в список ошибок не попадает, соседей не отменяет, и группа выходит штатно.
Это не случайность, а строка в реализации: if task.cancelled(): return
(Lib/asyncio/taskgroups.py:235).
Внешняя отмена родителя даёт наружу CancelledError, а не ExceptionGroup.
Дети отменяются, их finally отрабатывают, но обёртки в группу исключений не
возникает — отменять не значит падать.
Порядок отмены соседей не воспроизводим. Группа хранит задачи в set
(taskgroups.py:35), и в разных запусках соседи отменяются в разном порядке.
Сам факт отмены воспроизводим, порядок — нет, и завязываться на него нельзя.
Глубже: сколько стоит await
Три разные цены, которые обычно называют одним словом «асинхронность». Числа получены на 3.13.7 и 3.14.7; читать их надо по строкам, а не по колонкам — почему, сказано сразу после таблицы.
| что | 3.13.7 | 3.14.7 | во сколько раз дороже вызова (3.13.7) |
|---|---|---|---|
| обычный вызов функции | 60,0 нс | 49,5 нс | — |
await корутины | 119,4 нс | 112,9 нс | ×2,0 |
await asyncio.sleep(0) | 2,42 мкс | 2,33 мкс | ×40,4 |
await create_task(...) | 6,09 мкс | 5,30 мкс | ×101,6 |
Почему по строкам, а не по колонкам. Кратности в последнем столбце
измерены внутри одного запуска одного интерпретатора и потому верны. А
колонки — это две разные сборки, и различаются они не только версией языка:
3.14 собран с --with-tail-call-interp, которого у 3.13 нет и
быть не может, а «Что нового в 3.14» приписывает этому флагу a geometric
mean of 3-5% faster
(в среднем геометрическом на 3–5 % быстрее). Разделить «ускорился язык» и «ускорилась сборка» этими
замерами нельзя, поэтому здесь такого вывода и не делается. Проверить состав
сборок можно отдельным скриптом.
Из первой строки следует практическое: дробить async-код на мелкие корутины
не бесплатно, await вдвое дороже вызова. Из второй и третьей — главное:
выигрыш асинхронности берётся не из скорости вызова. Оборот через цикл
событий стоит в сорок раз дороже вызова, а задача — в сто.
Окупается это только тогда, когда на время настоящего ожидания процессор
занят чем-то другим. На вычислительной задаче без ожидания asyncio не
ускоряет ничего, а каждый await в ней обходится вдвое дороже обычного вызова.
Что изменилось в 3.14
create_task и родственные методы принимают произвольные именованные
аргументы и передают их конструктору задачи или фабрике: now take an
arbitrary list of keyword arguments
(теперь принимают произвольный список именованных аргументов). Аргументы name и context перестали
быть особыми — The
(Именованные аргументы name and context keyword arguments are no longer
specialname и context больше не выделены особо). Для прикладного кода это ничего не меняет, для своей фабрики
задач — меняет контракт.
Появились две функции интроспекции — capture_call_graph() и
print_call_graph(): two new utility functions for introspecting and printing
a program's call graph
(две новые вспомогательные функции для разбора и печати графа вызовов программы). Это первый штатный способ увидеть, чего именно ждёт
зависшая задача.
Про числа в таблице выше — отдельно и честно. create_task на 3.14.7
измеряется быстрее, чем на 3.13.7: 5,30 против 6,09 мкс, и эти пятнадцать
процентов больше разброса самого прибора — прогон повторяет весь замер трижды
и печатает его сам: 6,6 % на 3.13.7 и 0,5 % на 3.14.7. Но приписать разницу
переработке asyncio нельзя по двум причинам сразу. Во-первых, в списке
изменений 3.14 оптимизаций asyncio нет вовсе, и там же быстрее обычный
вызов функции — 49,5 против 60,0 нс, а он к asyncio отношения не имеет.
Во-вторых, две сборки различаются не только версией: у 3.14 включён
--with-tail-call-interp, про который в том же документе сказано a geometric mean of 3-5% faster
(в среднем геометрическом на 3–5 % быстрее) и This feature is opt-in for now
(пока эта возможность включается вручную).
То есть на вопрос «стал ли asyncio быстрее в 3.14» эти замеры ответа не дают — и правильный вывод из них не «стал», а «здесь этого не измерить».
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.5 | PEP 492 делает корутины отдельной сущностью языка, а не надстройкой над генераторами: This proposal makes coroutines a native Python language feature, and clearly separates them from generators (это предложение делает корутины полноценной языковой конструкцией Python и чётко отделяет их от генераторов). Тогда же появляется RuntimeWarning про забытый await — и сразу привязанным к сборке мусора, то есть с обеими оговорками из урока. | |
| 3.11 | Появились TaskGroup и asyncio.timeout, и обоим документация сразу даёт предпочтение: For new code this is recommended over using (для нового кода это рекомендуется вместо прямого использования create_task() и gather()) и recommended over using (рекомендуется вместо прямого использования wait_for()). Тогда же PEP 654 приносит ExceptionGroup и except*, без которых TaskGroup нечем было бы отдавать две ошибки сразу. | |
| 3.13 | TaskGroup.create_task() у неактивной группы теперь закрывает переданную корутину, which prevents a (что предотвращает RuntimeWarning о том, что переданную корутину так и не дождались). Там же переработана отмена во вложенных группах: раньше внешняя группа могла зависнуть, потому что её отмену проглатывала внутренняя. | |
| 3.14 | create_task принимает произвольные именованные аргументы, а name и context перестали быть особыми — прикладного кода это не касается, своей фабрики задач касается. Появились capture_call_graph() и print_call_graph(): штатный способ увидеть, чего ждёт зависшая задача. |
Как отвечать на собеседовании
Короткий ответ: вызов async-функции не выполняет её тело. Он создаёт
объект coroutine и возвращает управление; выполнение начинается только там,
где этот объект ждут, и там же работа уступает управление другим. Отсюда
забытый await: if is_allowed(user) превращается в проверку объекта на
истинность, то есть проходит при любых данных.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Тип самой async-функции при этом обычный function, отличается только флаг
CO_COROUTINE в её коде — то есть «async-функция» не отдельный вид объекта, а
обычная функция с флагом.
Что отличает хороший ответ: сказать, что предупреждение об этом приходит
слишком поздно и слишком тихо. RuntimeWarning про «never awaited» печатается
при сборке мусора, а не в момент ошибки, и даже с -W error::RuntimeWarning
не останавливает выполнение и не меняет код возврата. Если спросят дальше —
ещё две вещи, которые легко назвать неверно: один time.sleep внутри
корутины останавливает весь цикл событий, а gather отдаёт вызывающему первое
проброшенное исключение и соседей не отменяет — они продолжают работать, и их
последующие ошибки этим вызовом не собираются: вторая ошибка остаётся при своей
задаче и достаётся только тому, кто сохранил ссылку и спросил; TaskGroup
отменяет соседей и отдаёт оба.
Дальше спросят
Вы сказали, что вызов корутины ничего не выполняет. Тогда почему один time.sleep внутри задачи вешает весь цикл?
Потому что уступает управление не вызов, а await. Обычный вызов управления не
отдаёт, и цикл о нём просто не узнаёт. Отсюда неразличимость, которая и делает
ошибку тихой: gather с time.sleep внутри занял 0,50 с, а последовательный
await вообще без gather — 0,51 с. По общему времени эти два случая не
отличаются, и жалоба «сервис тормозит» их тоже не различает.
Задачу отменили. Сколько времени есть у finally на уборку?
Столько, сколько уборка попросит. Расхожее «на уборку даётся мгновение»
неверно: await внутри finally работает как обычно, и цикл ждёт ровно
запрошенное — 0,05 с при 0,05, 0,30 с при 0,30. Предел появляется только когда
отменяющая сторона зовёт cancel() ещё раз: повторная отмена обрывает
уборку прямо на await.
gather или TaskGroup — что возьмёте и почему?
Разница не в удобстве записи, а в том, что происходит, когда падают две задачи
из трёх. У gather вызывающий получает первое проброшенное исключение;
остальные задачи продолжают работать, и их последующие ошибки этим вызовом не
собираются — вторая ошибка остаётся при своей задаче, и достаётся она только
тому, кто сохранил ссылку и спросил task.exception(). Если ссылок нет — не
достаётся никому: ни вызывающему, ни логу, ни предупреждению цикла событий. А
работают соседи уже поверх начатого отката. Документация описывает первую
половину прямо; вторая и была мотивом PEP 654.
Частые заблуждения
async делает код быстрее
Сам по себе — медленнее. await корутины вдвое дороже обычного вызова (119,4 против 60,0 нс на 3.13.7), оборот через цикл событий — в сорок раз (2,42 мкс), заведение задачи — в сотню (6,09 мкс). Выигрыш берётся только из того, что на время НАСТОЯЩЕГО ожидания процессор занят другим. На вычислениях без ожидания asyncio не ускоряет ничего.
забытый await заметен: будет RuntimeWarning
Предупреждение печатается при сборке мусора, а не в момент ошибки: пока ссылка на корутину жива, его нет вовсе. И даже -W error::RuntimeWarning его не останавливает — оно возникает в финализаторе, откуда исключения проглатываются. Проверено: except RuntimeWarning не срабатывает, выполнение доходит до конца, код возврата 0.
если код внутри async def, он не блокирует
Блокирует ровно так же. await — это место, где корутина уступает управление; обычный вызов его не уступает. Замер: пять задач с time.sleep(0.1) внутри gather заняли 0,50 с вместо 0,1 с, а соседняя задача с периодом 10 мс проснулась 2 раза из 51. Останавливается не задача, а цикл целиком.
медленный async и заблокированный async — одно и то же
По общему времени неотличимы, по последствиям противоположны. Последовательный await — 0,51 с и 51 удар сердцебиения; gather с блокирующим вызовом — 0,50 с и 2 удара. В первом случае приложение живо и просто медленное, во втором оно на полсекунды перестаёт существовать для балансировщика и метрик.
gather дождётся всех и покажет все ошибки
Ни того, ни другого. Документация: the first raised exception is immediately propagated… Other awaitables in the aws sequence won't be cancelled and will continue to run
(первое возникшее исключение немедленно передаётся наверх… Остальные ожидаемые объекты в последовательности aws не отменяются и продолжают работать). Прогон bench/async-await/gather_semantics.py: вызывающий увидел одну ошибку; вторая осталась при своей задаче и читается через task.exception(), если ссылку сохранили; медленная задача спокойно доработала до конца уже после начала обработки ошибки. Без сохранённых ссылок вторую ошибку не показал никто — обработчик ошибок цикла событий не сработал ни разу.
TaskGroup — это тот же gather, только красивее
Разница в поведении при ошибке. TaskGroup отменяет остальные задачи и отдаёт все исключения одним ExceptionGroup; gather не отменяет никого и отдаёт первое. Но и заменять механически нельзя: gather возвращает результаты в порядке аргументов, а TaskGroup не возвращает ничего — результаты забирают у объектов задач.
корутину можно сохранить и дождаться дважды
Второй await того же объекта даёт RuntimeError: cannot reuse already awaited coroutine на всех четырёх версиях. Переиспользуют не корутину, а функцию, которая её создаёт. Именно поэтому retry принимает функцию, а не готовый объект.
create_task достаточно, ссылку хранить необязательно
Документация предупреждает обратное: A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done
(задача, на которую больше никто не ссылается, может быть собрана сборщиком мусора в любой момент — даже до того, как она завершится). Попытка воспроизвести потерю в лоб не удалась — двести задач без ссылок доработали все, потому что спящую задачу удерживает таймер цикла. Это довод ЗА осторожность, а не против: ошибка, которую нельзя вызвать по заказу, не поймается и тестом.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
import asyncio
ALLOWED = {"alice", "bob"}
async def is_allowed(user):
return user in ALLOWED
async def main():
if is_allowed("chuck"):
print("allowed")
else:
print("denied")
print(await is_allowed("chuck"))
asyncio.run(main())Практика · оцените
Проверка знаний
Что окажется в переменной после result = work(1), где work объявлена как async def?
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
bench/cancellation/01_cancellederror_is_baseexception.pybench/cancellation/02_finally_budget.pybench/cancellation/03_timeout_vs_wait_for.pybench/cancellation/04_shield.pybench/cancellation/05_taskgroup_cancellation.pybench/async-await/blocking.pybench/async-await/cost.pybench/async-await/fire_and_forget.pybench/async-await/forgotten_await.pybench/async-await/gather_semantics.pybench/async-await/groups.pybench/async-await/identity.pybench/iterators/states.py
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Объявление, вызов, ожидание и задача — четыре разные вещи.
async defсоздаёт функцию-сопрограмму; её вызов не выполняет тело, а строит объект и возвращает управление;awaitзапускает этот объект и ждёт результата — и там же уступает управление другим работам; задача — сопрограмма, отданная циклу событий: она идёт сама. - Отсюда главное следствие: три ошибки, которые не падают. Забытый
awaitпревращаетif is_allowed(user)вif <объект>, то есть в истину при любых данных — проверено: пользовательchuck, которого нет в списке, проходит проверку. Одинtime.sleepвнутри корутины останавливает не свою работу, а весь цикл событий: пять задач по 0,1 с заняли 0,50 с вместо 0,1 с, а соседняя задача, обязанная просыпаться каждые 10 мс, проснулась 2 раза из 51. Аgatherотдаёт вызывающему первое проброшенное исключение и соседей не отменяет — они продолжают работать, и их последующие ошибки этим вызовом не собираются: вторая ошибка остаётся при своей задаче и достаётся тому, кто сохранил ссылку и спросилtask.exception(). - Дальше — числа, версии и границы. Тип самой
async-функции обычныйfunction, отличается только флагCO_COROUTINEв коде.RuntimeWarningпро «never awaited» печатается при сборке мусора, а не в момент ошибки; даже-W error::RuntimeWarningне останавливает выполнение, не ловится черезexceptи не меняет код возврата — проверено, возврат0.TaskGroup(3.11) устроен иначе: отменяет соседей и отдаёт оба исключения однимExceptionGroup. Цена самого механизма (3.13.7): обычный вызов 60,0 нс,awaitкорутины 119,4 нс — вдвое,await asyncio.sleep(0)2,42 мкс — в сорок раз.
На самом деле
- Сам по себе — медленнее.
awaitкорутины вдвое дороже обычного вызова (119,4 против 60,0 нс на 3.13.7), оборот через цикл событий — в сорок раз (2,42 мкс), заведение задачи — в сотню (6,09 мкс). Выигрыш берётся только из того, что на время НАСТОЯЩЕГО ожидания процессор занят другим. На вычислениях без ожиданияasyncioне ускоряет ничего. - Предупреждение печатается при сборке мусора, а не в момент ошибки: пока ссылка на корутину жива, его нет вовсе. И даже
-W error::RuntimeWarningего не останавливает — оно возникает в финализаторе, откуда исключения проглатываются. Проверено:except RuntimeWarningне срабатывает, выполнение доходит до конца, код возврата0. - Блокирует ровно так же.
await— это место, где корутина уступает управление; обычный вызов его не уступает. Замер: пять задач сtime.sleep(0.1)внутриgatherзаняли 0,50 с вместо 0,1 с, а соседняя задача с периодом 10 мс проснулась 2 раза из 51. Останавливается не задача, а цикл целиком. - По общему времени неотличимы, по последствиям противоположны. Последовательный
await— 0,51 с и 51 удар сердцебиения;gatherс блокирующим вызовом — 0,50 с и 2 удара. В первом случае приложение живо и просто медленное, во втором оно на полсекунды перестаёт существовать для балансировщика и метрик. - Ни того, ни другого. Документация: не отменяются и продолжают работать}>the first raised exception is immediately propagated… Other awaitables in the aws sequence won't be cancelled and will continue to run. Прогон
bench/async-await/gather_semantics.py: вызывающий увидел одну ошибку; вторая осталась при своей задаче и читается черезtask.exception(), если ссылку сохранили; медленная задача спокойно доработала до конца уже после начала обработки ошибки. Без сохранённых ссылок вторую ошибку не показал никто — обработчик ошибок цикла событий не сработал ни разу. - Разница в поведении при ошибке.
TaskGroupотменяет остальные задачи и отдаёт все исключения однимExceptionGroup;gatherне отменяет никого и отдаёт первое. Но и заменять механически нельзя:gatherвозвращает результаты в порядке аргументов, аTaskGroupне возвращает ничего — результаты забирают у объектов задач. - Второй
awaitтого же объекта даётRuntimeError: cannot reuse already awaited coroutineна всех четырёх версиях. Переиспользуют не корутину, а функцию, которая её создаёт. Именно поэтомуretryпринимает функцию, а не готовый объект. - Документация предупреждает обратное: A task that isn't referenced elsewhere may get garbage collected at any time, even before it's done. Попытка воспроизвести потерю в лоб не удалась — двести задач без ссылок доработали все, потому что спящую задачу удерживает таймер цикла. Это довод ЗА осторожность, а не против: ошибка, которую нельзя вызвать по заказу, не поймается и тестом.
По версиям
- 3.5
- PEP 492 делает корутины отдельной сущностью языка, а не надстройкой над генераторами: This proposal makes coroutines a native Python language feature, and clearly separates them from generators. Тогда же появляется
RuntimeWarningпро забытыйawait— и сразу привязанным к сборке мусора, то есть с обеими оговорками из урока.< - 3.11
- Появились
TaskGroupиasyncio.timeout, и обоим документация сразу даёт предпочтение: <Q ru={для нового кода это рекомендуется вместо прямого использованияcreate_task()иgather()< - 3.13
TaskGroup.create_task()у неактивной группы теперь закрывает переданную корутину, <Q ru={что предотвращаетRuntimeWarningо том, что переданную корутину так и не дождались<- 3.14
create_taskпринимает произвольные именованные аргументы, аnameиcontextперестали быть особыми — прикладного кода это не касается, своей фабрики задач касается. Появилисьcapture_call_graph()иprint_call_graph(): штатный способ увидеть, чего ждёт зависшая задача.<
Что разобрано
- База: четыре сущности и очередь работ
- Механизм 1: вызов не выполняет тело
- Механизм 2: ошибка, которая не падает
- Механизм 3: один блокирующий вызов останавливает всё
- Механизм 4: где gather и TaskGroup расходятся
- Механизм 5: задача, на которую никто не ссылается
- Механизм 6: отмена — это поток управления, а не ошибка
- Глубже: сколько стоит await
- История версий
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
- Чем измерено
Источники и что читать дальше
8 ИСТОЧНИКОВ
- PEP 492 — Coroutines with async and await syntaxPEP. Юрий Селиванов, Final, Python 3.5. Отсюда две вещи, на которых стоит урок. Первая: «Regular generators, when called, return a generator object; similarly, coroutines return a coroutine object» (Обычные генераторы при вызове возвращают объект-генератор; точно так же корутины возвращают объект-корутину) — то есть вызов создаёт объект, а не выполняет тело. Вторая: «When a native coroutine is garbage collected, a RuntimeWarning is raised if it was never awaited on» (Когда встроенная корутина собирается сборщиком мусора, возбуждается RuntimeWarning, если её так и не дождались) — предупреждение привязано к сборке мусора, и замер это подтверждает: пока ссылка жива, его нет.https://peps.python.org/pep-0492/
- asyncio — корутины и задачиОфициальная документация. Источник трёх утверждений, проверенных запуском. Про gather: «If return_exceptions is False (default), the first raised exception is immediately propagated to the task that awaits on gather(). Other awaitables in the aws sequence won't be cancelled and will continue to run» (Если return_exceptions равно False (по умолчанию), первое возникшее исключение сразу передаётся задаче, ожидающей gather(). Остальные ожидаемые объекты в последовательности aws не отменяются и продолжают работать). Про TaskGroup: «The first time any of the tasks belonging to the group fails with an exception other than asyncio.CancelledError, the remaining tasks in the group are cancelled» (Как только любая задача группы падает с исключением, отличным от asyncio.CancelledError, остальные задачи группы отменяются). И прямая рекомендация: «TaskGroup provides stronger safety guarantees than gather for scheduling a nesting of subtasks» (TaskGroup даёт более надёжные гарантии, чем gather, при запуске вложенных подзадач).https://docs.python.org/3.14/library/asyncio-task.html
- PEP 654 — Exception Groups and except*PEP. Ирит Катриэль, Юрий Селиванов, Гвидо ван Россум; Final, Python 3.11. В разделе мотивации названа ровно та задача, которую в уроке видно на замере: «Libraries for async concurrency provide APIs to invoke multiple tasks and return their results in aggregate. There isn't currently a good way for such libraries to handle situations where multiple tasks raise exceptions» (Библиотеки асинхронной одновременности дают средства запустить несколько задач и вернуть их результаты вместе. Сейчас у таких библиотек нет хорошего способа обработать случай, когда исключение возникает сразу в нескольких задачах). Отсюда ExceptionGroup, который отдаёт TaskGroup.https://peps.python.org/pep-0654/
- Что нового в Python 3.11 — asyncioОфициальная документация. Год, когда появилось и то и другое: «Added the TaskGroup class, an asynchronous context manager holding a group of tasks that will wait for all of them upon exit. For new code this is recommended over using create_task() and gather() directly» (Добавлен класс TaskGroup — асинхронный контекстный менеджер, держащий группу задач и дожидающийся их всех при выходе. Для нового кода это рекомендуется вместо прямого использования create_task() и gather()) и «Added timeout(), an asynchronous context manager for setting a timeout on asynchronous operations. For new code this is recommended over using wait_for() directly» (Добавлен timeout() — асинхронный контекстный менеджер для ограничения времени асинхронных операций. Для нового кода это рекомендуется вместо прямого использования wait_for()). Обе рекомендации записаны прямо, а не выведены.https://docs.python.org/3.14/whatsnew/3.11.html
- Что нового в Python 3.13 — asyncioОфициальная документация. Изменение, которое касается забытого await: «When TaskGroup.create_task() is called on an inactive TaskGroup, the given coroutine will be closed (which prevents a RuntimeWarning about the given coroutine being never awaited)» (Когда TaskGroup.create_task() вызывается у неактивной группы, переданная корутина будет закрыта (что предотвращает RuntimeWarning о том, что её так и не дождались)). Там же — переработка отмены во вложенных TaskGroup, из-за которой внешняя группа раньше могла зависнуть.https://docs.python.org/3.14/whatsnew/3.13.html
- Что нового в Python 3.14 — asyncioОфициальная документация. Две записи. Первая: create_task «now take an arbitrary list of keyword arguments» (теперь принимают произвольный список именованных аргументов), и «The name and context keyword arguments are no longer special» (Именованные аргументы name и context больше не выделены особо). Вторая: «There are two new utility functions for introspecting and printing a program's call graph: capture_call_graph() and print_call_graph()» (Появились две новые вспомогательные функции для разбора и печати графа вызовов программы: capture_call_graph() и print_call_graph()). Оптимизаций asyncio в списке НЕТ — это важно для чтения замера в уроке.https://docs.python.org/3.14/whatsnew/3.14.html
- asyncio — ExceptionsОфициальная документация. Место `CancelledError` в иерархии, записанное пометкой об изменении: «Changed in version 3.8: CancelledError is now a subclass of BaseException rather than Exception» (Изменено в версии 3.8: CancelledError теперь наследник BaseException, а не Exception). Там же про то, что делать с перехваченной отменой: «This exception can be caught to perform custom operations when asyncio Tasks are cancelled. In almost all situations the exception must be re-raised» (Это исключение можно перехватывать, чтобы выполнять свои действия при отмене задач asyncio. Почти во всех случаях исключение необходимо пробросить повторно). И про таймаут: «Changed in version 3.11: This class was made an alias of TimeoutError» (Изменено в версии 3.11: этот класс стал псевдонимом TimeoutError).https://docs.python.org/3.13/library/asyncio-exceptions.html
- Исходный код asyncio — Lib/asyncio/tasks.py, Lib/asyncio/timeouts.py, Lib/asyncio/taskgroups.py, Lib/asyncio/runners.pyИсходный код CPython. Места, на которые опирается раздел про отмену, на теге v3.13.7. `exceptions.py:10` — `class CancelledError(BaseException)`. `tasks.py:223` — `self._num_cancels_requested += 1`, то есть счётчик, а не флаг; `tasks.py:248` — `uncancel`, и с 3.13 он при нуле сбрасывает `_must_cancel`. `timeouts.py:112` — `if self._task.uncancel() <= self._cancelling and exc_type is not None:`, по этому сравнению таймаут отличает свою отмену от внешней, и оттуда же `raise TimeoutError from exc_val`. `taskgroups.py:235` — `if task.cancelled(): return`, из-за чего отменённый ребёнок группу не роняет; `taskgroups.py:35` — `self._tasks = set()`, из-за чего порядок отмены соседей не воспроизводим. `runners.py:198` — `_cancel_all_tasks`: на выходе из `asyncio.run` всем отменённым задачам дают договорить без ограничения по времени.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/tasks.py