Переход на async: потолок ставит пул соединений, а не модель исполнения
Тысяча корутин при пуле из восьми соединений — это восемь одновременных запросов и очередь из 992. Три модели исполнения на одной и той же работе укладываются в разницу 5 %, а на коротком запросе без одновременности асинхронный код на 16–33 % дороже синхронного.
Полное техническое изложение
TL;DR
- К базе данных нельзя обращаться сколько угодно раз одновременно: число одновременных разговоров задано заранее и обычно невелико.
- Переписывание кода на «асинхронный» это число не увеличивает. Оно увеличивает только длину очереди к нему.
- На коротких обращениях асинхронный код оказался немного дороже обычного — примерно на пятую часть.
- Он выигрывает в другом месте: когда ждать надо не восемь вещей, а несколько тысяч сразу.
- Расхожее «одна тяжёлая операция парализует асинхронную программу, а обычная переживёт» проверили. Парализует обе.
Восемь окошек и очередь
Представьте отделение банка. Окошек восемь, посетителей — сколько придёт.
Можно нанять сто консультантов, которые будут вежливо провожать посетителей к окошкам. Очередь от этого не станет двигаться быстрее: обслуживают по-прежнему восемь окошек. Сто консультантов дадут ровно одно — очередь будет стоять организованнее.
Именно это происходит, когда приложение переписывают на асинхронный код в надежде ускорить работу с базой данных. Корутины — это консультанты. Окошки — соединения к базе, и их столько, сколько заведено заранее.
Ряд кнопок «соединений в пуле» — это окошки. Ряд «одновременных корутин» — посетители. Переключите второй, не трогая первый: одновременно обслуживаемых не станет больше ни на одного.
Ниже — сколько одна и та же партия обращений занимает у трёх разных способов ходить в базу. Названия можно не читать: смотреть надо на то, что все три полосы почти одинаковой длины и укорачиваются только тогда, когда прибавляешь окошек.
Почему окошек мало
Соединение к базе — это не «строчка в настройках». На стороне сервера под каждое соединение заводится отдельный рабочий процесс: собственная память, собственное место в очереди на процессор.
Поэтому окошки нельзя раздать всем желающим. У сервера есть общий предел — по умолчанию около сотни на всех, кто к нему обращается. И задолго до этого предела начинается другое: чем больше одновременных процессов, тем больше сервер занимается переключением между ними вместо работы.
Документация библиотеки, через которую Python ходит в PostgreSQL, отвечает на
вопрос «сколько соединений держать» на редкость откровенно: Big question. Who
knows. However, probably not as large as you imagine
(Хороший вопрос. Кто ж его знает. Но, вероятно, не такой большой, как вам кажется) — большой вопрос, кто
знает, но, скорее всего, не столько, сколько вам кажется.
Сюрприз: асинхронный код оказался дороже
Если обращений к базе немного и они идут по очереди, ускорять нечего — ждать параллельно нечего. Зато остаётся цена самой асинхронности: каждый раз, когда код чего-то ждёт, он возвращает управление распорядителю, а тот выбирает, чем заняться дальше. Эта работа не бесплатна.
Замер: одно и то же короткое обращение к базе, три способа обратиться.
Обычный синхронный способ оказался самым дешёвым. Разница — десятки микросекунд, то есть в масштабах отдельного запроса почти ничего. Но это разница в другую сторону, чем ждут от переписывания.
Где асинхронность выигрывает
Всё сказанное не значит, что она не нужна. Значит, что её польза в другом месте.
Представьте не банк, а справочную, где тысячи людей висят на линии и ждут новостей. Работы почти нет — надо просто держать линию. Вот здесь разница огромна: держать четыре тысячи ожиданий потоками — то есть отдельными исполнителями, которых заводит операционная система, и каждому нужна своя память — стоит две секунды от первого до последнего и шестьдесят мегабайт. А корутинами та же работа стоит треть секунды, из которых 200 мс — само ожидание, и практически ноль дополнительной памяти.
Поэтому вопрос перед переписыванием звучит так: сколько у вас одновременных ожиданий? Если приложение держит два десятка соединений к базе — двадцать, и выигрывать нечем. Если это шлюз, к которому подключены десять тысяч клиентов, — десять тысяч, и выигрыш будет огромным.
Про «тяжёлая операция всё парализует»
Есть известное предупреждение: если асинхронная программа начнёт что-то долго считать, она перестанет отвечать вообще — распорядитель занят, и никто не может его прервать. Это правда, и так написано в документации Python прямым текстом.
Из этого обычно делают вывод: значит, на обычных потоках такой беды нет, там операционная система переключит принудительно.
Проверили. Вывод неверный: обычные потоки страдают не меньше, а в замере даже чуть больше. Причина в устройстве самого Python — считать одновременно двум потокам он всё равно не даёт, про это есть отдельная статья. Лечится это не выбором между потоками и корутинами, а выносом тяжёлого счёта в отдельный процесс.
Что из этого следует
Прежде чем переписывать, стоит выяснить, во что упирается программа. Случаев три, и асинхронность помогает ровно в одном из них:
- упирается в число соединений к базе — помогут соединения, кеш и уменьшение числа запросов, но не переписывание;
- упирается в вычисления — помогут отдельные процессы;
- упирается в количество одновременных ожиданий — вот здесь асинхронность и нужна.
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 processpostgres).
Поэтому у пула нет способа выдать девятое соединение из восьми. Когда
свободных нет, происходит ровно то, что записано в документации 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, внутри — тот же поток.
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_executor | 0,434 с | 1,01× |
| psycopg, async | 0,452 с | 1,06× |
| asyncpg | 0,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 мс, и задержка считается от назначенного времени, а не от момента, когда до заявки дошли руки. После этого обе модели получают одинаковый поток и одинаковое право не успеть, а числа переворачиваются.
Правило отсюда переносится на любой свой замер: в сравнении двух моделей исполнения нулевая точка — самое лёгкое место, чтобы соврать самому себе. Прежде чем верить числу, проверьте, с какого момента идёт отсчёт в каждой из двух моделей, — особенно если результат подтвердил ровно то, что вы ожидали увидеть.
Что с этим делать
Порядок действий, в котором каждый следующий шаг имеет смысл только после предыдущего:
- Узнать, во что упираетесь. Три потолка из первой картинки различаются по симптому. Пока не ясно, какой из них ваш, выбор модели исполнения — угадывание.
- Если это соединения — считать пул и запросы, а не переписывать клиент.
Меньше запросов на операцию, кеш, пакетирование, внешний пул. Пул поднимать
осторожно и с оглядкой на
max_connections. - Если это процессор — процессы. Ни потоки, ни корутины не добавляют ядер, а под GIL счёт всё равно идёт по очереди.
- Если это число одновременных ожиданий — вот здесь async и нужен, и выигрыш будет в разах, а не в процентах.
- Если переписываете — переписывайте до драйвера. Асинхронный код с
синхронным драйвером внутри
run_in_executorработает ровно как потоки; это допустимо, но выигрыша от такого перехода ждать неоткуда. - Счётные куски выносить из обработчика — в процессы. Это верно и для async, и для потоков, и второе из двух обычно забывают.
И последнее. Все числа здесь сняты на локальной базе, где запрос идёт доли миллисекунды. Чем дальше база и чем дольше запрос, тем больше доля ожидания и тем менее заметен накладной расход модели — но потолок пула от расстояния не двигается. Он двигается только числом соединений.
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Одновременность при работе с базой ограничена размером пула соединений, а не числом корутин. Тысяча корутин при пуле восемь — это восемь запросов и очередь из 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 счёт последовательный в обеих моделях.
На самом деле
- Одновременно пойдёт ровно столько запросов, сколько соединений в пуле, — и ни одним больше. Замер на одной и той же работе (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. Быстрее асинхронный драйвер не на запросе, а на одновременности — но одновременность ограничена пулом, а не драйвером. - Замер этого не показывает. Те же 600 запросов при пуле 8: чистые потоки 0,428 с, они же через
run_in_executor— 0,434 с, разница полтора процента. Обёртка не бесплатна, но её цена теряется на фоне того же потолка. Приём остаётся плохим ответом на вопрос «как ускорить» и вполне рабочим ответом на вопрос «как получитьawaitна границе, ничего не меняя в цене». - Экономит — но только там, где ожиданий много. При двадцати одновременных ожиданиях разницы нет никакой. При четырёх тысячах она в разы: +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. Вторая проверена и не подтвердилась. Один счётный кусок на 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) — на всех клиентов сразу, и цена самого процесса на соединение. Документация psycopg отвечает на вопрос о размере пула прямо: Big question. Who knows. However, probably not as large as you imagine. - Дальняя база увеличивает долю ожидания в запросе — значит, накладной расход модели становится менее заметным, и это правда. Но потолок пула от расстояния не двигается: восемь соединений остаются восемью и на локальной машине, и через океан. Дальняя база меняет то, сколько вы теряете на переписывании, а не то, сколько выигрываете.
Что разобрано
- Зачем это знать?
- Модель в голове: три потолка
- Первое: потолок ставит пул
- Второе: одна операция у async стоит дороже
- Третье: синхронный драйвер внутри async
- Четвёртое: где 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 точно выиграет».
Дальняя база увеличивает долю ожидания в запросе — значит, накладной расход модели становится менее заметным, и это правда. Но потолок пула от расстояния не двигается: восемь соединений остаются восемью и на локальной машине, и через океан. Дальняя база меняет то, сколько вы теряете на переписывании, а не то, сколько выигрываете.
Проверка знаний
Приложение делает 500 запросов в секунду к PostgreSQL через пул из 10 соединений. Переписали с потоков на asyncio с асинхронным драйвером, число корутин выросло до 500. Что произойдёт с пропускной способностью?
Источники и что читать дальше
5 ИСТОЧНИКОВ
- 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
- 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
- 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
- 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
- sys.setswitchinterval и sys.getswitchintervalОфициальная документация. Нужны для последнего раздела: интервал, через который интерпретатор выставляет запрос на передачу управления другому потоку. Прямая оговорка оттуда же — «The interpreter doesn't have its own scheduler» (У интерпретатора нет собственного планировщика) — объясняет, почему у потоков нет преимущества, которого от них ждут.https://docs.python.org/3/library/sys.html#sys.setswitchinterval