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

Переход на async: потолок ставит пул соединений, а не модель исполнения

Тысяча корутин при пуле из восьми соединений — это восемь одновременных запросов и очередь из 992. Три модели исполнения на одной и той же работе укладываются в разницу 5 %, а на коротком запросе без одновременности асинхронный код на 16–33 % дороже синхронного.

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

TL;DR

  • Одновременность при работе с базой ограничена размером пула соединений, а не числом корутин. Тысяча корутин при пуле восемь — это восемь запросов и очередь из 992.
  • Три модели исполнения на одной и той же работе дают одно и то же время: при пуле восемь — 0,43 с у потоков, 0,45 у async psycopg, 0,43 у asyncpg. Разница между ними — пять процентов.
  • Без одновременности асинхронный код дороже синхронного: на запросе по первичному ключу синхронный psycopg — 59 мкс, асинхронный — 70. Ускорять там нечего, накладной расход остаётся.
  • Синхронный драйвер в run_in_executor не хуже и не лучше остальных — 0,434 с против 0,428 у чистых потоков. Он тоже упирается в пул.
  • Async выигрывает там, где ожиданий много: четыре тысячи одновременных — 0,31 с и ноль лишних мегабайт против 2,03 с и 61 МБ у потоков.
  • Расхожий довод «блокирующий вызов останавливает цикл событий, а потоки переживут» проверен и не подтвердился: под GIL счёт последовательный в обеих моделях.

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

Фраза «перепишем на async — станет быстрее» произносится в проектах примерно так же часто, как «добавим индекс». Разница в том, что про индекс есть план запроса, а про async — только ощущение.

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

Практический выход отсюда не «не переходите на async». Он в том, что сначала надо узнать, во что вы упираетесь, и только потом выбирать модель исполнения. Потолков ровно три, и async лечит один из них.

Модель в голове: три потолка

Путь запроса от прихода до базы упирается в три разных ограничителя, и они независимы. Ошибка почти всегда в том, что чинят не тот.

Разница между ними видна по симптому:

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

Первые два потолка асинхронность не двигает вообще. Третий — двигает, и сильно.

Первое: потолок ставит пул

Возьмём одну и ту же работу: 600 запросов, каждый ждёт на сервере ровно 5 мс. Ожидание, а не счёт — то есть ровно тот случай, ради которого асинхронность и заводят. Меняем только модель исполнения и размер пула.

Три модели совпадают на пулах от одного до шестнадцати — не «примерно похожи», а совпадают: при пуле восемь это 0,43 / 0,45 / 0,43 секунды при теоретическом минимуме 0,38. На пуле 32 совпадение кончается, и почему — в следующем разделе. Число корутин при этом на время не влияет вовсе: в замере их 600 при пуле в 1, 2, 4, 8, 16 и 32 соединения, и результат следует за пулом.

Причина не в Python, а в том, что такое соединение к PostgreSQL. Это не дескриптор и не строка в таблице — это отдельный процесс на сервере. Документация говорит прямо: To achieve this it starts (“forks”) a new process for each connection (Ради этого он запускает („форкает“) новый процесс на каждое соединение), и дальше — the client and the new server process communicate without intervention by the original postgres process (клиент и новый серверный процесс общаются без участия исходного процесса postgres).

Поэтому у пула нет способа выдать девятое соединение из восьми. Когда свободных нет, происходит ровно то, что записано в документации psycopg: if no connection is available, the client is put in a queue, and will be served a connection once one becomes available (если свободного соединения нет, клиент ставится в очередь и получит соединение, как только оно освободится). Очередь — это очередь, независимо от того, стоят в ней потоки или корутины.

Тысяча корутин при пуле из восьми соединений — это не тысяча одновременных запросов. Это восемь запросов и очередь из 992.

Сколько соединений держать

Соблазн очевидный: раз потолок в пуле, поднимем пул. Он упирается в два ограничения сразу.

Первое — сервер. max_connections по умолчанию — typically 100 connections, but might be less if your kernel settings will not support it (обычно 100 соединений, но может быть меньше, если настройки ядра их не выдержат), и это на весь сервер, а не на одно приложение. Десять экземпляров приложения по тридцать соединений — это триста, втрое больше предела: в сотню помещаются три таких экземпляра, а не десять.

Второе — то, что каждое соединение стоит процесса. Больше процессов — больше переключений контекста и больше памяти на сервере, и после некоторого числа пропускная способность начинает падать, а не расти. Документация psycopg отвечает на вопрос о размере пула единственной честной фразой, которую стоит процитировать целиком: Big question. Who knows. However, probably not as large as you imagine (Хороший вопрос. Кто ж его знает. Но, вероятно, не такой большой, как вам кажется).

