open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 11 · 01

Одна итерация цикла под микроскопом: ready-очередь, опрос селектора, куча таймеров и какие await реально уступают

Итерация цикла: разгрузка ready-колбэков, опрос селектора до ближайшего таймера, снятие таймеров. await уступает только при реальной приостановке — завершённые футуры цепляются синхронно. Один блокирующий вызов морозит опрос; debug-режим ловит колбэки дольше 100 мс.

PY Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Гейтвей сменил auth-провайдера с HS256 на RS256-токены, и через час p99-латентность каждого эндпоинта — включая /healthz, который вообще не делает аутентификацию, — перевалила за 230 мс под нагрузкой. Подозреваемым, в которого никто не верил, была сама верификация: около 200 мс чистопитоновой длинной арифметики на токен, выполнявшейся прямо в async-хэндлере. Заблокированного тред-пула искать было негде — тред-пула не было; py-spy dump (сэмплирующий профайлер Python, показывающий живой стектрейс) показывал единственный поток event loop, пригвождённый внутри verify(), сэмпл за сэмплом. В этом и был весь инцидент: пока одна корутина перемалывает CPU между await, цикл — уже не планировщик, а застрявший for-цикл. Селектор не опрашивается, принятые сокеты не читаются, таймеры не срабатывают, и все четыреста открытых соединений процесса стоят в одной очереди. Фикс занял одну строку — убрать верификацию с цикла. Двухдневная диагностика была ценой за то, что event loop считали магией, вместо того чтобы прочитать, что на самом деле делает одна его итерация.

Одна итерация под микроскопом

Корутинами и TaskGroup вы уже пользуетесь ежедневно (юнит про конкурентность); этот урок открывает капот. Дефолтный цикл — читаемый круг вокруг трёх структур данных: FIFO ready-очередь колбэков, селектор (epoll/kqueue), следящий за дескрипторами, и куча таймеров (heapq хэндлов, отсортированных по времени). Одна итерация BaseEventLoop._run_once, упрощённо, но честно:

# форма одной итерации, упрощено из CPython asyncio
def _run_once(self):
    timeout = 0 if self._ready else self._next_timer_delay()  # None = ждать вечно
    events = self._selector.select(timeout)   # ЕДИНСТВЕННОЕ место сна цикла
    self._process_events(events)              # готовность -> колбэки в ready
    now = self.time()
    while self._scheduled and self._scheduled[0].when() <= now:
        self._ready.append(heapq.heappop(self._scheduled))    # созревшие таймеры в очередь
    ntodo = len(self._ready)                  # снимок! добавленные ВО ВРЕМЯ
    for _ in range(ntodo):                    # разгрузки ждут СЛЕДУЮЩЕЙ итерации
        self._ready.popleft()._run()

Следствия читаются прямо из кода. loop.call_soon(cb) добавляет в ready-очередь — FIFO, честно, выполнится в эту или следующую итерацию благодаря снимку ntodo (колбэк, бесконечно перепланирующий сам себя, не может заморить I/O голодом). call_later/call_at кладут TimerHandle в кучу; отменённые таймеры вычищаются лениво, так что миллион отменённых таймаутов болтается в куче, пока не сработает порог очистки. А таймаут селектора — самое элегантное место: цикл спит ровно до ближайшего таймера; ноль ready-колбэков и пустая куча означают полностью блокирующий select и ноль сожжённого CPU. Нет ни тика, ни интервала опроса, ни потока-планировщика. Цикл — это ровно этот круг, на одном потоке, навсегда.

Как на самом деле работает await — и какие await не уступают

Корутина — это генераторная машинерия в новом синтаксисе: она приостанавливается и возобновляется той же механикой фреймов, управляемой send() и throw(). Task владеет своей корутиной и ведёт её шагами: каждый шаг вызывает coro.send(None), корутина бежит, пока не завершится или не отдаст наружу pending-футуру; тогда Task вешает на футуру done-колбэк и возвращается — и этот возврат и есть передача управления циклу, потому что сам шаг был просто колбэком в ready-очереди. Когда футура разрешается, её колбэк делает call_soon(step), и круг продолжается.

Тонкость, на которой горят сеньоры: await не означает «уступить циклу». await обычной корутины — синхронный вызов на фрейм глубже. await уже завершённой футуры возвращается немедленно, без приостановки. Управление доходит до цикла, только когда await упирается во что-то действительно pending:

async def fast_path(cache, key):
    if key in cache:
        return cache[key]      # эта ветка никогда не уступает: обычный вызов
    val = await fetch(key)     # уступит, ТОЛЬКО если fetch повиснет на реальном I/O
    cache[key] = val
    return val

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

Это и есть тонкость голодания: async-функция без pending-await выполняется как один непрерываемый колбэк. Плотный цикл таких вызовов держит loop всю свою длительность — await asyncio.sleep(0) существует ровно для того, чтобы вставить явную точку уступки в такой цикл, а урок 2 покажет, что тот же факт решает, где может приземлиться отмена.

Викторина

Async-хэндлер делает await вспомогательной корутины, которая строит словарь на 10 тысяч записей на чистом Python, без await внутри. Получит ли цикл шанс выполнить другие задачи во время этого await?

