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

asyncio честно: приостанавливаемые фреймы, await как единственная точка переключения и блокирующий вызов, морозящий всех

Цикл событий исполняет одну корутину за раз; точки await — единственные места переключения. Корутина не делает ничего, пока её не await-нут или не завернут в Task; gather запускает их конкурентно. Один блокирующий вызов морозит все запросы — run_in_executor и to_thread спасают.

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

Async-API месяцами держал 3 000 запросов в секунду. Потом p99 ушёл с 40 мс до 26 секунд — не плавно, а пиками, и на всех эндпойнтах сразу, включая /health, который не делал ничего, кроме возврата константы. Эту деталь сутки не замечали, а она была ключом: когда несвязанные эндпойнты стоят синхронно, стоит сам цикл событий. Утренний дифф добавил одну невинную строку в один хендлер — вызов хелпера коллеги, который тянул курс валюты. Хелпер использовал requests.get с таймаутом 30 секунд, а не aiohttp. Каждый раз, когда API курсов тормозил, эта одна корутина сидела в блокирующем чтении сокета, держа единственный поток процесса; десять тысяч других корутин, полностью готовых к работе, ждали за ней. С PYTHONASYNCIODEBUG=1 цикл напечатал обвинительное заключение одной строкой: Executing <Task ...> took 26.1 seconds. Один блокирующий вызов в одном хендлере — это инцидент для всех хендлеров; такой контракт asyncio даёт вам подписать.

Корутины — приостанавливаемые фреймы, а не потоки

Прежде чем диагностировать 26-секундный p99 из вступления — или писать async-код, который не воспроизводит его случайно, — нужна механическая картина: что на самом деле создаёт async def и что на самом деле делает await.

async def не определяет фоновую работу; он определяет функцию, чей стековый фрейм можно приостановить и возобновить. Вызов её не исполняет ничего — он возвращает объект корутины, замороженный фрейм с локальными переменными и указателем инструкции (забудете await — не выполнится ничего, и при сборке мусора прилетит RuntimeWarning: coroutine 'fetch' was never awaited — классика молча пропущенного шага). await expr делает две вещи: исполняет expr, пока тот не завершится или не приостановится, и — при приостановке — пробрасывает её вверх по всей цепочке await до цикла событий. Приостановка упирается в Future: awaitable, который ещё не готов. Цепочка важна механически: когда handler await-ит fetch, который await-ит sock_recv, все три фрейма замирают как одно связанное целое, а когда сокет приносит данные, цикл возобновляет нижний фрейм, и значения текут обратно вверх. Вытеснения в этой истории нет нигде — корутина исполняется, пока сама не await-нет нечто приостанавливающееся. Это свойство — суперсила asyncio (между await не нужны блокировки: код между двумя точками await атомарен относительно других корутин) и его заряженный пистолет.

import asyncio

async def fetch(url: str) -> bytes:
    reader, writer = await asyncio.open_connection("example.com", 80)
    writer.write(f"GET {url} HTTP/1.0\r\nHost: example.com\r\n\r\n".encode())
    await writer.drain()
    body = await reader.read(-1)     # frame suspends here until data arrives
    writer.close()
    return body

coro = fetch("/")        # ничего ещё не запущено — просто замороженный фрейм
# asyncio.run(coro)      # цикл доводит его до завершения

Цикл: очередь готовых, селектор и никакой пощады

Цикл событий — маленький честный механизм: очередь готовых колбэков и OS-селектор (epoll на Linux, kqueue на BSD/macOS). На каждой итерации он (1) исполняет все готовые колбэки — возобновить корутину до следующего await это один колбэк, (2) спрашивает селектор, какие сокеты стали готовы, с таймаутом до ближайшего таймера, (3) ставит пробуждения в очередь. Это весь двигатель, и из него следует правило, которое нарушил инцидент из вступления: шаг 1 обязан быть быстрым. Цикл не может опрашивать сокеты и запускать таймеры, пока исполняется колбэк, поэтому корутина, считающая две секунды — или зовущая requests.get, time.sleep, синхронный драйвер БД, bcrypt.hashpw или читающая большой файл простым open().read(), — останавливает мир: каждый таймер опаздывает, ни одно соединение не принимается, и p99 всех остальных запросов раздувается ровно на эту длительность. Инструменты обнаружения встроены: PYTHONASYNCIODEBUG=1 (или loop.set_debug(True)) логирует любой колбэк дольше 100 мс через loop.slow_callback_duration. Лекарство — делегирование, а не героизм: await loop.run_in_executor(None, blocking_fn, arg) исполняет функцию в дефолтном ThreadPoolExecutor и приостанавливает корутину до завершения; asyncio.to_thread(blocking_fn, arg) (3.9+) — то же без церемоний. Честная сноска из урока про GIL: поточный экзекьютор помогает, только когда заблокированный вызов отпускает GIL (I/O, большинство C-вызовов); для чистопайтоновского CPU-счёта передавайте первым аргументом ProcessPoolExecutor.

import asyncio, hashlib
from concurrent.futures import ProcessPoolExecutor

