Deep Engineering
Поиск
Продвинутый·Опубликовано·3.12 · 3.13 · 3.14·25 МИН

Цикл событий: один поток, очередь и шесть шагов, которые повторяются

Пустая асинхронная программа делает шесть оборотов цикла, а тысяча корутин даёт ровно столько же оборотов, сколько десять. Число оборотов определяется не количеством задач, а числом приостановок, и из этого следует всё остальное, включая то, почему один time.sleep останавливает весь сервис.

Полное техническое изложение

TL;DR

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

Из этого следует всё остальное. Тысяча корутин с sleep(0) даёт столько же оборотов, сколько десять: обороты считают приостановки, а не задачи. Пустая программа asyncio.run делает шесть оборотов, из которых четыре — процедуры завершения. А пятьдесят задач с time.sleep(0.1) внутри дают не пятьдесят оборотов, а два, из которых один длится пять секунд.

Зачем это знать?

Асинхронный код в Python пишут все, а объяснить, что происходит между await и продолжением функции, могут немногие. Обычно это не мешает — пока не случается одно из трёх: сервис необъяснимо «залипает» на секунду, asyncio.sleep(0.01) почему-то спит 30 мс, или добавление ещё одной задачи роняет отзывчивость всего приложения.

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

Модель в голове

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

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

Пока стопка пуста, а будильник ещё не звонит, он спит — не «крутится вхолостую», а действительно спит, и просыпается либо от будильника, либо от заявки в окошке.

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

Из чего состоит цикл

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

PYTHON
# 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
PYTHON
# 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. Для цикла всё сущее — это колбэки.

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

BaseEventLoop, v3.13.7

Цикл событий — это объект с тремя контейнерами. Всё, что происходит в асинхронной программе, — это перекладывание работы между ними.

КТО ПРОСИТ
Задачи и футуры
Task / Future
call_later ↓
call_at
call_soon ↓
add_done_callback
ГДЕ ЖДЁТ
_scheduled
list + heapq
шаг 5
_ready
collections.deque
шаг 4
_selector
selectors.DefaultSelector
шаг 6 ↓ ntodo фиксируется до прохода
ГДЕ ВЫПОЛНЯЕТСЯ — ЕДИНСТВЕННОЕ МЕСТО
Handle._run()
events.py:87–105
↑ колбэк продолжает задачу, та ставит новые — и круг замыкается
Задачи и футуры · Task / Future · tasks.py, futures.py
Кто просит. Задача, дошедшая до await, паркует себя и вешает колбэк на футуру. Сам цикл о задачах ничего не знает — он знает только колбэки.
_scheduled · list + heapq · base_events.py:424
Куча таймеров, отсортированная по времени срабатывания. Сюда попадает всё, что просили сделать «потом»: call_later, call_at, внутренности asyncio.sleep.
_ready · collections.deque · base_events.py:423
Очередь того, что пора делать прямо сейчас. Единственный вход в исполнение: и таймеры, и готовый ввод-вывод, и call_soon — всё сначала оказывается здесь.
_selector · selectors.DefaultSelector · selector_events.py:63–65
Обёртка над epoll (или kqueue, или select — по системе). Единственное место, где процесс спит по-настоящему, и единственное, что знает про сокеты.
Handle._run() · events.py:87–105 · base_events.py:2050
Собственно вызов. Каждый колбэк исполняется в своей копии contextvars.Context; исключение уходит в обработчик цикла, а не роняет его.
Поля настоящие: _ready и _scheduled заводятся в BaseEventLoop.__init__ (base_events.py:423 и :424), селектор — в BaseSelectorEventLoop.__init__ (selector_events.py:63–65). Тег v3.13.7.

Шесть шагов одного оборота

Цикл событий в CPython — это метод _run_once в Lib/asyncio/base_events.py. Его вызывает run_forever, и вся конструкция буквально такая:

PYTHON
# 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 — шесть шагов. Вернитесь к визуализации выше и переключите её в режим «Схема оборота»: она подсветит, какая часть работает на каждом шаге. В режиме «Запись прогона» те же шаги показаны на настоящей программе.

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

This is the only place where callbacks are actually called. All other places just add them to ready.

И следом — то, что делает планировщик честным:

PYTHON
# 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.33.13.73.14.0rc2
async def main(): pass666
один await asyncio.sleep(0)777
десять await asyncio.sleep(0)161616
три вложенных await без приостановки666
gather из 10 корутин без приостановки999
gather из 10 корутин с sleep(0)101010
gather из 100 корутин с sleep(0)101010
gather из 1000 корутин с sleep(0)101010

