async/await изнутри

Корутина — тот же приостановленный кадр из прошлого урока, а await — прямой потомок yield from. Отсюда выводится и то, почему один синхронный вызов кладёт весь сервис, и почему задачу нужно держать за ссылку, и чем TaskGroup отличается от gather.

L1 · 9 минL2 · 11 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
async def co():
    print("тело пошло")
    return 1

c = co()
print("создана:", c)
создана: <coroutine object co at 0x...>
Плюс предупреждение «coroutine was never awaited». Тело не выполнилось.
Фрагмент B
async def slow(n):
    await asyncio.sleep(0.01)
    return n

# 50 задач через gather
# против 50 задач подряд через await
gather:         0.011 c
последовательно: 0.509 c
Разница ровно в число задач. Поток при этом один.
Фрагмент C
async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(long())    # спит секунду
        tg.create_task(boom())    # сразу падает
long отменён
TaskGroup: поймали (ValueError('bam'),)
Соседняя задача не досчитала до конца, а была отменена.
Фрагмент D
async def worker(): ...

async def main():
    asyncio.create_task(worker())   # ссылку не сохранили
    await asyncio.sleep(10)
Задача может быть собрана сборщиком до завершения — и тихо исчезнуть. Цикл событий держит на неё только слабую ссылку.

2Механизм

Из урока 13 известно: генератор — это объект с приостановленным кадром, а yield from строит канал насквозь. Асинхронность — надстройка ровно над этим.

Корутина — объект с приостановленным кадром, как генератор. С 3.5 это отдельный тип, но машинерия та же.

await передаёт управление вверх по цепочке — до того, кто эту цепочку запустил. Это yield from с другим именем и своим протоколом.

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

Следствие 1 · вызов корутины ничего не выполняет

Фрагмент A — тот же следствие 2 из урока 13. co() создаёт объект, тело не исполняется. Нужен либо await, либо asyncio.run, либо оборачивание в задачу.

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

Следствие 2 · одна очередь, один поток, никакого параллелизма

Фрагмент B. Пятьдесят задач по десять миллисекунд выполняются за сорок шесть миллисекунд вместо пятисот. Но параллелизма здесь нет: пока одна задача ждёт, цикл занимается другими. Работал всё это время один поток.

Значит выигрыш возникает ровно там, где есть ожидание — сеть, диск, ответ СУБД. На вычислениях его нет вовсе: сотня корутин, считающих хеши, выполнятся ровно за сумму своих времён, и цикл событий тут ничем не поможет.

Следствие 3 · синхронный вызов кладёт всё

Это главный практический вывод урока, и он выводится из следствия 2 в одну строчку. Управление возвращается циклу только на await. Синхронный вызов, который ждёт секунду, держит управление всю секунду — и весь сервис на это время не обслуживает никого.

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

Отсюда правило без исключений: внутри корутины любая блокирующая операция должна уходить в пул через run_in_executor либо иметь асинхронный аналог. И тот же вопрос надо задавать библиотекам: драйвер БД, HTTP-клиент, работа с файлами — синхронные версии внутри цикла событий недопустимы.

Следствие 4 · задачу нужно держать за ссылку

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

Проявляется как «задача иногда не доработала» на нагрузке — и это худший вид бага: воспроизводится редко, зависит от момента срабатывания сборщика. Лечится тем, что вытекает из механизма: сложить задачи в множество и убирать по завершении, либо использовать TaskGroup, который держит их сам.

Следствие 5 · gather и TaskGroup ведут себя по-разному при ошибке

Фрагмент C. TaskGroup при исключении в любой задаче отменяет остальные и на выходе гарантирует, что ничего не осталось работать. Ошибки приходят группой, ловятся через except* (урок 12).

gather по умолчанию ведёт себя иначе: отдаёт первое исключение, а соседние задачи продолжают выполняться в фоне — с их результатами и их возможными ошибками, о которых уже никто не узнает. Это и есть «структурная конкурентность» против её отсутствия: у TaskGroup область видимости блока совпадает с временем жизни задач.

3Границы модели

Где сказанное перестаёт держать

Отмена — это исключение, а не убийство. CancelledError возбуждается внутри задачи в точке await. Значит задача может её перехватить и не отмениться; значит except Exception её не поймает (с 3.8 она наследуется от BaseException) — а вот except BaseException поймает и сломает отмену.

Цикл событий не вытесняет. Корутина, ушедшая в бесконечный цикл без await, не будет прервана ничем: планировщик кооперативный, а не вытесняющий. Отличие от потоков принципиальное.

Асинхронность не отменяет GIL, а обходит другую проблему. Она про ожидание, а не про вычисления. Для счётной нагрузки нужны процессы или расширение, отпускающее GIL — это R3 и R6, а не R5.