В нашем замере это видно на последней строке: при пуле 32 время перестаёт делиться на размер пула — 0,16 с вместо теоретических 0,09. Два ядра машины, на которой снят замер, уже не успевают обслуживать столько одновременных соединений, и добавление девятого, шестнадцатого, тридцать второго соединения приносит всё меньше.

Второе: одна операция у async стоит дороже

Теперь уберём одновременность вовсе. Запросы идут по одному, по одному соединению. Ускорять здесь нечего — ждать параллельно нечего.

Синхронный psycopg дешевле обеих асинхронных моделей: 59 микросекунд против 70 и 68 на запросе по первичному ключу, и 51 против 67 и 64 на select 1. То есть от 16 до 33 процентов сверху — за возврат в цикл событий на каждом ожидании и за работу планировщика.

Это и есть вторая половина утверждения, с которого начиналась статья: переход на async может замедлить. Не «в теории», а на самом обычном коде — обработчик, который делает два-три коротких запроса по первичному ключу и отдаёт ответ. Если одновременных запросов немного и в пул они помещаются, переписывание заберёт эти проценты и не вернёт ничего.

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

Третье: синхронный драйвер внутри async

Самый частый способ «перейти на async, не переписывая доступ к базе» — оставить синхронный драйвер и заворачивать каждый вызов в loop.run_in_executor. Снаружи получается await, внутри — тот же поток.

PYTHON
async def get_user(pool, user_id: int):
    loop = asyncio.get_running_loop()
    # Снаружи корутина, внутри поток из пула исполнителя.
    return await loop.run_in_executor(executor, fetch_user_blocking, pool, user_id)

Про этот приём говорят и «так делать нельзя, это худшее из двух миров», и «так делать нужно, иначе цикл встанет». Замер на той же нагрузке — 600 запросов, пул восемь соединений:

модельвремяк чистым потокам
потоки, без цикла событий0,428 с1,00×
потоки через run_in_executor0,434 с1,01×
psycopg, async0,452 с1,06×
asyncpg0,421 с0,98×

Четыре строки сняты одним прогоном, поэтому их и можно ставить рядом; с числами предыдущего раздела они совпадают до последней цифры не везде — это другой прогон, и расхождение в сотых и есть цена шума.

Обёртка стоит полтора процента: 0,434 против 0,428. Все четыре модели укладываются между 0,98× и 1,06× к чистым потокам — то есть цена любой из них теряется на фоне того же потолка: сколько соединений в пуле, столько запросов и идёт, каким бы способом их ни запускали.

Отсюда практический вывод, который редко произносят: если у вас уже есть синхронный код и вам нужен await на границе (например, потому что фреймворк асинхронный), run_in_executor — не позор и не оптимизация. Это способ перенести границу, ничего не изменив в цене.

Четвёртое: где async выигрывает по-настоящему

Три замера подряд показали, что модель исполнения не решает. Из этого легко сделать вывод «async не нужен» — и он был бы неверным. Разница есть, просто она не в скорости одного ожидания, а в цене того, чтобы держать много ожиданий сразу.

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

У картинки выше есть третий переключатель — память по tracemalloc. В нём спрятана ловушка: от тех же четырёх тысяч потоков этот счётчик показывает 10,7 МБ вместо 60,7, которые показал RSS, а от корутин — 3,9. Разница падает с «шестьдесят мегабайт против нуля» до «в два с половиной раза» — то есть до величины, ради которой никто не пойдёт разбираться. Причина в том, что стек потока выделяет операционная система, и аллокатор Python его не видит.

Вопрос, который стоит задать себе перед переписыванием: сколько у вас одновременных ожиданий? Если приложение держит пул в двадцать соединений к базе, ответ — двадцать, и картинка выше к вам не относится. Если это шлюз с десятью тысячами открытых вебсокетов, ответ — десять тысяч, и относится она только к вам.

Пятое: счёт рядом с запросами

Приложение не состоит из одного ожидания. Рядом с запросом всегда есть работа процессора: сериализация ответа, шаблон, разбор тела, хеширование пароля.

Про этот случай существует расхожий довод, и звучит он убедительно. Документация его подтверждает — прямым текстом, без оговорок: Blocking (CPU-bound) code should not be called directly. For example, 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 и операции ввода-вывода будут задержаны на эту секунду). Дальше из этого делают вывод: значит, потоки в такой ситуации переживут, потому что их переключает операционная система.

Проверим.

Первая половина довода верна: цикл событий действительно встаёт, и на сценарии с одним куском в 191 мс это видно — хвост у async плотный, все ждущие поднимаются ровно на длину куска, до 184 мс.

