Команда пересматривает доклад про неблокирующий ввод-вывод, переписывает клиентский сервис на aiohttp и получает прирост, умещающийся в погрешность, а местами и минус: девяносто девятый перцентиль латентности подрос, профилирование усложнилось, половина зависимостей всё равно работает через мосты в потоки. Жива и обратная история: сервис на честных потоках годами держит десятки тысяч соединений, пока соседи по датацентру отлаживают забытые исключения в корутинах. Противостояние async/await и пула потоков не сводится к лозунгу про то, что асинхронность всегда быстрее. Есть механика, есть цена каждого переключения, есть сценарии, в которых event loop проигрывает синхронному коду с горстью потоков, и ниже они разобраны с замерами, кодом и выводами для выбора.
Устройство event loop и реальная цена одного переключения между корутинами
Асинхронный код исполняется одним потоком. Event loop держит очередь готовых к продолжению задач и гоняет их по кругу: корутина доходит до await, отдаёт управление, цикл вызывает следующую. Никаких захватов блокировки интерпретатора, никаких переключений контекста в ядре операционной системы, никаких разделяемых структур, требующих блокировок. Переключение между корутинами это несколько инструкций в пространстве пользователя, тогда как переключение потоков это системный вызов, сохранение регистров и холодный кэш процессора.
Разница заметна уже на старте сущностей. Объект корутины создаётся мгновенно, обёртка asyncio.create_task стоит единицы микросекунд, поток со своим стеком стартует на порядок дороже, десятки микросекунд на рядовой Linux-машине. На тысяче конкурентных операций экономия складывается в миллисекунды, и event loop забирает лёгкую победу на диспетчеризации. Есть и второй этаж выигрыша: раз поток один, нет гонок за общие словари и счётчики, нет нужды в блокировках вокруг кэша соединений.
У медали обратная сторона, и её часто не показывают на слайдах. Единственный поток означает единственную точку отказа по времени: любой вызов, не возвращающий управление, замораживает весь цикл. В потоках зависший worker соседям почти не мешает, в event loop зависание одного await это пауза для всех. Дальше разберём, чем это грозит на реальных замерах.
Замеры двухсот конкурентных запросов в event loop и в пуле из пятидесяти потоков
Классический сценарий, где асинхронность рекламируют: множество одновременных сетевых вызовов. Тестовый стенд прост, локальный сервер отвечает через полсекунды задержки, клиент дергает его двести раз. Синхронная версия строится на пуле потоков:
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
def fetch() -> int:
with urllib.request.urlopen("http://127.0.0.1:8080/", timeout=30) as r:
return len(r.read())
if __name__ == "__main__":
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=50) as pool:
list(pool.map(fetch, range(200)))
print(f"пул потоков: {time.perf_counter() - t0:.2f} с")
Асинхронная версия делает то же самое одним потоком:
import asyncio
import time
from aiohttp import ClientSession
async def fetch(session: ClientSession) -> int:
async with session.get("http://127.0.0.1:8080/") as r:
return len(await r.read())
async def main() -> None:
t0 = time.perf_counter()
async with ClientSession() as session:
await asyncio.gather(*(fetch(session) for _ in range(200)))
print(f"event loop: {time.perf_counter() - t0:.2f} с")
asyncio.run(main())
Типичная картина на четырёх ядрах: пул из пятидесяти потоков укладывается примерно в 2.6 секунды, ведь двести задач делят пятьдесят исполнителей и идут четырьмя волнами. Event loop показывает около 2.1 секунды, все запросы летят одновременно. Выигрыш есть, но он скромнее ожиданий, и суть в устройстве задачи: половина секунды ожидания на запрос доминирует, стоимость диспетчеризации мала у обеих сторон. Поднимем число потоков до двухсот, и пул догонит асинхронную версию по времени, расплатившись двумястами стеками и лишним планированием. Именно на такой волне равных возможностей и решается настоящий вопрос, а не в рекламных примерах.
В асинхронной версии спрятан и бонус про соединения: единый ClientSession держит пул и переиспользует открытые TCP-каналы, тогда как urllib.request поднимает новое соединение на каждый вызов и теряет на хендшейках ещё по нескольку миллисекунд на запрос. Часть репутации асинхронности как безусловного ускорителя обеспечивает именно этот пул, а не сам event loop: синхронный код с requests.Session и пулом соединений закрывает разницу без всякого await.
Где асинхронность честно проигрывает потокам на вставках и локальных вызовах
Первый сценарий проигрыша это вычислительные вставки. Пусть обработчик ответа парсит крупный JSON или пересчитывает дерево сегментов, тратя четыреста миллисекунд чистого Python. В event loop все прочие корутины стоят эти четыреста миллисекунд без движения: сетевые ответы готовы, но до кода очередь не доходит, латентность каждого клиента растёт на всё время вставки. В пул-поточной версии та же вставка тоже съест блокировку интерпретатора, однако переключение по таймеру возвращает остальным потокам их долю времени, и очередь двигается рывками, а не стоит колом. При равной вычислительной нагрузке синхронный вариант с потоками держит более ровный профиль латентности, и это измеримо на перцентилях.
Второй сценарий это быстрые локальные операции. Чтение маленького файла, который уже лежит в страничном кэше ядра, занимает микросекунды, os.stat ещё дешевле. Асинхронные обёртки отправляют такие вызовы в исполнители, и накладные расходы на сам мост в разы превышают время операции. Оборачивать в асинхронность операцию быстрее собственного переключения бессмысленно, это правило стоит выучить раньше первого await.
Третий сценарий это малое число соединений. Сервис, который ходит в три внешних API по пять параллельных вызовов, от асинхронности не получит ничего, кроме сложности. Выигрыш event loop растёт с числом одновременных ожиданий, при десятке их просто нет.
Блокирующиеся библиотеки внутри асинхронного кода и мосты в исполнители
Асинхронный сервис живёт в окружении синхронных библиотек: драйвер старой СУБД, SDK облака, прототип на requests. Мост встроен в стандартную библиотеку, asyncio.to_thread появился в Python 3.9 и отправляет вызов в пул потоков:
import asyncio
import requests
async def fetch_via_thread(url: str) -> int:
r = await asyncio.to_thread(requests.get, url, timeout=30)
return len(r.content)
Под мостом работает дефолтный пул ThreadPoolExecutor с числом исполнителей min(32, os.cpu_count() + 4), так настроено поведение run_in_executor в стандартной библиотеке. Арифметика безжалостна: у сервиса тысячу конкурентных вызовов, а мостов в потоки всего тридцать два, и перед ними вырастает очередь. Сервис называется асинхронным, а параллелизм у него старый, поточный, только с двумя слоями диспетчеризации и лишними микросекундами на каждом прыжке. Если четыре из пяти внешних вызовов идут через to_thread, честнее писать синхронный код с обычным пулом: меньше слоёв, понятнее стеки, предсказуемее пул.
Если мосты неустранимы, параллелизм ограничивают семафором, иначе очередь к тридцати двум потокам превращается в пробку без приоритетов:
sem = asyncio.Semaphore(32)
async def bounded_fetch(url: str) -> int:
async with sem:
r = await asyncio.to_thread(requests.get, url, timeout=30)
return len(r.content)
Отдельно стоят библиотеки, которые нельзя оборачивать вообще: криптография на больших объёмах, тяжёлый парсинг, компиляция шаблонов. Их место вне цикла, в фоновых исполнителях с явным контролем размера, иначе один вызов положит латентность всего сервиса.
Стоимость владения асинхронным кодом в отладке, трассировках и исключениях
Скорость не единственная валюта, её у async и так хватает. Дороже обходится сам текст программы. Функция, помеченная async def, не вызывается из обычного кода, и это раскалывает кодовую базу на два цвета: сигнатура любого метода теперь обязана предсказывать, кто и откуда его позовёт. Рефакторинг среднего сервиса рассыпается о это ограничение регулярно.
Отладка отдельное искусство. Трассировка упавшей корутины показывает не реальный стек вызовов, а цепочку await с адресами объектов, читаемую с напряжением. Исключение в задаче, которую никто не ждал, всплывает не сразу: стандартная библиотека лишь печатает в конце работы предупреждение о никем не полученном исключении, и в продакшене такой след легко потерять в шумных логах. Debug режим, включаемый переменной окружения PYTHONASYNCIODEBUG=1, отмечает колбэки, задержавшие цикл дольше ста миллисекунд, и это единственный штатный способ увидеть заморозку изнутри.
Стандартная библиотека помогает держать дисциплину: с Python 3.11 работает asyncio.TaskGroup, который при ошибке одной задачи отменяет сестринские и ждёт всех в конце блока, раньше те же гарантии требовали gather с return_exceptions=True и ручного разбора результатов:
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(fetch(session, url))
Наконец, экосистема: тесты требуют плагина для событийного цикла, профилировщики скрывают сетевые ожидания, новичку в команде труднее объяснить, почему между двумя строками кода проходит секунда. Синхронный код с пулом потоков здесь старомоден, зато скучно предсказуем: обычный стек, обычные брейкпоинты, обычные тесты.
Масштаб десяти тысяч соединений и пределы потоков по памяти и планированию
У потоков есть математический потолок. Каждому потоку ядро резервирует стек, на Linux по умолчанию это восемь мегабайт виртуальной памяти. Десять тысяч потоков означают восемьдесят гигабайт адресного пространства и очередь планирования, по которой ядро бегает вместо полезной работы. Реальная резидентная память меньше, ведь стек используется частично, но переключения контекста остаются. Дальше играют лимиты: ulimit на число процессов, у контейнера свои потолки, и сервис упирается в них ровно в вечер пиковой нагрузки.
Event loop на той же задаче держит десять тысяч корутин как десять тысяч маленьких объектов в куче. Именно здесь асинхронность демонстрирует настоящую силу, а не на двухстах запросах из учебного примера. Цикл можно ускорить сторонней заменой: uvloop от MagicStack, написанный поверх libuv на Cython, встаёт вместо штатного цикла одной строкой asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) и по замерам авторов ускоряет сетевые сценарии кратно, приближая Python к показателям Go на чистом вводе-выводе.
Правда и здесь асимметрия: десять тысяч соединений в одном цикле это десять тысяч клиентов, зависящих от одного потока. Одна вставка на двести миллисекунд даёт двести миллисекунд задержки каждому. Потоки изолируют соседей лучше, даже с потерей общего бюджета процессора.
Чек-лист выбора между асинхронностью и пулом потоков под новый сервис
Решение о модели параллелизма проще принимать по короткому списку признаков, чем по моде:
- сотни и тысячи одновременных сетевых ожиданий, превышающие число ядер на порядок, это территория event loop;
- единицы и десятки конкурентных вызовов живут в пуле потоков без всякой асинхронности, размер подбирается под внешние лимиты;
- заметная доля вычислительной работы в запросе требует процессов или выноса в сервисы, async и потоки тут одинаково бессильны из-за блокировки интерпретатора;
- синхронные библиотеки без асинхронных аналогов выталкивают проект к пулу потоков, мосты to_thread допустимы точечно, а не как основа архитектуры;
- требования к отладке и быстрому онбордингу новичков склоняют весы к синхронному коду, если выигрыш в миллисекунды не окупается.
Итоговое правило простое. Асинхронность покупает масштаб одновременных ожиданий и платит за это сложностью, дисциплиной и единственной точкой заморозки. Пул потоков покупает простоту, предсказуемость и привычные инструменты, платя стеками памяти и переключениями контекста. Замер собственного профиля нагрузки перед выбором стоит дешевле, чем переписывание сервиса после неудачного релиза.