4Корень

Корень R5: приостановленный кадр с двусторонним каналом. Асинхронность Python — не отдельная подсистема, а применение механизма обхода к другой задаче.

Хронология это подтверждает буквально. В 2005 yield стал двусторонним (PEP 342) — и появились библиотеки, писавшие конкурентность на голых генераторах. В 2012 добавили yield from (PEP 380) — и цепочки стали строиться насквозь. В 2014 на этом вырос asyncio, где корутина была именно генератором с декоратором. И только в 2015 (PEP 492) появились слова async и await — как отдельный синтаксис для того, что уже работало.

Поэтому знание урока 13 здесь не «полезный контекст», а буквально механизм: понимая приостановленный кадр и канал yield from, ты уже понимаешь await.

5Аналогия

Точнее всего — корутины C++20, как и в прошлом уроке: co_await и await делают одно и то же, приостанавливая кадр и отдавая управление вызывающему. Если держать это в голове, весь asyncio раскладывается на две части: примитив языка (кадр плюс приостановка) и обычная библиотека поверх него (очередь готовых задач, селектор на epoll, таймеры). Ничего магического во второй части нет — это цикл, который можно написать самому за вечер.

Второй полезный образ — кооперативная многозадачность в старых системах: задача сама решает, когда отдать управление. Windows 3.x и Mac OS до X работали так же, и болели тем же самым — одна зависшая программа вешала всё. Следствие 3 — это ровно та болезнь, и она принципиальна для модели, а не является дефектом реализации.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему нельзя было сделать все функции асинхронными неявно, без await?

Потому что тогда исчезла бы единственная гарантия, ради которой asyncio существует.

await — не украшение, а маркер точки переключения. Пока его нет, код исполняется без вытеснения: между двумя await никто не влезет. Это то, чего нет у потоков, и именно поэтому в asyncio не нужны блокировки для «прочитать-изменить-записать».

counter = 0

async def bump():
    global counter
    tmp = counter        # между этими строками
    tmp += 1             # переключения не будет —
    counter = tmp        # нет ни одного await

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

Второе: await — это документация. Читая функцию, видно все места, где управление может уйти. Ревью «где здесь может влезть другой запрос» сводится к поиску слова в тексте.

Цена решения известна как «раскрашивание функций»: асинхронную нельзя вызвать из синхронной, и async расползается вверх по стеку до самого входа. Go выбрал наоборот — горутины и неявное переключение, — и заплатил тем, что там нужны мьютексы и race detector. Это обмен, а не победа одной стороны.

Почему asyncio не ускоряет вычисления?

Потому что он не добавляет исполнителей, а только эффективнее ждёт.

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

import asyncio, time

async def cpu(n):
    t = 0
    for i in range(n): t += i        # ни одного await
    return t

async def main():
    t0 = time.perf_counter()
    await asyncio.gather(cpu(5_000_000), cpu(5_000_000))
    print(f"{time.perf_counter() - t0:.2f} c")   # ровно сумма, не быстрее

asyncio.run(main())

И хуже: один синхронный вызов отравляет весь процесс. Обычный requests.get внутри обработчика блокирует цикл целиком — все остальные соединения замирают на время запроса. Это самая частая авария в асинхронных сервисах, и профилировщик показывает её плохо: время висит в одном месте, а страдают все.

Правило. Ждём — asyncio. Считаем на Python — процессы (урок 20). Считаем в C-расширении — потоки, потому что GIL там отпущен (урок 15). Смешанная нагрузка — loop.run_in_executor для тяжёлых кусков, чтобы они ушли в отдельный поток или процесс и не держали цикл.

TaskGroup или gather — зачем понадобился новый механизм?

Затем, что gather не умеет корректно отменять соседей при ошибке — и оставляет задачи болтаться.

Проблема gather: если одна задача упала, остальные продолжают работать. Исключение всплывает наверх, а фоновые корутины остаются жить — с открытыми соединениями, недописанными файлами и последующим «Task exception was never retrieved» в логе.

import asyncio

async def boom(): raise ValueError("bam")
async def long():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("long отменён"); raise

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(long())
            tg.create_task(boom())
    except* ValueError as eg:
        print("TaskGroup: поймали", eg.exceptions)

asyncio.run(main())
# → long отменён
# → TaskGroup: поймали (ValueError('bam'),)

TaskGroup (3.11) даёт структурную конкурентность: блок не завершится, пока не завершатся все задачи; при ошибке в одной остальные отменяются; все ошибки собираются в ExceptionGroup и ловятся через except*.