async def handler(password: bytes, big_doc: bytes):
    loop = asyncio.get_running_loop()
    # блокирующий I/O или C-вызов, отпускающий GIL → хватит потока
    rate = await asyncio.to_thread(fetch_rate_sync, "EUR")
    # чистая CPU-работа → пул процессов, иначе GIL сводит поток на нет
    digest = await loop.run_in_executor(cpu_pool, hashlib.pbkdf2_hmac,
                                        "sha256", password, b"salt", 600_000)
    return rate, digest

cpu_pool = ProcessPoolExecutor(max_workers=4)
Викторина

/health async-сервиса (не await-ит ничего медленного) пикует до 26 с в те же моменты, что и все остальные эндпойнты. О чём говорит этот паттерн?

Task против корутины: где на самом деле начинается конкурентность

await some_coro() — это последовательно: сахар для вызова функции, который может приостановиться. Конкурентность начинается только когда вы отдаёте корутину циклу как независимую единицу: asyncio.create_task(coro) заворачивает фрейм в Task, планирует старт на следующую итерацию цикла и сразу возвращается. Task — подкласс Future, который ведёт свою корутину и запоминает результат или исключение. Эта разница и кусается на код-ревью: три последовательных await трёх HTTP-вызовов стоят сумму их латентностей; три задачи, await-нутые после создания, — максимум. asyncio.gather(*coros) упаковывает паттерн: заворачивает каждый аргумент в Task, исполняет конкурентно и возвращает результаты в порядке аргументов; по умолчанию первое исключение пробрасывается сразу, пока выжившие задачи продолжают работать без присмотра, а return_exceptions=True вместо этого отдаёт исключения как значения для поэлементного разбора. Отсюда два продакшен-правила. Первое: голый create_task, ссылку на который вы выбросили, может быть собран сборщиком мусора прямо в полёте — цикл держит лишь слабую ссылку; храните ссылку или используйте структурные инструменты следующего урока. Второе: поведение gather «работаем дальше при ошибке» — замаскированная утечка ресурсов для fan-out с побочными эффектами; тоже тема следующего урока.

Викторина

Код делает: a = fetch(u1); b = fetch(u2); ra = await a; rb = await b — где fetch это async def, вызванный без create_task. Каждый fetch занимает 1 с. Общее время?

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

Зачем вообще кооперативное планирование, если одна грубая корутина может заморозить процесс? Потому что именно отсутствие вытеснения делает 50 тысяч конкурентных соединений дешёвыми, а код между await — свободным от гонок. Приостановленная корутина стоит несколько КБ фрейма — ни OS-потока, ни резервирования стека, ни переключения в ядро; селектор следит за всеми сокетами одним сисколлом. А раз переключения происходят только в размеченных точках, инвариантам из нескольких операций не нужны блокировки, пока между ними не вклинился await — то самое «прочитать-изменить-записать», требовавшее Lock в уроке о потоках, в корутине безопасно, если последовательность не разрезана await. Вы меняете безопасность-по-умолчанию на явное проверяемое правило: каждый await — видимая точка уступки; всё между — атомарно. Цена — дисциплина на границе: один протащенный синхронный вызов, и вся сделка рушится.

Вспомните перед уходом
  1. 01
    Объясните механически, почему один requests.get в одном хендлере раздувает p99 всех эндпойнтов async-сервиса, и как это обнаружить и починить.
  2. 02
    Разведите объекты корутин, await и Task — и скажите, когда на самом деле начинается конкурентность.
Итог

Модель asyncio — три честные части. Корутины: вызов async def создаёт приостановленный фрейм, а не работающую задачу — исполнение происходит при await (последовательно) или в обёртке Task (конкурентно, со следующей итерации цикла); gather — fan-out, возвращающий результаты в порядке аргументов и пробрасывающий первую ошибку рано, если не return_exceptions=True, а слабая ссылка цикла означает, что незакреплённый create_task может исчезнуть в полёте. Цикл: очередь готовых плюс селектор (epoll/kqueue); каждая итерация исполняет все готовые колбэки — один колбэк равен одному шагу корутины от await до await — и затем опрашивает селектор с таймаутом до ближайшего таймера. Вытеснения не существует: корутина бежит, пока сама не приостановится, что делает код между двумя await атомарным (замок, охранявший check-then-act в потоках, не нужен, если последовательность не разрезана await) и делает один синхронный вызов катастрофой — requests.get, time.sleep, синхронный драйвер или CPU-цикл в любой корутине останавливает таймеры, accept-ы и все остальные запросы на всю свою длительность; идеально коррелированные пики p99 на несвязанных эндпойнтах, включая /health, — диагностический отпечаток, а PYTHONASYNCIODEBUG=1 называет виновный Task за порогом 100 мс. Выход: await asyncio.to_thread для блокирующего I/O (поток блокируется, GIL отпущен, цикл живёт) и run_in_executor с ProcessPoolExecutor для чистопайтоновского CPU, потому что поток, делящий GIL с циклом, для байткодного счёта не даёт ничего. Теперь, когда p99 скоррелированно растёт на всех эндпойнтах сразу, вы первым делом ищете синхронный вызов — а не докупаете инфраструктуру.

Практика

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

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

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

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

Примени это

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

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

Trademarks belong to their respective owners. Editorial reference only.