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

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

Цикл событий — это обычный while True с шестью шагами внутри. Сколько раз он повторится, задают не задачи, а их приостановки: замените десять корутин на тысячу — повторов не прибавится. Отсюда и главная ловушка: один time.sleep останавливает весь сервис.

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

TL;DR

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. Уборка отменённых таймеров (строки 1978–1999).
  2. Сколько можно спать (2001–2010) — таймаут для следующего шага.
  3. selector.select(timeout) (2012) — единственное место, где процесс действительно блокируется.
  4. Разбор готового ввода-вывода (2013) — дескрипторы превращаются в колбэки и кладутся в _ready, но не выполняются.
  5. Снятие просроченных таймеров (2017–2025) — тоже в _ready.
  6. Единственное место, где колбэки вызываются (2027–2051).

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

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

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.7
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.7во сколько раз дороже вызова (3.13.7)
обычный вызов функции44,5 нс36,1 нс×1,0
await корутины без приостановки90,7 нс91,0 нс×2,0
создать объект корутины и закрыть, без await68,9 нс76,6 нс×1,5
await уже завершённого Future370,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 возобновляет корутину». Не возобновляет.

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

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

— asyncio — Debug Mode

Режим и порог — две независимые ручки, и это сбивает чаще всего. Проверено на блокировке в 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.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 в цикле — антипаттерн. Замер: один asyncio.run(noop()) стоит 71 мкс, а та же корутина внутри уже работающего цикла — 0,12 мкс. То есть 99,8 % бюджета уходит на создание и закрытие цикла плюс те самые четыре оборота завершения. Для повторных запусков есть asyncio.Runner: он переиспользует один цикл и обходится в 10 мкс на запуск — в семь раз дешевле.

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

Чем измерено

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

Унести в работу

  • Не звать 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 мс. Уступка помогает, только если порция сопоставима с требуемой отзывчивостью.

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

Вопрос 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» (Если функция считает что-то ресурсоёмкое одну секунду, все параллельные задачи 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
  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» (улучшено на 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