Когда gather всё-таки: когда нужны результаты всех задач, включая упавшие, — gather(..., return_exceptions=True). Например опрос десяти реплик, где две могут быть недоступны, и это нормально.

Во всех остальных случаях TaskGroup — правильный умолчательный выбор, и это прямой перенос идей Trio, которые оказались настолько убедительнее, что их втащили в стандартную библиотеку.

Итог. Корутина — тот же приостановленный кадр, что и генератор, а await — прямой потомок yield from: вся асинхронность выросла из протокола обхода и появилась как синтаксис для того, что уже работало на генераторах. Вызов корутины ничего не исполняет, нужен await или запуск. Цикл событий — обычный цикл в одном потоке: выигрыш возникает там, где есть ожидание, и полностью отсутствует на вычислениях. Отсюда главное практическое следствие — управление возвращается циклу только на await, поэтому один синхронный вызов останавливает весь сервис, и внешне ничего не сломано, просто растёт латентность. Задачу, созданную через create_task, нужно держать за ссылку: цикл держит только слабую, и по правилам урока 09 задача может исчезнуть до завершения. И TaskGroup отличается от gather не удобством, а гарантией: при ошибке он отменяет соседей и не оставляет ничего работающим после выхода из блока.
Дальше — по желанию
контрфактуалC++ корутины, Go, JavaScriptОдин поток с кооперативным планировщиком против горутин с вытеснением

C++20 дал ровно тот же примитив — приостановленный кадр с co_await, — но не дал ни планировщика, ни цикла событий: их пишут сами или берут из библиотеки. Python поставляет батарейки в комплекте, C++ оставляет выбор. Отсюда и то, что в C++ можно разместить кадр так, чтобы компилятор его вообще устранил, а в Python кадр всегда в куче.

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

JavaScript ближе всего: один поток, цикл событий, async/await поверх промисов. И та же болезнь — синхронный тяжёлый код блокирует всё. Разница в том, что в браузере альтернативы никогда не было, и вся экосистема асинхронна с рождения; Python получил асинхронность через двадцать лет после старта, и синхронные библиотеки никуда не делись.

Отсюда «проблема цвета функций», которой нет у Go: в Python функция либо синхронная, либо асинхронная, и смешивать их нельзя без явных мостов.

🐛 багОдин синхронный вызов на весь сервисДрайвер без асинхронной версии внутри корутины — латентность растёт под нагрузкой, ошибок нет

Асинхронный обработчик, в котором одна библиотека оказалась синхронной. Найти это по логам почти невозможно.

async def handle(request):
    user = await db.fetch_user(request.user_id)      # асинхронно, хорошо
    profile = requests.get(PROFILE_URL, timeout=2)   # ← синхронно
    return render(user, profile.json())

Строка выглядит невинно: таймаут выставлен, ошибки обрабатываются. Но requests.get не отдаёт управление циклу событий — он держит поток до ответа. Пока он ждёт, ни один другой запрос не обслуживается.

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

Ревью пропускает, потому что смешивание видно только если знать, какие библиотеки асинхронные. Именно поэтому существует проблема цвета функций: async def в сигнатуре — обещание, которое язык не проверяет.

async def handler():
    async with httpx.AsyncClient() as client:        # асинхронный аналог
        profile = await client.get(PROFILE_URL, timeout=2)

async def handler_fallback():                        # если аналога нет
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, requests.get, PROFILE_URL)

Как находить существующие: asyncio.run(..., debug=True) или loop.slow_callback_duration — цикл сам пишет в лог предупреждение, если callback выполнялся дольше порога. Это самый дешёвый способ отловить следствие 3 в чужом коде.

⚡ 46×Ожидания складывать, а не выстраиватьПятьдесят запросов последовательно — 0.509 с; через TaskGroup — 0.011 с в одном потоке

Цикл с await внутри выглядит асинхронным и таковым не является.

async def sequential(urls):
    results = []
    for url in urls:
        results.append(await fetch(url))  # ждём каждый по очереди
    return results
async def concurrent(urls):
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(url)) for url in urls]
    return [t.result() for t in tasks]
50 операций по 10 мсвремя
последовательно через await0.509 с
одновременно0.011 с

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

Почему TaskGroup, а не gather. Следствие 5: при ошибке в одной задаче группа отменит остальные и не оставит ничего работающим после выхода из блока. С gather соседи продолжат в фоне, а их исключения будут потеряны. Плюс группа сама держит ссылки на задачи — следствие 4 закрыто бесплатно.

Ограничение параллелизма. Пятьдесят одновременных запросов к чужому API — способ получить бан. Семафор решает это в две строки и не ломает выигрыш:

