Цикл событий: один поток, очередь и шесть шагов, которые повторяются
Цикл событий — это обычный while True с шестью шагами внутри. Сколько раз он повторится, задают не задачи, а их приостановки: замените десять корутин на тысячу — повторов не прибавится. Отсюда и главная ловушка: один time.sleep останавливает весь сервис.
Полное техническое изложение
TL;DR
- Цикл событий — это один работник, который берёт стопку дел, делает всё, что в ней лежало, и только потом смотрит, не появилось ли новых.
- Работник один. Пока он занят одним делом, он не смотрит ни на часы, ни на дверь.
- Поэтому один
time.sleep(0.1)внутри корутины останавливает не одну задачу, а весь сервис. Измерено: пятьдесят таких задач — это пять секунд, в течение которых не выполнится вообще ничего. awaitсам по себе почти бесплатный: 90,8 наносекунды, из которых 69,0 — создание объекта корутины. Дорого стоит настоящее ожидание, а не словоawait.
Один человек за стойкой
Представьте стойку выдачи. За ней один человек. Перед ним лежит стопка бумажек — дела, которые пора сделать. Рядом будильник: его можно завести на любое время. И есть окошко, куда просовывают новые заявки.
Правило у него одно, и оно важнее всего остального:
Взял стопку — сделай всё, что в ней было в момент, когда взял. Бумажки, подложенные пока работал, — в следующий заход.
Когда стопка пуста и будильник ещё не звонит, человек спит. Не сидит, глядя в стену, а спит по-настоящему — и просыпается либо от будильника, либо когда в окошко просунут заявку.
Вот и весь цикл событий. Всё остальное — подробности.
Из чего он сделан
Если открыть исходники, окажется, что это буквально три вещи:
- стопка — очередь дел, которые пора сделать прямо сейчас;
- будильник — набор таймеров, отсортированных по времени; ближайший всегда сверху;
- окошко — то, что следит за сетевыми соединениями и умеет спать, пока ничего не происходит.
Никакого «планировщика», который решает, кому дать поработать, там нет. Есть очередь и правило «делай всё, что в ней лежало».
Переключите вкладку «Из чего состоит» — там эти три части нарисованы с их настоящими именами и стрелками, кто куда попадает.
Что делает await
await — это не «подожди здесь». Это «мне нужен результат; если его пока нет,
положи меня спать и займись другими».
async def fetch_data():
response = await request_the_server() # <- вот здесь функция уходит спать
return response.text # <- а сюда вернётся, когда ответ придётМежду этими двумя строками может пройти секунда, и всю секунду человек за стойкой занимается другими делами. Функция не «висит» — её просто отложили в сторону, а когда результат появился, положили обратно в стопку.
Важная деталь: если результат уже готов, никто никуда не уходит. Функция
просто продолжается. Поэтому три вложенных await подряд, где никто ничего не
ждёт, стоят ровно столько же, сколько пустая программа, — это измерено.
Почему один time.sleep ломает всё
Теперь понятно и главное правило асинхронного кода.
async def bad():
time.sleep(0.1) # человек за стойкой замер на 100 мс
async def good():
await asyncio.sleep(0.1) # человек завёл будильник и занялся другимиРазница не в том, что первое «медленнее». Разница в том, что первое занимает единственного работника, и всё остальное просто не происходит.
Измерено на CPython 3.13.7 — пятьдесят задач, каждая занята 100 мс:
| общее время | |
|---|---|
time.sleep(0.1) | 5,012 с |
await asyncio.sleep(0.1) | 0,101 с |
Пятьдесят раз по сто миллисекунд сложились в пять секунд, потому что все пятьдесят задач попали в одну стопку и были сделаны подряд, одна за другой. Во втором случае все пятьдесят будильников тикали одновременно.
Второй замер на той же вкладке нагляднее. Фоновая задача просит будить её каждые 10 миллисекунд — за секунду она должна тикнуть примерно сто раз.
- Работник блокирует: тикнула 4 раза.
- Работник уступает: тикнула 99 раз.
Причём работник из первой строки уступал управление после каждой порции — он блокирует только внутри неё. Не помогло: одной уступки на сто миллисекунд работы слишком мало.
Что делать, если блокирующее неизбежно
Иногда нужно вызвать библиотеку, которая про асинхронность не знает. Для этого есть готовый способ — отдать её отдельному потоку:
result = await asyncio.to_thread(slow_function, argument)Человек за стойкой отдаёт заявку соседу в подсобку и возвращается к своей стопке. Сосед закончит — положит результат в стопку обычной бумажкой. Это единственный правильный ответ на «мне нужно вызвать блокирующее».
Три вещи, которые стоит запомнить
Работник один. Всё остальное — следствия.
await — это не пауза, а отметка «здесь можно отложить». Если откладывать
нечего, ничего и не происходит.
Медленно не то, что долго ждёт, а то, что долго занимает. Задача, которая десять секунд ждёт ответа по сети, не мешает никому. Задача, которая сто миллисекунд считает, мешает всем.
TL;DR
Цикл событий — это while True, внутри которого шесть шагов. Пятый складывает
в очередь всё, чей срок пришёл; шестой — единственное место, где что-либо
вызывается. Длина очереди фиксируется до прохода, поэтому колбэк, добавленный
во время оборота, выполнится только на следующем.
Из этого следует всё остальное. Тысяча корутин с sleep(0) даёт столько же
оборотов, сколько десять: обороты считают приостановки, а не задачи. Пустая
программа asyncio.run делает шесть оборотов, из которых четыре — процедуры
завершения. А пятьдесят задач с time.sleep(0.1) внутри дают не пятьдесят
оборотов, а два, из которых один длится пять секунд.
Зачем это знать?
Асинхронный код в Python пишут все, а объяснить, что происходит между await и
продолжением функции, могут немногие. Обычно это не мешает — пока не случается
одно из трёх: сервис необъяснимо «залипает» на секунду; фоновая задача, которая
должна тикать каждые 10 мс, тикает раз в 300; или добавление ещё одной задачи
роняет отзывчивость всего приложения.
Все три случая объясняются одним механизмом, и он умещается в одну функцию на восемьдесят строк. Эта статья разбирает её целиком — и проверяет каждое утверждение замером на живом интерпретаторе.
Модель в голове
Представьте одного человека за стойкой. Перед ним лежит стопка бумажек — это очередь готовых дел. Рядом стоит будильник, который можно завести на любое время. И есть окошко, в которое иногда просовывают новые заявки.
Человек работает по одному правилу: берёт стопку, делает всё, что в ней лежало на момент, когда он её взял, и только потом смотрит на будильник и в окошко. Бумажки, которые подложили, пока он работал, попадут в следующий заход.
Пока стопка пуста, а будильник ещё не звонит, он спит — не «крутится вхолостую», а действительно спит, и просыпается либо от будильника, либо от заявки в окошке.
Всё остальное в этой статье — уточнение этой картинки до уровня, на котором её можно проверить кодом. Одна деталь важна сразу: человек один. Пока он занят одной бумажкой, он не смотрит ни на будильник, ни в окошко. Не потому, что не хочет, а потому что он один.
Из чего состоит цикл
Прежде чем разбирать, что цикл делает, стоит увидеть, из чего он сделан. Это буквально объект с тремя контейнерами, и всё происходящее в асинхронной программе — перекладывание работы между ними.
# base_events.py, BaseEventLoop.__init__ — тег v3.13.7
class BaseEventLoop(events.AbstractEventLoop):
def __init__(self): # :419
self._timer_cancelled_count = 0 # :420
self._ready = collections.deque() # :423
self._scheduled = [] # :424
self._clock_resolution = time.get_clock_info('monotonic').resolution # :430# selector_events.py, BaseSelectorEventLoop.__init__ — тот же тег
if selector is None:
selector = selectors.DefaultSelector() # :63
self._selector = selector # :65Пять строк — и это весь состав.
_ready — очередь того, что пора делать прямо сейчас. Обычная deque.
Это единственный вход в исполнение: и сработавший таймер, и готовый сокет, и
call_soon — всё сначала оказывается здесь и только потом выполняется.
_scheduled — куча таймеров, отсортированная по времени срабатывания
(обычный список, но работают с ним через heapq, поэтому в голове всегда
ближайший дедлайн). Сюда попадает всё, что просили сделать «потом»:
call_later, call_at и внутренности asyncio.sleep.
_selector — обёртка над epoll (или kqueue, или select — по
системе). Единственное, что знает про сокеты, и единственное место, где
процесс действительно спит.
Над этими тремя — слой задач и футур, а под ними ровно одна функция, которая
что-либо вызывает. Причём цикл о задачах не знает вообще: Task и
Future живут поверх него и общаются с ним только через call_soon и
call_later. Для цикла всё сущее — это колбэки.
Вкладка «Из чего состоит» ниже показывает эту схему целиком, с настоящими именами полей и стрелками, кто куда попадает. Переключите на «Схему оборота» — и та же картинка начнёт подсвечивать те части, которые работают на каждом шаге.
Шесть шагов одного оборота
Цикл событий в CPython — это метод _run_once в
Lib/asyncio/base_events.py. Его вызывает run_forever, и вся конструкция
буквально такая:
# base_events.py:678–687, тег v3.13.7
def run_forever(self):
...
self._run_forever_setup()
try:
while True:
self._run_once()
if self._stopping:
break
finally:
self._run_forever_cleanup()Внутри _run_once — шесть шагов, и дальше в тексте они называются по номерам:
- Уборка отменённых таймеров (строки 1978–1999).
- Сколько можно спать (2001–2010) — таймаут для следующего шага.
selector.select(timeout)(2012) — единственное место, где процесс действительно блокируется.- Разбор готового ввода-вывода (2013) — дескрипторы превращаются в
колбэки и кладутся в
_ready, но не выполняются. - Снятие просроченных таймеров (2017–2025) — тоже в
_ready. - Единственное место, где колбэки вызываются (2027–2051).
Вернитесь к визуализации выше и переключите её в режим «Схема оборота»: она подсветит, какая часть работает на каждом шаге. В режиме «Запись прогона» те же шаги показаны на настоящей программе.
Шаг, ради которого стоит остановиться, — последний. В исходниках рядом с ним стоит комментарий, который объясняет устройство планировщика лучше любого пересказа:
This is the only place where callbacks are actually called. All other places just add them to ready.
И следом — то, что делает планировщик честным:
# base_events.py:2033–2035
ntodo = len(self._ready)
for i in range(ntodo):
handle = self._ready.popleft()ntodo считается до цикла. Колбэк, который добавят в _ready во время
этого прохода, в него уже не попадёт — он дождётся следующего оборота. Без
этой строчки задача, которая бесконечно ставит себе продолжение через
call_soon, заняла бы цикл навсегда.
Сколько оборотов делает ваша программа
Здесь начинаются измерения. Считать обороты можно точно: подменить
BaseEventLoop._run_once обёрткой со счётчиком до создания цикла. Это не
оценка — это ровно те итерации, которые были.
| Программа | 3.12.3 | 3.13.7 | 3.14.7 |
|---|---|---|---|
async def main(): pass | 6 | 6 | 6 |
один await asyncio.sleep(0) | 7 | 7 | 7 |
десять await asyncio.sleep(0) | 16 | 16 | 16 |
три вложенных await без приостановки | 6 | 6 | 6 |
gather из 10 корутин без приостановки | 9 | 9 | 9 |
gather из 10 корутин с sleep(0) | 10 | 10 | 10 |
gather из 100 корутин с sleep(0) | 10 | 10 | 10 |
gather из 1000 корутин с sleep(0) | 10 | 10 | 10 |
Три строки в этой таблице стоит прочитать дважды.
Пустая программа делает шесть оборотов, а не один. Причина — не в цикле, а
в asyncio.run: это три последовательных run_until_complete, и только первый
исполняет ваш код. Второй закрывает асинхронные генераторы
(shutdown_asyncgens), третий — пул потоков по умолчанию
(shutdown_default_executor). Каждый даёт пару оборотов. В режиме «Запись
прогона» выше эти четыре оборота видны поимённо в конце.
Три вложенных await стоят столько же, сколько пустая программа. То есть
нисколько. await сам по себе цикла не касается — об этом ниже.
Тысяча корутин даёт столько же оборотов, сколько десять. Это, пожалуй,
главное, что стоит вынести из статьи: число оборотов определяется глубиной
приостановок, а не количеством задач. Все задачи, готовые к продолжению,
съедаются одним проходом по _ready.
Что такое await на самом деле
Корутина — это генератор с другим протоколом. Её двигают вызовом .send(None),
а return прилетает наружу как StopIteration.value. Это можно проверить
руками, вообще без цикла событий:
async def inner():
return "значение из return"
coro = inner()
try:
coro.send(None) # первый шаг — и сразу последний
except StopIteration as e:
print(e.value) # значение из returnТеперь то же самое, но с приостановкой. Весь протокол умещается в пять строк
Future.__await__:
# futures.py:283–289, тег v3.13.7
def __await__(self):
if not self.done():
self._asyncio_future_blocking = True
yield self # This tells Task to wait for completion.
if not self.done():
raise RuntimeError("await wasn't used with future")
return self.result() # May raise too.yield self пробрасывается вверх через всю цепочку await и вылезает наружу
как результат coro.send(None). Флаг _asyncio_future_blocking — это условный
знак «я настоящий awaitable, паркуй задачу»; задача проверяет его в
tasks.py:324–325.
Отсюда следствие, которое объясняет строку «три вложенных await» из таблицы:
если по пути не встретилось ни одного приостанавливающегося Future, вся
цепочка await схлопывается без единого захода в цикл. Она стоит ровно
столько, сколько стоит создать объекты корутин.
Что это в наносекундах:
| Операция | 3.13.7 | 3.14.7 | во сколько раз дороже вызова (3.13.7) |
|---|---|---|---|
| обычный вызов функции | 44,5 нс | 36,1 нс | ×1,0 |
await корутины без приостановки | 90,7 нс | 91,0 нс | ×2,0 |
создать объект корутины и закрыть, без await | 68,9 нс | 76,6 нс | ×1,5 |
await уже завершённого Future | 370,2 нс | 402,4 нс | ×8,3 |
await asyncio.sleep(0) — ровно один оборот | 2087 нс | 2007 нс | ×46,9 |
Читать надо по строкам, а не по колонкам: кратности в последнем столбце измерены внутри одного запуска 3.13.7 и потому верны, а две колонки — две разные сборки.
Вторая и третья строки близки, и это ответ на вопрос «дорог ли await». Из
90,8 нс на создание самого объекта корутины приходится 69,0 — три
четверти. Собственно машинерия await стоит примерно двадцать наносекунд.
Дорога не она, а настоящая приостановка с возвратом в цикл: 2,0 мкс, в
двадцать с лишним раз больше.
Как футура будит задачу
Это место, где чаще всего ошибаются, и ошибка звучит невинно: «set_result
возобновляет корутину». Не возобновляет.
# futures.py:158–170, тег v3.13.7
def __schedule_callbacks(self):
callbacks = self._callbacks[:]
if not callbacks:
return
self._callbacks[:] = []
for callback, ctx in callbacks:
self._loop.call_soon(callback, self, context=ctx)set_result, set_exception и cancel — все три заканчиваются вызовом этой
функции, а она ставит колбэк в очередь. Корутина продолжится не здесь, а
в шестом шаге следующего оборота.
Полная петля выглядит так:
await fut→Future.__await__делаетyield self;- задача видит флаг, вешает
add_done_callback(self.__wakeup)и запоминает футуру в_fut_waiter(tasks.py:341–343) — с этого момента цикл о задаче не знает; - когда-то потом кто-то вызывает
fut.set_result(v); __schedule_callbacksкладётTask.__wakeupв_readyчерезcall_soon;- шестой шаг оборота вызывает
__wakeup, тот вызывает__step, тот —coro.send(None); __await__возвращаетself.result(), и функция продолжается со следующей строки.
asyncio.sleep(delay) — самый короткий полный пример этой петли:
# tasks.py:713–720, тег v3.13.7
future = loop.create_future()
h = loop.call_later(delay, futures._set_result_unless_cancelled, future, result)
try:
return await future
finally:
h.cancel()Футура, таймер, ожидание — и всё. Никакой «магии сна» в asyncio нет: есть
таймер, который через delay секунд положит результат в футуру, и это разбудит
задачу по петле выше.
Почему один блокирующий вызов останавливает всё
Теперь механизм собран, и главное следствие можно не декларировать, а показать.
Возьмём N задач, каждая занята 100 мс. В одном варианте — time.sleep(0.1),
в другом — await asyncio.sleep(0.1). Замеряется общее время, число оборотов
и длительность самого долгого оборота.
Смотреть надо не на время, а на число оборотов. У блокирующего варианта оно не растёт вовсе: и одна задача, и пятьдесят дают два оборота. Растёт длительность одного оборота — до 5011 мс.
Это ровно то, что предсказывает ntodo = len(self._ready). Все пятьдесят задач
были готовы одновременно, попали в очередь и были съедены одним проходом
шестого шага. Их блокировки сложились.
Обратите внимание и на кооперативный вариант: там тоже есть оборот длиной
100 мс. Разница не в длительности, а в том, что происходит внутри: в первом
случае поток работает и не может отреагировать ни на что, во втором —
спит в select() и проснётся от любого события. На шкале времени эти два
прямоугольника выглядят одинаково, поэтому они покрашены по-разному.
Вкладка «Отзывчивость» переводит это на язык, который чувствуется: фоновая задача просит будить её каждые 10 мс, пока работник делает десять порций работы по 100 мс, уступая управление между порциями. Уступают оба варианта; разница только в том, блокирует ли работник внутри порции. При блокирующем работнике пульс тикнул 4 раза за секунду с медианой интервала 300,9 мс. При кооперативном — 99 раз с медианой 10,2 мс.
Почему 300, а не 100 — вопрос, на который стоит ответить, потому что ответ
целиком выводится из шестого шага. Пульс теряет оборот дважды: таймер,
истёкший во время блокировки, снимается только на пятом шаге следующего
оборота, а колбэк, попавший в _ready во время оборота, выполняется лишь
оборотом позже — из-за ntodo. Значит, между двумя тиками пульса проходит не
один оборот работника, а три: свой и два потерянных. Три оборота по 100 мс и
дают 300. Проверено
трассировкой: в записи видно TaskStepMethWrapper в трёх оборотах подряд,
прежде чем появляется Task.task_wakeup пульса.
Как увидеть это в логе, а не в профайлере
У asyncio есть штатный способ поймать блокировку: режим отладки. По умолчанию он
выключен, а порог, за которым коллбэк считается медленным, — 100 миллисекунд
(Lib/asyncio/base_events.py:437).
Callbacks taking longer than 100 milliseconds are logged. The
loop.slow_callback_duration attribute can be used to set the minimum execution
duration in seconds that is considered "slow".
Коллбэки дольше 100 миллисекунд записываются в лог. Атрибутом loop.slow_callback_duration задаётся минимальная длительность выполнения в секундах, которая считается «медленной».
Режим и порог — две независимые ручки, и это сбивает чаще всего. Проверено
на блокировке в 0,25 с (bench/cancellation/06_debug_slow_callback.py):
| режим | порог | что в логе |
|---|---|---|
| выключен | по умолчанию | ничего |
| включён | 0,1 с | WARNING:asyncio:Executing <Handle …> took <N> seconds |
| включён | 1,0 с | ничего: блокировка короче порога |
| включён | 0,01 с | то же предупреждение |
Формат строки один, а объект в ней меняется — и по нему видно, что именно заблокировало цикл:
<Handle …>— обычный коллбэк, поставленный черезcall_soon;<TimerHandle when=… …>— отложенный, черезcall_later;<Task … coro=<…>>— шаг корутины, то есть ровно тот случай, который разбирался выше: задача не отдала управление.
Включается снаружи, не трогая код: PYTHONASYNCIODEBUG=1, или python -X dev,
или asyncio.run(main(), debug=True). Порог задаётся отдельно и только вместе с
режимом: loop.slow_callback_duration = 0.05.
Заодно режим отладки включает отслеживание места создания корутины: к
предупреждению coroutine ... was never awaited добавляется строка
Coroutine created at <файл:строка> — и забытая корутина перестаёт быть загадкой.
Что изменилось в 3.12, 3.13 и 3.14
Само ядро цикла — не менялось. Тело _run_once в тегах v3.13.7 и v3.14.0
совпадает побайтово, все 83 строки; сдвинулся только номер первой строки,
1970 → 1966. Числа оборотов в таблице выше одинаковы во всех трёх версиях —
планировщик считает одинаково. Скорость этими замерами между версиями не
сравнивается: три сборки различаются не только версией языка.
Менялось вокруг.
3.12 — eager tasks. asyncio.eager_task_factory заставляет корутину
начинать выполняться синхронно прямо в конструкторе задачи. Документация тут
предельно точна: Tasks are only scheduled on the event loop if they block
(задачи попадают в расписание цикла событий, только если они приостанавливаются).
Отсюда свойство, которое стоит знать до того, как включать: ускорение получают
только задачи, завершающиеся без единой приостановки. Если тело
приостанавливается, выигрыша нет — задача всё равно регистрируется в цикле.
3.13 — корректность отмены. Исправлена ситуация, в которой две вложенные
TaskGroup с одновременными исключениями могли повиснуть; заодно группы стали
сохранять счётчик отмен (Task.cancelling()). Появились
asyncio.Queue.shutdown, Server.close_clients().
3.14 — интроспекция и свободные потоки. Появился
python -m asyncio ps PID и pstree PID — просмотр дерева задач живого
процесса, и модуль asyncio.graph с print_call_graph(). Оптимизация,
заявленная в примечаниях к выпуску, — improved by 10-20%
(улучшено на 10–20 %) за счёт нового
двусвязного списка задач, своего у каждого потока. Свободнопоточные сборки
получили first class support for free-threading builds… scaling linearly with
the number of threads
(полноценная поддержка свободных от GIL сборок… с линейным масштабированием по числу потоков). И то, что ломает существующий код:
asyncio.get_event_loop теперь бросает RuntimeError, если цикла нет, а
не создаёт его молча.
| Версия | Изменение | Статус порядка |
|---|---|---|
| 3.12 | asyncio.eager_task_factory; C-реализация current_task; loop_factory у asyncio.run | |
| 3.13 | Исправлено зависание вложенных TaskGroup; Queue.shutdown; as_completed отдаёт исходные задачи | |
| 3.14 | python -m asyncio ps/pstree и asyncio.graph; get_event_loop больше не создаёт цикл; поддержка свободнопоточных сборок |
Что с этим делать
Не вызывайте блокирующее в корутине. Это единственное правило, из которого
следуют остальные, и теперь понятно почему: поток один, а шестой шаг — обычный
for без прерываний.
Если блокирующее неизбежно — await loop.run_in_executor(...) или
asyncio.to_thread(...). Работа уедет в поток, а цикл вернётся в select().
Уступать между порциями работы недостаточно. В замере выше работник честно
делал await asyncio.sleep(0) после каждой стомиллисекундной порции — и пульс
всё равно потерял 95 тиков из 99. Одна уступка на 100 мс работы не спасает.
asyncio.run в цикле — антипаттерн. Замер:
один asyncio.run(noop()) стоит 71 мкс, а та же корутина внутри уже
работающего цикла — 0,12 мкс. То есть 99,8 % бюджета уходит на создание и
закрытие цикла плюс те самые четыре оборота завершения. Для повторных запусков
есть asyncio.Runner: он переиспользует один цикл и обходится в 10 мкс на
запуск — в семь раз дешевле.
Не оптимизируйте await. Он стоит 91 нс, и три четверти этого бюджета
уходят на создание объекта корутины. Оптимизировать имеет смысл число приостановок, а не
число await.
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Цикл событий — это
while True, внутри которого шесть шагов. Пятый складывает в очередь всё, чей срок пришёл; шестой — единственное место, где что-либо вызывается. Длина очереди фиксируется до прохода, поэтому колбэк, добавленный во время оборота, выполнится только на следующем. - Из этого следует всё остальное. Тысяча корутин с
sleep(0)даёт столько же оборотов, сколько десять: обороты считают приостановки, а не задачи. Пустая программаasyncio.runделает шесть оборотов, из которых четыре — процедуры завершения. А пятьдесят задач сtime.sleep(0.1)внутри дают не пятьдесят оборотов, а два, из которых один длится пять секунд.
На самом деле
- Он спит. Третий шаг оборота —
selector.select(timeout), единственное место, где процесс действительно блокируется; таймаут вычисляется как время до ближайшего таймера. В записи прогонаgather(sleep(0.02), sleep(0.05))это видно прямо: оборот 3 спит 0.019983 с, оборот 6 — 0.029687 с. Не фиксированный тик, а ровно до ближайшего дедлайна. - Не возобновляет.
set_resultвызывает__schedule_callbacks(futures.py:158–170), а та кладётTask.__wakeupв очередь черезcall_soon. Корутина продолжится в шестом шаге СЛЕДУЮЩЕГО оборота. Разница видна в замере с пульсом: колбэк, попавший в очередь во время оборота, ждёт следующего — из-за этого пульс теряет лишние 100 мс. - Обороты считают приостановки, а не задачи.
gatherиз 10, 100 и 1000 корутин сawait asyncio.sleep(0)даёт одно и то же число оборотов — 10, и это одинаково на 3.12.3, 3.13.7 и 3.14.7. Все готовые задачи съедаются одним проходом по_ready, потому чтоntodoфиксируется до цикла. - Переключение происходит только при настоящей приостановке. 90,8 нс на 3.13.7 — против 44,6 нс у обычного вызова функции, и 69,0 нс из этих 90,8 уходит на создание объекта корутины, а не на механику ожидания. Дорога настоящая приостановка:
await asyncio.sleep(0)стоит 2033 нс, в двадцать с лишним раз больше. Экономить надо число приостановок, а не числоawait. - Шесть.
asyncio.run— это три последовательныхrun_until_complete: ваш код, затемshutdown_asyncgens(), затемshutdown_default_executor(). Каждый даёт пару оборотов. Отсюда и практический вывод:asyncio.runв цикле — антипаттерн, для повторных запусков естьasyncio.Runner. - Ускоряет ровно один профиль нагрузки, и документация говорит это прямо: Tasks are only scheduled on the event loop if they block. Задача, которая завершается без единой приостановки, экономит один
call_soonи один оборот. Задача, которая приостанавливается, не экономит ничего — она всё равно регистрируется в цикле. - Зависит от размера порции. В замере работник честно делал
await asyncio.sleep(0)после каждой стомиллисекундной порции — и фоновая задача, просившая будить её каждые 10 мс, тикнула 4 раза вместо 99, с медианой интервала 300,9 мс. Уступка помогает, только если порция сопоставима с требуемой отзывчивостью.
По версиям
- 3.12
- asyncio.eager_task_factory; C-реализация current_task; loop_factory у asyncio.run
- 3.13
- Исправлено зависание вложенных TaskGroup; Queue.shutdown; as_completed отдаёт исходные задачи
- 3.14
- python -m asyncio ps/pstree и asyncio.graph; get_event_loop больше не создаёт цикл; поддержка свободнопоточных сборок
Что разобрано
- Зачем это знать?
- Модель в голове
- Из чего состоит цикл
- Шесть шагов одного оборота
- Сколько оборотов делает ваша программа
- Что такое `await` на самом деле
- Как футура будит задачу
- Почему один блокирующий вызов останавливает всё
- Как увидеть это в логе, а не в профайлере
- Что изменилось в 3.12, 3.13 и 3.14
- Что с этим делать
- Чем измерено
Унести в работу
- Не звать
time.sleepвнутри корутины: он останавливает не одну задачу, а весь сервис — пятьдесят таких задач это пять секунд, за которые не выполнится ничего. - Не экономить на
await: он стоит около девяноста наносекунд, из которых большая часть — создание объекта корутины. Дорого стоит настоящее ожидание. - Не считать, что цикл опрашивает задачи: готовность приносит селектор, а не обход списка, и число оборотов не растёт с числом задач.
- Не ждать ускорения от
eager_task_factoryбез замера: выигрыш есть не во всяком случае.
Частые заблуждения
«Цикл событий постоянно опрашивает задачи в поисках готовых».
Он спит. Третий шаг оборота — selector.select(timeout), единственное место, где процесс действительно блокируется; таймаут вычисляется как время до ближайшего таймера. В записи прогона gather(sleep(0.02), sleep(0.05)) это видно прямо: оборот 3 спит 0.019983 с, оборот 6 — 0.029687 с. Не фиксированный тик, а ровно до ближайшего дедлайна.
«fut.set_result(x) возобновляет корутину, которая ждёт эту футуру».
Не возобновляет. set_result вызывает __schedule_callbacks (futures.py:158–170), а та кладёт Task.__wakeup в очередь через call_soon. Корутина продолжится в шестом шаге СЛЕДУЮЩЕГО оборота. Разница видна в замере с пульсом: колбэк, попавший в очередь во время оборота, ждёт следующего — из-за этого пульс теряет лишние 100 мс.
«Чем больше задач, тем больше оборотов делает цикл»
Обороты считают приостановки, а не задачи. gather из 10, 100 и 1000 корутин с await asyncio.sleep(0) даёт одно и то же число оборотов — 10, и это одинаково на 3.12.3, 3.13.7 и 3.14.7. Все готовые задачи съедаются одним проходом по _ready, потому что ntodo фиксируется до цикла.
«await дорогой, его надо экономить»
Переключение происходит только при настоящей приостановке. 90,8 нс на 3.13.7 — против 44,6 нс у обычного вызова функции, и 69,0 нс из этих 90,8 уходит на создание объекта корутины, а не на механику ожидания. Дорога настоящая приостановка: await asyncio.sleep(0) стоит 2033 нс, в двадцать с лишним раз больше. Экономить надо число приостановок, а не число await.
«Пустая программа asyncio.run(main()) делает один оборот цикла»
Шесть. asyncio.run — это три последовательных run_until_complete: ваш код, затем shutdown_asyncgens(), затем shutdown_default_executor(). Каждый даёт пару оборотов. Отсюда и практический вывод: asyncio.run в цикле — антипаттерн, для повторных запусков есть asyncio.Runner.
«eager_task_factory из 3.12 ускоряет asyncio»
Ускоряет ровно один профиль нагрузки, и документация говорит это прямо: Tasks are only scheduled on the event loop if they block
(задачи попадают в расписание цикла событий, только если они приостанавливаются). Задача, которая завершается без единой приостановки, экономит один call_soon и один оборот. Задача, которая приостанавливается, не экономит ничего — она всё равно регистрируется в цикле.
«Если уступать управление между порциями работы, отзывчивость сохранится»
Зависит от размера порции. В замере работник честно делал await asyncio.sleep(0) после каждой стомиллисекундной порции — и фоновая задача, просившая будить её каждые 10 мс, тикнула 4 раза вместо 99, с медианой интервала 300,9 мс. Уступка помогает, только если порция сопоставима с требуемой отзывчивостью.
Проверка знаний
Асинхронный сервис отвечает быстро, но раз в несколько секунд «залипает» на секунду. Процессор при этом загружен на одно ядро. Что проверять первым?
Источники и что читать дальше
10 ИСТОЧНИКОВ
- PEP 3156 — Asynchronous IO Support Rebooted: the «asyncio» ModulePEP. Документ, которым asyncio введён в язык. Статус Final, Python 3.3. Отсюда сама модель: цикл, колбэки, футуры, транспорты.https://peps.python.org/pep-3156/
- PEP 492 — Coroutines with async and await syntaxPEP. Статус Final, Python 3.5. Вводит
async defиawaitкак отдельный протокол поверх генераторов — то, из-за чегоawaitможно объяснить черезsend.https://peps.python.org/pep-0492/ - Lib/asyncio/base_events.py — _run_onceИсходный код CPython. Строки 1970–2051: шесть шагов одного оборота. Там же константы _MIN_SCHEDULED_TIMER_HANDLES = 100 (строка 58), _MIN_CANCELLED_TIMER_HANDLES_FRACTION = 0.5 (62) и MAXIMUM_SELECT_TIMEOUT = 24 * 3600 (68). Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/base_events.py
- Lib/asyncio/futures.py — Future.__await__ и __schedule_callbacksИсходный код CPython. Строки 283–289 — пять строк, в которых
yield selfи флаг_asyncio_future_blockingобразуют весь протокол приостановки. Строки 158–170 — почему set_result не возобновляет корутину, а только ставит колбэк в очередь. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/futures.py - Lib/asyncio/tasks.py — Task.__step_run_and_handle_result и asyncio.sleepИсходный код CPython. Строки 298–371: разбор того, что вернул
coro.send(None). Строки 703–720: sleep как минимальный полный пример петли «футура — таймер — пробуждение». Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/asyncio/tasks.py - Разработка с asyncio — про блокирующий код и многопоточностьОфициальная документация. «If a function performs a CPU-intensive calculation for 1 second, all concurrent asyncio Tasks and IO operations would be delayed by 1 second» (Если функция считает что-то ресурсоёмкое одну секунду, все параллельные задачи asyncio и операции ввода-вывода будут задержаны на эту секунду) — и рядом: «While a Task is running in the event loop, no other Tasks can run in the same thread» (Пока в цикле событий выполняется задача, никакая другая задача в том же потоке выполняться не может).https://docs.python.org/3.13/library/asyncio-dev.html
- Цикл событий — call_soon и call_laterОфициальная документация. «Callbacks are called in the order in which they are registered» (Обратные вызовы вызываются в том порядке, в каком были зарегистрированы) и оговорка про таймеры: «may run up to one clock-resolution early» (может сработать раньше на величину до одного шага часов) — ровно то, что в коде реализовано прибавлением _clock_resolution.https://docs.python.org/3.13/library/asyncio-eventloop.html
- asyncio — задачи: eager_task_factoryОфициальная документация. «Coroutines begin execution synchronously during Task construction. Tasks are only scheduled on the event loop if they block» (Корутины начинают выполняться синхронно уже при создании задачи. В расписание цикла событий задачи попадают, только если приостанавливаются). Появилось в 3.12 — объясняет, почему эта оптимизация помогает только одному профилю нагрузки.https://docs.python.org/3.13/library/asyncio-task.html
- selectors — BaseSelector.selectОфициальная документация. Определение того единственного вызова, в котором асинхронная программа действительно спит: «Wait until some registered file objects become ready, or the timeout expires» (Ждать, пока какие-нибудь зарегистрированные файловые объекты не будут готовы или не истечёт время ожидания).https://docs.python.org/3.13/library/selectors.html
- Что нового в Python 3.14 — asyncioОфициальная документация. Два заявления, на которые опирается раздел про версии: «improved by 10-20% following the implementation of a new per-thread doubly linked list for native tasks» (улучшено на 10–20 % благодаря новому двусвязному списку встроенных задач, отдельному для каждого потока) и «first class support for free-threading builds… scaling linearly with the number of threads» (полноценная поддержка свободных от GIL сборок… с линейным масштабированием по числу потоков). Там же — get_event_loop теперь бросает RuntimeError вместо создания цикла.https://docs.python.org/3.14/whatsnew/3.14.html