Вторая половина не подтвердилась. У потоков в том же сценарии худшая задержка выше — 212 мс против 184. А на сценарии «часто и понемногу» потоки хуже по всем четырём числам сразу: медиана 54,6 мс против 42,9, p99 — 102,3 против 75,6.

Причина в том, о чём отдельная статья про GIL: счёт на чистом Python под GIL идёт последовательно в любой модели. Поток не считает параллельно с другим потоком — он ждёт своей очереди на тот же самый процессор, и вдобавок платит за переключения, которых у корутин нет вовсе. Документация sys.setswitchinterval предупреждает об этом прямо: The interpreter doesn't have its own scheduler (У интерпретатора нет собственного планировщика).

Цикл событий действительно встаёт от счёта. Потоки от него не спасают — спасают процессы.

Ловушка нулевой точки: от какого момента считать задержку

Первый вариант запускал все задачи разом и мерил время с момента старта задачи. Числа получились красивые и в пользу потоков: медиана 5,5 мс против 94,4 у async, максимум 6,8 против 178,3.

Они были неправильные. В асинхронной версии все 240 корутин создавались сразу, и каждая начинала отсчёт в нулевой момент — то есть в её задержку попадало ожидание в общей очереди за соединением. В потоковой версии задача начинала существовать только тогда, когда освобождался поток, и очередь в измерение не попадала. Сравнивались разные величины.

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

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

Что с этим делать

Порядок действий, в котором каждый следующий шаг имеет смысл только после предыдущего:

  1. Узнать, во что упираетесь. Три потолка из первой картинки различаются по симптому. Пока не ясно, какой из них ваш, выбор модели исполнения — угадывание.
  2. Если это соединения — считать пул и запросы, а не переписывать клиент. Меньше запросов на операцию, кеш, пакетирование, внешний пул. Пул поднимать осторожно и с оглядкой на max_connections.
  3. Если это процессор — процессы. Ни потоки, ни корутины не добавляют ядер, а под GIL счёт всё равно идёт по очереди.
  4. Если это число одновременных ожиданий — вот здесь async и нужен, и выигрыш будет в разах, а не в процентах.
  5. Если переписываете — переписывайте до драйвера. Асинхронный код с синхронным драйвером внутри run_in_executor работает ровно как потоки; это допустимо, но выигрыша от такого перехода ждать неоткуда.
  6. Счётные куски выносить из обработчика — в процессы. Это верно и для async, и для потоков, и второе из двух обычно забывают.

И последнее. Все числа здесь сняты на локальной базе, где запрос идёт доли миллисекунды. Чем дальше база и чем дольше запрос, тем больше доля ожидания и тем менее заметен накладной расход модели — но потолок пула от расстояния не двигается. Он двигается только числом соединений.

Чем измерено

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

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

Утверждение

«Переписали на async — запросов к базе пойдёт больше».

На самом деле

Одновременно пойдёт ровно столько запросов, сколько соединений в пуле, — и ни одним больше. Замер на одной и той же работе (600 запросов по 5 мс ожидания): при пуле 8 потоки дают 0,43 с, асинхронный psycopg — 0,45, asyncpg — 0,43. Число корутин при этом 600 в каждом случае. Причина не в Python: соединение к PostgreSQL — это процесс на сервере, it starts (“forks”) a new process for each connection (он запускает („форкает“) новый процесс на каждое соединение), и девятое соединение из восьми не берётся ниоткуда.

Утверждение

«Асинхронный драйвер быстрее синхронного».

На самом деле

На одном запросе — медленнее. Запрос по первичному ключу, 3000 раз подряд по одному соединению: psycopg синхронно 59,0 мкс, тот же psycopg асинхронно 70,3, asyncpg 68,3. На select 1 разрыв ещё шире: 50,6 против 67,4 и 64,1. Быстрее асинхронный драйвер не на запросе, а на одновременности — но одновременность ограничена пулом, а не драйвером.

Утверждение

«run_in_executor — худшее из двух миров».

На самом деле

Замер этого не показывает. Те же 600 запросов при пуле 8: чистые потоки 0,428 с, они же через run_in_executor — 0,434 с, разница полтора процента. Обёртка не бесплатна, но её цена теряется на фоне того же потолка. Приём остаётся плохим ответом на вопрос «как ускорить» и вполне рабочим ответом на вопрос «как получить await на границе, ничего не меняя в цене».

Утверждение

«Async экономит память по сравнению с потоками».

На самом деле