Три строки в этой таблице стоит прочитать дважды.

Пустая программа делает шесть оборотов, а не один. Причина — не в цикле, а в asyncio.run: это три последовательных run_until_complete, и только первый исполняет ваш код. Второй закрывает асинхронные генераторы (shutdown_asyncgens), третий — пул потоков по умолчанию (shutdown_default_executor). Каждый даёт пару оборотов. В режиме «Запись прогона» выше эти четыре оборота видны поимённо в конце.

Три вложенных await стоят столько же, сколько пустая программа. То есть нисколько. await сам по себе цикла не касается — об этом ниже.

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

Что такое await на самом деле

Корутина — это генератор с другим протоколом. Её двигают вызовом .send(None), а return прилетает наружу как StopIteration.value. Это можно проверить руками, вообще без цикла событий:

PYTHON
async def inner():
    return "значение из return"
 
coro = inner()
try:
    coro.send(None)          # первый шаг — и сразу последний
except StopIteration as e:
    print(e.value)           # значение из return

Теперь то же самое, но с приостановкой. Весь протокол умещается в пять строк Future.__await__:

PYTHON
# 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.73.14.0rc2во сколько раз дороже вызова функции
обычный вызов функции20,3 нс20,3 нс×1,0
await корутины без приостановки56,6 нс59,8 нс×2,8
создать объект корутины и закрыть, без await53,8 нс52,1 нс×2,7
await уже завершённого Future270,4 нс315,8 нс×13,3
await asyncio.sleep(0) — ровно один оборот1698 нс1591 нс×83,7

Вторая и третья строки почти совпадают, и это ответ на вопрос «дорог ли await». Из 56,6 нс около 54 — это создание самого объекта корутины. Собственно машинерия await стоит единицы наносекунд. Дорога не она, а настоящая приостановка с возвратом в цикл: 1,7 мкс, в тридцать раз больше.

Утверждение

«await — это переключение контекста, поэтому его надо экономить».

На самом деле

Переключение происходит только при настоящей приостановке. await корутины, которая ничего не ждёт, стоит 56,6 нс, из которых 54 — создание объекта корутины; до цикла событий дело не доходит вовсе.

Как футура будит задачу

Это место, где чаще всего ошибаются, и ошибка звучит невинно: «set_result возобновляет корутину». Не возобновляет.

PYTHON
# 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 — все три заканчиваются вызовом этой функции, а она ставит колбэк в очередь. Корутина продолжится не здесь, а в шестом шаге следующего оборота.

Полная петля выглядит так:

  1. await futFuture.__await__ делает yield self;
  2. задача видит флаг, вешает add_done_callback(self.__wakeup) и запоминает футуру в _fut_waiter (tasks.py:341–343) — с этого момента цикл о задаче не знает;
  3. когда-то потом кто-то вызывает fut.set_result(v);
  4. __schedule_callbacks кладёт Task.__wakeup в _ready через call_soon;
  5. шестой шаг оборота вызывает __wakeup, тот вызывает __step, тот — coro.send(None);
  6. __await__ возвращает self.result(), и функция продолжается со следующей строки.

asyncio.sleep(delay) — самый короткий полный пример этой петли:

PYTHON
# 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). Замеряется общее время, число оборотов и длительность самого долгого оборота.

CPython 3.13.7
поток занят — реагировать не на чтопоток спит в select() — разбудит любое событие
Блокирует: time.sleep(0.1)
N=1
0.100 с2 оборота
N=10
1.002 с2 оборота
N=50
5.012 с2 оборота
Уступает: await asyncio.sleep(0.1)
N=1
0.100 с4 оборота
N=10
0.101 с4 оборота
N=50
0.101 с4 оборота
N=1000
0.107 с10 оборотов

Пятьдесят блокирующих задач — это пять секунд и по-прежнему два оборота. Пятьдесят кооперативных — одна десятая секунды и четыре оборота. Тысяча кооперативных — сто семь миллисекунд: лишние семь ушли на создание и прокрутку самих задач.

Замер: N задач, каждая занята 100 мс. Обёртка вокруг _run_once считала обороты и длительность каждого. Обратите внимание на столбец «оборотов»: у блокирующего варианта он не растёт вовсе — растёт длительность одного оборота.

Смотреть надо не на время, а на число оборотов. У блокирующего варианта оно не растёт вовсе: и одна задача, и пятьдесят дают два оборота. Растёт длительность одного оборота — до 5011 мс.

