Корутина — тот же приостановленный кадр из прошлого урока, а await — прямой потомок yield from. Отсюда выводится и то, почему один синхронный вызов кладёт весь сервис, и почему задачу нужно держать за ссылку, и чем TaskGroup отличается от gather.
Зафиксируй вывод и причину до того, как откроешь ответ.
async def co():
print("тело пошло")
return 1
c = co()
print("создана:", c)создана: <coroutine object co at 0x...>Плюс предупреждение «coroutine was never awaited». Тело не выполнилось.async def slow(n):
await asyncio.sleep(0.01)
return n
# 50 задач через gather
# против 50 задач подряд через awaitgather: 0.011 c
последовательно: 0.509 cРазница ровно в число задач. Поток при этом один.async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(long()) # спит секунду
tg.create_task(boom()) # сразу падаетlong отменён
TaskGroup: поймали (ValueError('bam'),)Соседняя задача не досчитала до конца, а была отменена.async def worker(): ...
async def main():
asyncio.create_task(worker()) # ссылку не сохранили
await asyncio.sleep(10)Из урока 13 известно: генератор — это объект с приостановленным кадром, а yield from строит канал насквозь. Асинхронность — надстройка ровно над этим.
Корутина — объект с приостановленным кадром, как генератор. С 3.5 это отдельный тип, но машинерия та же.
await передаёт управление вверх по цепочке — до того, кто эту цепочку запустил. Это yield from с другим именем и своим протоколом.
Цикл событий — обычный цикл в одном потоке. Он берёт готовую задачу, возобновляет её кадр, работает до ближайшего await, который не может ответить сразу, забирает управление обратно и берёт следующую.
Фрагмент A — тот же следствие 2 из урока 13. co() создаёт объект, тело не исполняется. Нужен либо await, либо asyncio.run, либо оборачивание в задачу.
Отсюда самая частая ошибка новичка — забытый await: код не падает, просто ничего не делает, а в конце печатается предупреждение о неожиданной корутине. И отсюда же то, что корутину можно создать в одном месте, а запустить в другом.
Фрагмент B. Пятьдесят задач по десять миллисекунд выполняются за сорок шесть миллисекунд вместо пятисот. Но параллелизма здесь нет: пока одна задача ждёт, цикл занимается другими. Работал всё это время один поток.
Значит выигрыш возникает ровно там, где есть ожидание — сеть, диск, ответ СУБД. На вычислениях его нет вовсе: сотня корутин, считающих хеши, выполнятся ровно за сумму своих времён, и цикл событий тут ничем не поможет.
Это главный практический вывод урока, и он выводится из следствия 2 в одну строчку. Управление возвращается циклу только на await. Синхронный вызов, который ждёт секунду, держит управление всю секунду — и весь сервис на это время не обслуживает никого.
Причём внешне ничего не сломано: ошибок нет, задача выполняется, просто под нагрузкой латентность растёт необъяснимо. Один requests.get внутри корутины обнуляет смысл всей асинхронности в процессе.
Отсюда правило без исключений: внутри корутины любая блокирующая операция должна уходить в пул через run_in_executor либо иметь асинхронный аналог. И тот же вопрос надо задавать библиотекам: драйвер БД, HTTP-клиент, работа с файлами — синхронные версии внутри цикла событий недопустимы.
Фрагмент D. create_task планирует корутину и возвращает объект задачи. Цикл событий держит на неё слабую ссылку, чтобы не мешать сборке — то есть, по правилам урока 09, задача может быть собрана до завершения, если её никто не держит.
Проявляется как «задача иногда не доработала» на нагрузке — и это худший вид бага: воспроизводится редко, зависит от момента срабатывания сборщика. Лечится тем, что вытекает из механизма: сложить задачи в множество и убирать по завершении, либо использовать TaskGroup, который держит их сам.
Фрагмент C. TaskGroup при исключении в любой задаче отменяет остальные и на выходе гарантирует, что ничего не осталось работать. Ошибки приходят группой, ловятся через except* (урок 12).
gather по умолчанию ведёт себя иначе: отдаёт первое исключение, а соседние задачи продолжают выполняться в фоне — с их результатами и их возможными ошибками, о которых уже никто не узнает. Это и есть «структурная конкурентность» против её отсутствия: у TaskGroup область видимости блока совпадает с временем жизни задач.
Отмена — это исключение, а не убийство. CancelledError возбуждается внутри задачи в точке await. Значит задача может её перехватить и не отмениться; значит except Exception её не поймает (с 3.8 она наследуется от BaseException) — а вот except BaseException поймает и сломает отмену.
Цикл событий не вытесняет. Корутина, ушедшая в бесконечный цикл без await, не будет прервана ничем: планировщик кооперативный, а не вытесняющий. Отличие от потоков принципиальное.
Асинхронность не отменяет GIL, а обходит другую проблему. Она про ожидание, а не про вычисления. Для счётной нагрузки нужны процессы или расширение, отпускающее GIL — это R3 и R6, а не R5.
Корень R5: приостановленный кадр с двусторонним каналом. Асинхронность Python — не отдельная подсистема, а применение механизма обхода к другой задаче.
Хронология это подтверждает буквально. В 2005 yield стал двусторонним (PEP 342) — и появились библиотеки, писавшие конкурентность на голых генераторах. В 2012 добавили yield from (PEP 380) — и цепочки стали строиться насквозь. В 2014 на этом вырос asyncio, где корутина была именно генератором с декоратором. И только в 2015 (PEP 492) появились слова async и await — как отдельный синтаксис для того, что уже работало.
Поэтому знание урока 13 здесь не «полезный контекст», а буквально механизм: понимая приостановленный кадр и канал yield from, ты уже понимаешь await.
Точнее всего — корутины C++20, как и в прошлом уроке: co_await и await делают одно и то же, приостанавливая кадр и отдавая управление вызывающему. Если держать это в голове, весь asyncio раскладывается на две части: примитив языка (кадр плюс приостановка) и обычная библиотека поверх него (очередь готовых задач, селектор на epoll, таймеры). Ничего магического во второй части нет — это цикл, который можно написать самому за вечер.
Второй полезный образ — кооперативная многозадачность в старых системах: задача сама решает, когда отдать управление. Windows 3.x и Mac OS до X работали так же, и болели тем же самым — одна зависшая программа вешала всё. Следствие 3 — это ровно та болезнь, и она принципиальна для модели, а не является дефектом реализации.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
await?Потому что тогда исчезла бы единственная гарантия, ради которой asyncio существует.
await — не украшение, а маркер точки переключения. Пока его нет, код исполняется без вытеснения: между двумя await никто не влезет. Это то, чего нет у потоков, и именно поэтому в asyncio не нужны блокировки для «прочитать-изменить-записать».
counter = 0
async def bump():
global counter
tmp = counter # между этими строками
tmp += 1 # переключения не будет —
counter = tmp # нет ни одного await
С неявной асинхронностью каждая строка стала бы потенциальной точкой переключения, и мы получили бы всё те же гонки, что в потоках, — только без вытеснения по таймеру, то есть недетерминированные и невоспроизводимые.
Второе: await — это документация. Читая функцию, видно все места, где управление может уйти. Ревью «где здесь может влезть другой запрос» сводится к поиску слова в тексте.
Цена решения известна как «раскрашивание функций»: асинхронную нельзя вызвать из синхронной, и async расползается вверх по стеку до самого входа. Go выбрал наоборот — горутины и неявное переключение, — и заплатил тем, что там нужны мьютексы и race detector. Это обмен, а не победа одной стороны.
Потому что он не добавляет исполнителей, а только эффективнее ждёт.
Цикл событий — один поток. Он может держать десять тысяч открытых сокетов, потому что ожидание не требует процессора: задача снимается с исполнения на 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++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 в чужом коде.
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 мс | время |
|---|---|
последовательно через await | 0.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)
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
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)
# Что появится в выводе, если внутри есть синхронный вызов?
Третий вопрос — прямое подтверждение того, что планировщик кооперативный: соседняя задача не получит управления ни разу, пока цикл не закончится.
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 не делает ничего из этого не по недосмотру, а потому что появился на девять лет раньше.
Из 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. Кадр, приостановка и возобновление — те же самые.
Про цену. Переключение между корутинами — это возобновление кадра, то есть десятки наносекунд; переключение потоков ОС — микросекунды, процессов — больше. Отсюда и порядок величин: десятки тысяч одновременных корутин нормальны, десятки тысяч потоков — нет. Но каждая корутина держит кадр в куче, поэтому память растёт линейно по числу задач, и миллион одновременных соединений упрётся именно в это.
Тег v3.11.15.
Objects/genobject.c — тип корутины живёт там же, где генератор, и переиспользует gen_send_ex. Прямое подтверждение того, что механизм один.Lib/asyncio/ — вся библиотека на Python, ничего в C. Читается целиком за вечер; начинать с base_events.py и tasks.py.Modules/_asynciomodule.c — ускоренные версии Future и Task. Полезно знать, что они есть: питоновские версии в Lib читаемее, а исполняются обычно эти.async/await; в мотивации прямо написано, что механизм уже работал на генераторах и добавляется синтаксис.yield from, из которых всё это вырослоLib/asyncio/base_events.py, метод _run_once — читать целиком. Полсотни строк, после которых цикл событий перестаёт быть чёрным ящиком.slow_callback_duration.