Единственное правило — с механизмом в комплекте

Каждый учебник по asyncio говорит «не блокируй цикл». Теперь механизм виден: блокирующий вызов — requests.get, time.sleep, та самая 200-миллисекундная верификация RS256 — выполняется внутри фазы разгрузки одной итерации. Пока он идёт, итерация не возвращается к selector.select: готовность сокетов не наблюдается; таймеры (включая каждый asyncio.timeout в полёте — урок 3) лежат в куче несработавшими; новые соединения копятся в accept-бэклоге ядра. В юните про веб-сервисы вы видели, что один заблокированный воркер останавливает один запрос; здесь одна заблокированная корутина останавливает каждый запрос процесса — тот самый множитель, который превратил 200 мс верификации в общепроцессный пол p99 в 230 мс. Клапан давления — loop.run_in_executor / asyncio.to_thread: они уносят блокирующий вызов в другой поток и возвращают циклу его итерацию; урок 4 владеет математикой размеров пула и оговорками про GIL.

Детектор встроен в коробку. Debug-режимPYTHONASYNCIODEBUG=1 или asyncio.run(main(), debug=True) — логирует каждый колбэк или шаг задачи, державший цикл дольше loop.slow_callback_duration (по умолчанию 0.1 с), с именем корутины: строка Executing took 0.213 seconds — это инцидент из Хука, находящий сам себя в стейджинге. Он же печатает трейсбеки никогда-не-await-нутых корутин до точки создания. Накладные расходы реальны, поэтому включайте его в нагрузочных тестах стейджинга, а не как прод-дефолт.

uvloop, честно

uvloop подменяет всю реализацию цикла на libuv (C-библиотека под Node.js) за тем же API: установили policy — остальное без изменений. Честные числа: порядка 2–4x пропускной способности на сокето-ёмких нагрузках — прокси, echo-подобные сервисы, много мелких фреймов — и выигрыш тает по мере того, как профиль начинает доминировать ваш собственный код хэндлеров. Он ускоряет машинерию (опрос, диспетчеризацию колбэков, чтение транспортов); инциденту из Хука он не помог бы никак, потому что цикл не был медленным — он был в заложниках. Если py-spy показывает время в вашем Python, uvloop не сдвинет ничего.

Почему это работает

Почему await вообще построен на генераторной машинерии? Родословная: генераторы получили send()/throw() (PEP 342), yield from сделал делегирование композируемым (PEP 380), а async/await (PEP 492) — выделенный синтаксис над той же механикой приостановки фреймов: awaitable отдаёт итератор через __await__. Это не справка ради справки: раз Task возобновляет корутину через send() и вбрасывает исключения через throw(), отмена (урок 2) — буквально исключение, брошенное в ваш фрейм в точке приостановки; вся модель отмены вытекает из этого одного проектного решения.

Викторина

В цикле нет ready-колбэков; ближайший таймер созреет через 3 секунды. Что реально сделает следующая итерация?

Вспомните перед уходом
  1. 01
    Пройдите одну итерацию event loop и назовите точный механизм, которым один блокирующий вызов замораживает каждое соединение процесса.
  2. 02
    Какие await возвращают управление циклу, и как Task на самом деле ведёт свою корутину? Включая следствие про голодание.
Итог

Дефолтный event loop — однопоточный круг, читающийся с одного экрана: разгрузить ready-очередь колбэков (FIFO, со снимком длины, чтобы самоперепланирующийся колбэк не отрезал I/O), затем selector.select с таймаутом, вычисленным как время до ближайшего таймера — цикл спит в ядре и никогда не крутится вхолостую, — затем снять созревшие TimerHandle из кучи в очередь и пойти по кругу снова. call_soon, call_later и call_at — просто три входа в эти структуры. await — генераторная машинерия: Task шагает корутину через send, и шаг кончается, только когда корутина отдаёт pending-футуру; await обычных корутин и завершённых футур цепляется синхронно, поэтому «асинхронный» код без реальной приостановки выполняется как один непрерываемый колбэк — одновременно бесплатный быстрый путь и ловушка голодания, которую разрывает await asyncio.sleep(0). Из той же картинки вытекает единственное правило вместе с механизмом: блокирующий вызов в фазе разгрузки не даёт итерации дойти до опроса селектора, сокеты не читаются, таймеры не срабатывают, и каждое соединение процесса наследует латентность блокера — те самые 200 мс JWT-верификации, подложившие пол в 230 мс под /healthz. Debug-режим (PYTHONASYNCIODEBUG=1, порог медленного колбэка 0.1 с) — встроенный детектор ровно этого; run_in_executor/to_thread — клапан давления (урок 4); а uvloop, честно, — апгрейд машинерии опроса и диспетчеризации в 2–4 раза на сокето-ёмких нагрузках, не дающий ничего, когда профиль говорит, что время — в вашем собственном Python. Теперь, когда p99 ползёт вверх по всем эндпоинтам и py-spy показывает поток цикла пригвождённым внутри вашей функции — вы знаете, в какой фазе какой итерации застряло и какой одной строкой это починить.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.