Это ровно то, что предсказывает ntodo = len(self._ready). Все пятьдесят задач были готовы одновременно, попали в очередь и были съедены одним проходом шестого шага. Их блокировки сложились.

Обратите внимание и на кооперативный вариант: там тоже есть оборот длиной 100 мс. Разница не в длительности, а в том, что происходит внутри: в первом случае поток работает и не может отреагировать ни на что, во втором — спит в select() и проснётся от любого события. На шкале времени эти два прямоугольника выглядят одинаково, поэтому они покрашены по-разному.

Вкладка «Отзывчивость» переводит это на язык, который чувствуется: фоновая задача просит будить её каждые 10 мс, пока воркер делает десять порций работы по 100 мс. При блокирующем воркере она тикнула 4 раза за секунду с медианой интервала 300,9 мс. При кооперативном — 99 раз с медианой 10,2 мс.

Почему 300, а не 100 — вопрос, на который стоит ответить, потому что ответ целиком выводится из шестого шага. Пульс теряет оборот дважды: таймер, истёкший во время блокировки, снимается только в начале следующего оборота (шаг 5), а колбэк, попавший в _ready во время оборота, выполняется лишь оборотом позже — из-за ntodo. Три оборота по 100 мс и дают 300. Проверено трассировкой: в записи видно TaskStepMethWrapper в трёх оборотах подряд, прежде чем появляется Task.task_wakeup пульса.

Что изменилось в 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%» за счёт нового потокового двусвязного списка задач. И самое ломающее: asyncio.get_event_loop теперь бросает RuntimeError, если цикла нет, а не создаёт его молча.

ВерсияИзменениеСтатус порядка
3.12asyncio.eager_task_factory; C-реализация current_task; loop_factory у asyncio.run
3.13Исправлено зависание вложенных TaskGroup; Queue.shutdown; as_completed отдаёт исходные задачи
3.14python -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 в цикле — антипаттерн. Из ~53 мкс, которые он стоит, полезной работы там 6: остальное — создание и закрытие цикла плюс те самые четыре оборота завершения. Для повторных запусков есть asyncio.Runner.

Не оптимизируйте await. Он стоит 56 нс и почти весь этот бюджет уходит на создание объекта корутины. Оптимизировать имеет смысл число приостановок, а не число await.

Частые заблуждения

Утверждение

«Цикл событий постоянно опрашивает задачи в поисках готовых».

На самом деле

Он спит. Второй шаг оборота — 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.0rc2. Все готовые задачи съедаются одним проходом по _ready, потому что ntodo фиксируется до цикла.

Утверждение

«await дорогой, его надо экономить»

На самом деле

56,6 нс на 3.13.7 — против 20,3 нс у обычного вызова функции. Причём 53,8 нс из этих 56,6 уходит на создание объекта корутины, а не на механику ожидания. Дорога настоящая приостановка: await asyncio.sleep(0) стоит 1698 нс, в тридцать раз больше. Экономить надо число приостановок, а не число 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 мс. Уступка помогает, только если порция сопоставима с требуемой отзывчивостью.

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

Вопрос 1 из 5

Асинхронный сервис отвечает быстро, но раз в несколько секунд «залипает» на секунду. Процессор при этом загружен на одно ядро. Что проверять первым?

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

10 ИСТОЧНИКОВ

  1. PEP 3156 — Asynchronous IO Support Rebooted: the «asyncio» ModulePEP. Документ, которым asyncio введён в язык. Статус Final, Python 3.3. Отсюда сама модель: цикл, колбэки, футуры, транспорты.https://peps.python.org/pep-3156/
  2. PEP 492 — Coroutines with async and await syntaxPEP. Статус Final, Python 3.5. Вводит `async def` и `await` как отдельный протокол поверх генераторов — то, из-за чего `await` можно объяснить через `send`.https://peps.python.org/pep-0492/
  3. 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
  4. 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
  5. 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
  6. Разработка с 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» — и рядом: «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
  7. Цикл событий — 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
  8. 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
  9. selectors — BaseSelector.selectОфициальная документация. Определение того единственного вызова, в котором асинхронная программа действительно спит: «Wait until some registered file objects become ready, or the timeout expires».https://docs.python.org/3.13/library/selectors.html
  10. Что нового в Python 3.14 — asyncioОфициальная документация. Два заявления, на которые опирается раздел про версии: «improved by 10-20% following the implementation of a new per-thread doubly linked list for native tasks» и «first class support for free-threading builds… scaling linearly with the number of threads». Там же — get_event_loop теперь бросает RuntimeError вместо создания цикла.https://docs.python.org/3.14/whatsnew/3.14.html