Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·40 МИН

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 чаще всего путается в одном месте: четыре разные вещи называют одним словом «корутина». Разведём их сразу, обычными словами.

  1. Объявление. async def work(...) заводит функцию особого рода — функцию-сопрограмму. Это ещё не работа, а описание работы.
  2. Вызов. work(1) ничего не выполняет. Он создаёт объект — приостановленное вычисление, готовое начаться, но не начатое.
  3. Ожидание. await work(1) запускает этот объект и ждёт результата. И ровно здесь происходит вторая, менее заметная вещь: на время ожидания работа уступает управление.
  4. Задача. asyncio.create_task(work(1)) отдаёт объект циклу событий. Дальше работа идёт сама, рядом с текущей, а результат забирают у объекта задачи.

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

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

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

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

Механизм 1: вызов не выполняет тело

контракт языкаГарантия языка: вызов корутинной функции создаёт объект и не выполняет тело. Это записано в PEP 492 и не менялось.

Начнём с того, что проверяется двумя строками и объясняет всё остальное.

PYTHON
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 workdef plain
type(...)functionfunction
type(work) is type(plain)True
bool(co_flags & CO_COROUTINE)TrueFalse
что вернёт вызовcoroutineint

Отличие ровно одно и лежит не в типе функции, а во флаге её кода. Компилятор, увидев 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: ошибка, которая не падает

Проверка прав. Девять строк, каких в любом сервисе десяток:

PYTHON
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 (в списке)TrueTrue
chuck (не в списке)FalseTrue

Ни исключения, ни падения, ни расхождения в тестах, если тесты проверяют только разрешённого пользователя. chuck проходит.

Почему предупреждение не спасает

На RuntimeWarning: coroutine ... was never awaited рассчитывать нельзя, и причин две.

Первая: оно печатается не тогда, когда сделана ошибка. PEP 492 привязывает его к сборке мусора: When a native coroutine is garbage collected, a RuntimeWarning is raised if it was never awaited on (Когда встроенная корутина собирается сборщиком мусора, возбуждается RuntimeWarning, если её так и не дождались). Замер это подтверждает: пока на корутины есть ссылки, предупреждений ноль, сколько ни зови 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 __bool__ or __len__ so it could always be true in boolean context (возвращает Coroutine[Any, Any, bool], у которого нет ни __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 и пауза в полсекунды. Замерли не пять задач, а всё: другие запросы, таймеры, проверка живости, отправка метрик. Балансировщик в этот момент видит мёртвый экземпляр.

Лечится передачей блокирующего вызова в поток:

PYTHON
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 cancelled (Как только любая задача группы падает с исключением, отличным от asyncio.CancelledError, остальные задачи группы отменяются). Разбирать группу принято через except*.

PYTHON
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() directly (Для нового кода это рекомендуется вместо прямого использования create_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, а его самого держит таймер цикла, и обе ссылки сильные.

Отрицательный результат здесь и есть содержание. Совет из документации верен, но проверяется он не тестом: раз потеря не воспроизводится по заказу, значит, она проявится однажды и не там, где её будут искать. Ссылку надо сохранять не потому, что иначе задача исчезнет сегодня, а потому, что заметить её исчезновение будет нечем.

PYTHON
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.

asyncio — Exceptions

Причина та же, по которой вне 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 перехвачен явно, его, как правило, следует пробросить дальше по завершении очистки.

asyncio — Task Cancellation

И там же — почему это не вопрос вкуса:

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 можно поймать только снаружи контекстного менеджера.

asyncio — Timeouts

Практически это значит, что одно и то же событие внутри и снаружи блока ловится разными ветками: внутри — только 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 — shield

Отсюда ловушка, которая в проде выглядит как «таймаут сработал, а запрос всё равно ушёл»: под 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

наблюдение замераbench/async-await/cost.py, CPython 3.13.7. Абсолютные наносекунды зависят от машины; содержательна кратность между строками.

Три разные цены, которые обычно называют одним словом «асинхронность». Числа получены на 3.13.7 и 3.14.7; читать их надо по строкам, а не по колонкам — почему, сказано сразу после таблицы.

что3.13.73.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

деталь реализации · CPython 3.13Кто именно держит ссылку на выполняющуюся задачу — устройство текущей реализации, а не обещание asyncio.

create_task и родственные методы принимают произвольные именованные аргументы и передают их конструктору задачи или фабрике: now take an arbitrary list of keyword arguments (теперь принимают произвольный список именованных аргументов). Аргументы name и context перестали быть особыми — The name and context keyword arguments are no longer special (Именованные аргументы name и 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.5PEP 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() and gather() directly (для нового кода это рекомендуется вместо прямого использования create_task() и gather()) и recommended over using wait_for() directly (рекомендуется вместо прямого использования wait_for()). Тогда же PEP 654 приносит ExceptionGroup и except*, без которых TaskGroup нечем было бы отдавать две ошибки сразу.
3.13TaskGroup.create_task() у неактивной группы теперь закрывает переданную корутину, which prevents a RuntimeWarning about the given coroutine being never awaited (что предотвращает RuntimeWarning о том, что переданную корутину так и не дождались). Там же переработана отмена во вложенных группах: раньше внешняя группа могла зависнуть, потому что её отмену проглатывала внутренняя.
3.14create_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 (задача, на которую больше никто не ссылается, может быть собрана сборщиком мусора в любой момент — даже до того, как она завершится). Попытка воспроизвести потерю в лоб не удалась — двести задач без ссылок доработали все, потому что спящую задачу удерживает таймер цикла. Это довод ЗА осторожность, а не против: ошибка, которую нельзя вызвать по заказу, не поймается и тестом.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Проверка прав написана async-функцией. В условии await забыли, строкой ниже он на месте. Что напечатает этот код?
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())

Практика · оцените

Обычный вызов внутри корутины и await asyncio.sleep(0) — то есть один оборот цикла событий. Во сколько раз дороже оборот?
раза

Проверка знаний

Вопрос 1 из 5

Что окажется в переменной после result = work(1), где work объявлена как async def?

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

Источники и что читать дальше

8 ИСТОЧНИКОВ

  1. 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/
  2. 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
  3. 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/
  4. Что нового в 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
  5. Что нового в 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
  6. Что нового в 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
  7. 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
  8. Исходный код 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