sem = asyncio.Semaphore(10)
async def fetch_limited(url):
    async with sem:
        return await fetch(url)
💻 терминалПроверь в REPLЧетыре фрагмента плюс отмена, вложенный run и время в цикле

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

async def t():
    try: await asyncio.sleep(10)
    except asyncio.CancelledError: print("отменяют"); raise
# создай задачу, отмени через 0.1 с
# Что будет, если убрать raise? А если поставить except Exception?

asyncio.run(asyncio.run(main()))
# Почему нельзя вложить? Что говорит ошибка?

async def spin():
    s = 0
    for i in range(10**8): s += i     # ни одного await
    return s
# Запусти вместе с другой задачей. Что произойдёт с ней?

asyncio.run(main(), debug=True)
# Что появится в выводе, если внутри есть синхронный вызов?

Третий вопрос — прямое подтверждение того, что планировщик кооперативный: соседняя задача не получит управления ни разу, пока цикл не закончится.

исходникиasyncio и его цикл событийГде посмотреть, что цикл — это просто цикл

Lib/asyncio/base_events.py, метод _run_once. Сердце всей библиотеки, и оно умещается на экран: посчитать ближайший таймер, вызвать селектор с этим таймаутом, разложить готовые события по колбэкам, выполнить очередь готовых. Всё. После этого «магия asyncio» перестаёт быть магией — это очередь и epoll.

Lib/asyncio/tasks.py, класс Task. Здесь видно следствие 4: найди _all_tasks и убедись, что это слабое множество. И метод __step — он делает coro.send(None), то есть возобновляет кадр ровно тем же способом, что и генератор из урока 13.

Lib/asyncio/taskgroups.py. Полторы сотни строк, реализующие следствие 5: список задач, отмена соседей при первой ошибке, сбор исключений в группу. Стоит прочитать целиком — это лучший короткий пример структурной конкурентности, и видно, что gather не делает ничего из этого не по недосмотру, а потому что появился на девять лет раньше.

L2Как устроен await и чем корутина отличается от генератораПротокол ожидания, состояния задачи и цена переключения

Из L1 ты знаешь, что await — потомок yield from. Здесь — как именно это устроено.

await x требует, чтобы у x был __await__, возвращающий итератор. Дальше работает та же механика делегирования: значения, приходящие из этого итератора, прокидываются вверх по цепочке до задачи, а задача передаёт их циклу. Значение, которое доезжает до цикла, — это фактически «я жду, разбуди меня вот по этому событию».

В самом низу цепочки всегда стоит Future: его __await__ делает голый yield self. То есть единственное место, где реально происходит приостановка, — это future; всё остальное только передаёт управление насквозь.

import inspect
c = co()
inspect.getcoroutinestate(c)      # CORO_CREATED
c.cr_frame.f_lasti                 # позиция, как у генератора
c.cr_await                         # на чём стоим

Отличия корутины от генератора формальные, а не механические: отдельный тип, отсутствие __iter__ (чтобы нельзя было случайно обойти циклом) и запрет на голый yield внутри async def. Кадр, приостановка и возобновление — те же самые.

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

L3Где это в исходниках CPythongenobject.c для корутин, asyncio на Python, PEP 492

Тег v3.11.15.

  • Objects/genobject.c — тип корутины живёт там же, где генератор, и переиспользует gen_send_ex. Прямое подтверждение того, что механизм один.
  • Lib/asyncio/ — вся библиотека на Python, ничего в C. Читается целиком за вечер; начинать с base_events.py и tasks.py.
  • Modules/_asynciomodule.c — ускоренные версии Future и Task. Полезно знать, что они есть: питоновские версии в Lib читаемее, а исполняются обычно эти.
  • PEP 492 — введение async/await; в мотивации прямо написано, что механизм уже работал на генераторах и добавляется синтаксис.
связиКуда это ведётL13, L12, L09 · контраст с горутинами Go и корутинами C++20
корень R5 · Итерация как универсальный обход
← основа L13 · Итератор и генератор — приостановленный кадр и канал yield from, из которых всё это выросло
← основа L09 · Подсчёт ссылок — почему задачу нужно держать за ссылку
← основа L12 · Контекстные менеджеры — except* и группы исключений, которыми ловится TaskGroup
↔ контраст Горутины Go: вытесняющий планировщик и настоящий параллелизм по ядрам
↔ контраст Корутины C++20: тот же примитив без планировщика в комплекте
↔ контраст Потоки против корутин — дерево решений в уроке 20
→ дальше L19 · GIL — почему асинхронность не помогает счётной нагрузке
источникиЧто почитатьasyncio на Python 🟥 · PEP 492 🟧 · «Ошибки в asyncio» 🟦