Экономит — но только там, где ожиданий много. При двадцати одновременных ожиданиях разницы нет никакой. При четырёх тысячах она в разы: +60,7 МБ резидентной памяти у потоков против +0,0 МБ у корутин, и 2,03 с против 0,31 только на создание. Отдельная ловушка: по tracemalloc от тех же потоков видно 10,7 МБ вместо 60,7, а от корутин — 3,9. Разница падает с «шестьдесят мегабайт против нуля» до «в два с половиной раза» — стек потока выделяет операционная система, и аллокатор Python его не видит.

Утверждение

«Один блокирующий вызов останавливает цикл событий — на потоках такого не будет».

На самом деле

Первая половина верна и записана в документации: 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 и операции ввода-вывода будут задержаны на эту секунду). Вторая проверена и не подтвердилась. Один счётный кусок на 191 мс при потоке в 500 заявок в секунду: худшая задержка у потоков 212,0 мс, у async — 184,4. На сценарии «20 мс двадцать раз» потоки хуже по всем четырём числам: медиана 54,6 против 42,9, p99 102,3 против 75,6. Под GIL счёт последовательный в любой модели, и потоки вдобавок платят за переключения.

Утверждение

«Увеличим пул — и потолок уедет».

На самом деле

Уедет, но не линейно и не далеко. В замере при пуле 32 время перестаёт делиться на размер пула: 0,16 с вместо теоретических 0,09. Сверху два предела: max_connections у сервера (The default is typically 100 connections (по умолчанию обычно 100 соединений)) — на всех клиентов сразу, и цена самого процесса на соединение. Документация psycopg отвечает на вопрос о размере пула прямо: Big question. Who knows. However, probably not as large as you imagine (Хороший вопрос. Кто ж его знает. Но, вероятно, не такой большой, как вам кажется).

Утверждение

«Если база далеко, async точно выиграет».

На самом деле

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

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

Вопрос 1 из 5

Приложение делает 500 запросов в секунду к PostgreSQL через пул из 10 соединений. Переписали с потоков на asyncio с асинхронным драйвером, число корутин выросло до 500. Что произойдёт с пропускной способностью?

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

5 ИСТОЧНИКОВ

  1. Developing with asyncio — Concurrency and Multithreading, Running Blocking CodeОфициальная документация. Два утверждения, на которых держится половина статьи. Первое: «An event loop runs in a thread (typically the main thread) and executes all callbacks and Tasks in its thread. While a Task is running in the event loop, no other Tasks can run in the same thread» (Цикл событий работает в потоке (обычно в главном) и выполняет все обратные вызовы и задачи в этом же потоке. Пока в цикле выполняется задача, никакая другая задача в том же потоке выполняться не может). Второе, прямым текстом про счёт: «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 и операции ввода-вывода будут задержаны на эту секунду).https://docs.python.org/3/library/asyncio-dev.html
  2. PostgreSQL 16 — Architectural FundamentalsОфициальная документация. Почему соединение к Postgres — не дескриптор, а процесс: «To achieve this it starts (“forks”) a new process for each connection» (Ради этого он запускает („форкает“) новый процесс на каждое соединение), и дальше «the client and the new server process communicate without intervention by the original postgres process» (клиент и новый серверный процесс общаются без участия исходного процесса postgres). Отсюда и берётся потолок, который не двигается переписыванием клиента.https://www.postgresql.org/docs/16/tutorial-arch.html
  3. PostgreSQL 16 — Connections and Authentication (max_connections)Официальная документация. «Determines the maximum number of concurrent connections to the database server. The default is typically 100 connections, but might be less if your kernel settings will not support it» (Задаёт наибольшее число одновременных соединений с сервером базы данных. По умолчанию обычно 100 соединений, но может быть меньше, если настройки ядра их не выдержат). Сто — это верхняя граница на весь сервер, а не на одно приложение.https://www.postgresql.org/docs/16/runtime-config-connection.html
  4. psycopg 3 — Connection poolsОфициальная документация. Что происходит, когда свободных соединений нет: «if no connection is available, the client is put in a queue, and will be served a connection once one becomes available» (если свободного соединения нет, клиент ставится в очередь и получит соединение, как только оно освободится). И честный ответ на вопрос о размере пула, который стоит процитировать целиком: «Big question. Who knows. However, probably not as large as you imagine» (Хороший вопрос. Кто ж его знает. Но, вероятно, не такой большой, как вам кажется).https://www.psycopg.org/psycopg3/docs/advanced/pool.html
  5. sys.setswitchinterval и sys.getswitchintervalОфициальная документация. Нужны для последнего раздела: интервал, через который интерпретатор выставляет запрос на передачу управления другому потоку. Прямая оговорка оттуда же — «The interpreter doesn't have its own scheduler» (У интерпретатора нет собственного планировщика) — объясняет, почему у потоков нет преимущества, которого от них ждут.https://docs.python.org/3/library/sys.html#sys.setswitchinterval