Большинство программистов понимают, что асинхронный Python-код имеет более высокий уровень конкурентности, чем обычный синхронный код. Это даёт некоторые основания полагать, что асинхронный код способен показывать более высокий уровень производительности при решении распространённых задач вроде выдачи динамических веб-страниц или поддержки веб-API.
Но, к сожалению, Python-интерпретатор не выполняет асинхронный код быстрее синхронного.
В реалистичных условиях асинхронные веб-фреймворки показывают немного худшую пропускную способность (выраженную в запросах в секунду), чем обычные, и отличаются гораздо более сильной изменчивостью задержек.
Я протестировал широкий набор различных синхронных и асинхронных конфигураций веб-серверов. В следующей таблице показаны результаты испытаний.
50 и 99 перцентили времени отклика измерены в миллисекундах. Пропускная способность измерена в запросах в секунду. Данные в таблице отсортированы по 99 перцентилю. Я полагаю, что, если речь идёт о реальном применении технологий, это — самый важный показатель.
Обратите внимание на следующее:
Я думаю да. Я попытался максимально приблизить этот бенчмарк к реальности. Ниже показана схема использованной мной архитектуры тестовой среды.
Архитектура тестовой среды: генератор нагрузки, обратный прокси-сервер, веб-сервер, менеджер соединений, база данных
Я попытался сделать всё возможное для того чтобы как можно точнее смоделировать то, что используется в реальном мире. Здесь имеется обратный прокси (nginx), код, написанный на Python, и база данных (PostgreSQL). Я, кроме того, использовал здесь менеджер соединений для PostgreSQL (PgBouncer), так как полагаю, что в реальных конфигурациях, по крайней мере, там, где используется PostgreSQL, этот менеджер встречается достаточно часто.
Приложение, использованное в испытаниях, запрашивало строку таблицы базы данных с использованием случайного ключа и возвращало данные в формате JSON. Вот репозиторий с полным исходным кодом проекта.
Для того чтобы определить оптимальное количество процессов-воркеров, я пользовался одной и той же простой методикой. А именно, я, исследуя каждую из конфигураций, начинал с одного воркера. Потом я последовательно увеличивал их количество до тех пор, пока производительность конфигурации не ухудшалась.
Оптимальное количество воркеров у асинхронных и синхронных фреймворков различается. У этого есть очевидные причины. Асинхронные фреймворки, так как они используют подсистему ввода-вывода в конкурентном режиме, способны полностью нагрузить работой одно процессорное ядро с помощью одного процесса-воркера.
Этого нельзя сказать о синхронных фреймворках. Когда они выполняют операции ввода-вывода, они захватывают соответствующий ресурс до тех пор, пока эти операции не завершаются. Следовательно, им нужно столько воркеров, чтобы под высокой нагрузкой все имеющиеся в их распоряжении процессорные ядра использовались бы полностью.
Подробнее об этом можно почитать в документации к gunicorn:
Обычно мы рекомендуем, для начала, устанавливать количество воркеров, пользуясь следующей формулой: (2 x $num_cores) + 1. Эта формула построена исходя из предположения о том, что один воркер, выполняющийся на некоем процессорном ядре, будет заниматься чтением данных из сокета или записью данных в сокет, а в это время другой воркер будет заниматься обработкой запроса. За этой формулой не стоят некие глубокие научные исследования.
Я запускал бенчмарк на сервере, соответствующем тарифному плану Hetzner CX31. Речь идёт о 4 vCPU и о 8 Гб памяти. Сервер работал под управлением Ubuntu 20.04. Генератор нагрузки был запущен на другой виртуальной машине (менее мощной).
Если говорить о пропускной способности системы (то есть — о том, сколько запросов она способна обработать в секунду), то главным фактором здесь является не то, синхронный или асинхронный код в ней используется, а то, сколько Python-кода в ней было заменено на нативный код. Проще говоря: чем больше Python-кода, влияющего на производительность, можно заменить нативным кодом, тем лучше. Это — подход к производительности в Python, у которого долгая история (взгляните, например, на numpy).
Meinheld и UWSGI (~5300 запросов в секунду) содержат большие объёмы кода, написанного на C. Стандартный Gunicorn (~3400 запросов в секунду) — это чистый Python.
В связке Uvicorn+Starlette (~4900 запросов в секунду) на более производительный код заменено гораздо больше Python-кода, чем в стандартном сервере AIOHTTP (~4500 запросов в секунду). Правда, тут надо отметить, что AIOHTTP был установлен с необязательными «ускорителями».
Если говорить о задержках, то тут вырисовывается гораздо более глубокая проблема. Под высокой нагрузкой асинхронные системы работают хуже синхронных. При этом величины задержек асинхронных систем достигают более высоких значений.
Почему это так? Дело в том, что в асинхронном Python-коде реализована кооперативная многопоточность. Это, если объяснить по-простому, означает, что выполнение потоков не прерывается неким менеджером (например — планировщиком ядра). Потоки, вместо этого, отдают ресурсы другим потокам по собственной инициативе. В asyncio для этого используются три команды:
Это означает, что время выполнения не распределяется «справедливо», и то, что один поток может случайно лишить процессорного времени другие потоки. Именно поэтому величина задержек в асинхронных системах подвержена большим колебаниям, чем в синхронных.
Традиционные синхронные Python-серверы, вроде UWSGI, используют режим с принудительным распределением ресурсов между процессами. За это отвечает планировщик ядра. Это позволяет обеспечить справедливое распределение ресурсов путём периодического переключения процессов. Это означает, что процессорное время распределяется между процессами более справедливо, что ведёт к меньшей изменчивости величины задержек.
Основная масса других бенчмарков (особенно — тех, которые написаны авторами асинхронных фреймворков!) просто не исследует синхронные фреймворки с достаточным количеством воркеров. Это означает, что у исследуемых синхронных фреймворков просто нет возможности задействовать процессоры на полную мощность.
Вот, например, результаты бенчмарка, созданного в рамках проекта Vibora (я этот фреймворк не тестировал, так как он относится к числу наименее популярных решений).
Тестирование фреймворка Vibora
Результаты говорят о том, что пропускная способность Vibora на 500% выше, чем пропускная способность Flask. Правда, когда я почитал код использованного здесь бенчмарка, оказалось, что Flask тут был настроен далеко не самым оптимальным образом: на одно ядро приходился всего один воркер. Когда я это исправил, я получил то, что показано в следующей таблице.
То есть, на самом деле, Vibora обходит Flask лишь на 18%. Flask — это один из протестированных мной синхронных фреймворков с самой низкой пропускной способностью. Поэтому я полагаю, что более удачная синхронная платформа, несмотря на впечатляющий график, показанный выше, окажется гораздо быстрее, чем Vibora.
Ещё одна проблема заключается в том, что многие бенчмарки отдают приоритет показателям пропускной способности, а не результатам измерения задержек (в бенчмарке Vibora, например, о задержках даже не упоминается). Но, если пропускная способность может быть улучшена благодаря использованию большего количества серверов, задержки, характерные для некоей платформы, работающей под нагрузкой, это не улучшит.
Повышение пропускной способности, на самом деле, приносит пользу только тогда, когда задержки находятся в приемлемом диапазоне значений.
Хотя мой бенчмарк и достаточно реалистичен, если рассматривать его через призму используемых в нём технологий, нагрузка, которой я подвергаю сервер, гораздо однороднее той, которой подвергаются реальные серверы. А именно, в моём бенчмарке все запросы вызывают обращения к базе данных, при этом с тем, что пришло из базы данных, всегда делается одно и то же. В реальных приложениях всё, как правило, выглядит иначе. Например, в них могут встречаться быстрые и медленные операции. Некоторые из операций предусматривают интенсивное использование подсистемы ввода-вывода, а некоторые сильно нагружают процессор. Правильным мне кажется исходить из предположения о том (и опыт подсказывает мне, что так оно и есть), что в реальных приложениях диапазон изменения значений задержек будет гораздо больше.
Интуиция подсказывает мне, что в таких условиях ситуация с производительностью асинхронных приложений будет ещё сложнее. Общедоступные результаты наблюдений, сделанных различными людьми, эту идею подтверждают.
Так, Дэн Мак-Кинли писал о своём опыте работы с системой, основанной на фреймворке Twisted и используемой в Etsy. Похоже, что эта система страдала от хронической изменчивости задержек:
[Консультант по Twisted] сказал, что, хотя Twisted отличается высоким уровнем общей пропускной способности, некоторые запросы могут сталкиваться с очень сильными задержками. А это [для систем Etsy] было проблемой, потому что PHP-фронтенд обращался к Twisted сотни или тысячи раз на один веб-запрос.
Майк Байер, автор SQLAlchemy, несколько лет назад написал материал об асинхронном Python-коде и базах данных. Там он рассматривал асинхронный код с несколько иной точки зрения. Он тоже делал бенчмарки и выяснил, что asyncio отличается меньшей эффективностью, чем можно было бы ожидать.
На ресурсе rachelbythebay.com опубликована статья, в которой речь идёт о беспорядке, возникающем от использования конфигураций, основанных на gevent. Хочу отметить, что и я сталкивался с проблемами (хотя и не относящимися к производительности), когда использовал gevent в продакшне.
Ещё кое-что, что мне хотелось бы упомянуть, касается поведения фреймворков во время тестирования. Что интересно, каждый из исследованных мной асинхронных фреймворков давал пренеприятнейшие сбои.
Например, uvicorn останавливал родительский процесс, не останавливая дочерних процессов. А это означало, что мне нужно было «охотиться» за этими процессами, которые всё ещё «держались» за порт 8001. Однажды AIOHTTP выдал внутреннюю критическую ошибку, которая касалась файловых дескрипторов, но не остановился. А это значит, что его не смог бы перезапустить какой-нибудь менеджер процессов. Это, на мой взгляд, прямо-таки смертный грех. Проблемы были и у Daphne, но я уже не помню точно, что именно случилось.
Все эти ошибки случались нечасто, их легко можно было исправить с помощью SIGKILL. Но факт остаётся фактом: не хотелось бы мне столкнуться с чем-то таким в продакшне. В то же время, у меня, например, не было никаких проблем с Gunicorn и UWSGI, за исключением того, что мне сильно не понравилось то, что UWSGI не завершает работу в том случае, если приложение не было правильно загружено.
В итоге могу порекомендовать всем тем, кому нужна высокая производительность Python-кода, просто использовать обычные синхронные решения. При этом стоит заменить как можно большие объёмы Python на что-то более быстрое. В случае с веб-серверами, если важна пропускная способность системы, стоит обратить внимание на все фреймворки за исключением Flask. Правда, даже Flask в паре с UWSGI отличается показателями задержек, которые ставят его в один ряд с самыми лучшими фреймворками.
Пользуетесь ли вы серверными решениями, написанными на Python?
Но, к сожалению, Python-интерпретатор не выполняет асинхронный код быстрее синхронного.
В реалистичных условиях асинхронные веб-фреймворки показывают немного худшую пропускную способность (выраженную в запросах в секунду), чем обычные, и отличаются гораздо более сильной изменчивостью задержек.
Результаты бенчмарка
Я протестировал широкий набор различных синхронных и асинхронных конфигураций веб-серверов. В следующей таблице показаны результаты испытаний.
Веб-сервер | Фреймворк | Воркеры | Задержки, P50, мс | Задержки, P99, мс | Пропускная способность, запросов в секунду |
---|---|---|---|---|---|
Gunicorn с meinheld | Falcon | 16 | 17 | 31 | 5589 |
Gunicorn с meinheld | Bottle | 16 | 17 | 32 | 5780 |
UWGGI через http | Bottle | 16 | 18 | 32 | 5497 |
UWSGI через http | Flask | 16 | 22 | 33 | 4431 |
Gunicorn с meinheld | Flask | 16 | 21 | 35 | 4510 |
UWSGI через 'uwsgi' | Bottle | 16 | 18 | 36 | 5281 |
UWSGI через http | Falcon | 16 | 18 | 37 | 5415 |
Gunicorn | Flask | 14 | 28 | 42 | 3473 |
Uvicorn | Starlette | 5 | 16 | 75 | 4952 |
AIOHTTP | AIOHTTP | 5 | 19 | 76 | 4501 |
Uvicorn | Sanic | 5 | 17 | 85 | 4687 |
Gunicorn с gevent | Flask | 12 | 24 | 136 | 3077 |
Daphne | Starlette | 5 | 20 | 364 | 2678 |
50 и 99 перцентили времени отклика измерены в миллисекундах. Пропускная способность измерена в запросах в секунду. Данные в таблице отсортированы по 99 перцентилю. Я полагаю, что, если речь идёт о реальном применении технологий, это — самый важный показатель.
Обратите внимание на следующее:
- Лучше всего показывают себя синхронные фреймворки, однако Flask даёт худшие результаты, чем другие фреймворки.
- Худшие результаты имеют все асинхронные фреймворки.
- Для асинхронных фреймворков характерна очень сильная изменчивость задержек.
- Конфигурации, в которых используется uvloop, альтернативная реализации цикла событий для asyncio, показывают более высокие результаты, чем asyncio-конфигурации без uvloop. Это значит, что если вы вынуждены пользоваться asyncio-решениями, вам стоит применять и uvloop.
Репрезентативны ли результаты этого бенчмарка?
Я думаю да. Я попытался максимально приблизить этот бенчмарк к реальности. Ниже показана схема использованной мной архитектуры тестовой среды.
Архитектура тестовой среды: генератор нагрузки, обратный прокси-сервер, веб-сервер, менеджер соединений, база данных
Я попытался сделать всё возможное для того чтобы как можно точнее смоделировать то, что используется в реальном мире. Здесь имеется обратный прокси (nginx), код, написанный на Python, и база данных (PostgreSQL). Я, кроме того, использовал здесь менеджер соединений для PostgreSQL (PgBouncer), так как полагаю, что в реальных конфигурациях, по крайней мере, там, где используется PostgreSQL, этот менеджер встречается достаточно часто.
Приложение, использованное в испытаниях, запрашивало строку таблицы базы данных с использованием случайного ключа и возвращало данные в формате JSON. Вот репозиторий с полным исходным кодом проекта.
Почему в разных конфигурациях используется разное количество воркеров?
Для того чтобы определить оптимальное количество процессов-воркеров, я пользовался одной и той же простой методикой. А именно, я, исследуя каждую из конфигураций, начинал с одного воркера. Потом я последовательно увеличивал их количество до тех пор, пока производительность конфигурации не ухудшалась.
Оптимальное количество воркеров у асинхронных и синхронных фреймворков различается. У этого есть очевидные причины. Асинхронные фреймворки, так как они используют подсистему ввода-вывода в конкурентном режиме, способны полностью нагрузить работой одно процессорное ядро с помощью одного процесса-воркера.
Этого нельзя сказать о синхронных фреймворках. Когда они выполняют операции ввода-вывода, они захватывают соответствующий ресурс до тех пор, пока эти операции не завершаются. Следовательно, им нужно столько воркеров, чтобы под высокой нагрузкой все имеющиеся в их распоряжении процессорные ядра использовались бы полностью.
Подробнее об этом можно почитать в документации к gunicorn:
Обычно мы рекомендуем, для начала, устанавливать количество воркеров, пользуясь следующей формулой: (2 x $num_cores) + 1. Эта формула построена исходя из предположения о том, что один воркер, выполняющийся на некоем процессорном ядре, будет заниматься чтением данных из сокета или записью данных в сокет, а в это время другой воркер будет заниматься обработкой запроса. За этой формулой не стоят некие глубокие научные исследования.
Характеристики сервера
Я запускал бенчмарк на сервере, соответствующем тарифному плану Hetzner CX31. Речь идёт о 4 vCPU и о 8 Гб памяти. Сервер работал под управлением Ubuntu 20.04. Генератор нагрузки был запущен на другой виртуальной машине (менее мощной).
Почему асинхронный код работает хуже синхронного?
▍Пропускная способность
Если говорить о пропускной способности системы (то есть — о том, сколько запросов она способна обработать в секунду), то главным фактором здесь является не то, синхронный или асинхронный код в ней используется, а то, сколько Python-кода в ней было заменено на нативный код. Проще говоря: чем больше Python-кода, влияющего на производительность, можно заменить нативным кодом, тем лучше. Это — подход к производительности в Python, у которого долгая история (взгляните, например, на numpy).
Meinheld и UWSGI (~5300 запросов в секунду) содержат большие объёмы кода, написанного на C. Стандартный Gunicorn (~3400 запросов в секунду) — это чистый Python.
В связке Uvicorn+Starlette (~4900 запросов в секунду) на более производительный код заменено гораздо больше Python-кода, чем в стандартном сервере AIOHTTP (~4500 запросов в секунду). Правда, тут надо отметить, что AIOHTTP был установлен с необязательными «ускорителями».
▍Задержки
Если говорить о задержках, то тут вырисовывается гораздо более глубокая проблема. Под высокой нагрузкой асинхронные системы работают хуже синхронных. При этом величины задержек асинхронных систем достигают более высоких значений.
Почему это так? Дело в том, что в асинхронном Python-коде реализована кооперативная многопоточность. Это, если объяснить по-простому, означает, что выполнение потоков не прерывается неким менеджером (например — планировщиком ядра). Потоки, вместо этого, отдают ресурсы другим потокам по собственной инициативе. В asyncio для этого используются три команды:
await
, async for
и async with
.Это означает, что время выполнения не распределяется «справедливо», и то, что один поток может случайно лишить процессорного времени другие потоки. Именно поэтому величина задержек в асинхронных системах подвержена большим колебаниям, чем в синхронных.
Традиционные синхронные Python-серверы, вроде UWSGI, используют режим с принудительным распределением ресурсов между процессами. За это отвечает планировщик ядра. Это позволяет обеспечить справедливое распределение ресурсов путём периодического переключения процессов. Это означает, что процессорное время распределяется между процессами более справедливо, что ведёт к меньшей изменчивости величины задержек.
Почему другие бенчмарки дают другие результаты?
Основная масса других бенчмарков (особенно — тех, которые написаны авторами асинхронных фреймворков!) просто не исследует синхронные фреймворки с достаточным количеством воркеров. Это означает, что у исследуемых синхронных фреймворков просто нет возможности задействовать процессоры на полную мощность.
Вот, например, результаты бенчмарка, созданного в рамках проекта Vibora (я этот фреймворк не тестировал, так как он относится к числу наименее популярных решений).
Тестирование фреймворка Vibora
Результаты говорят о том, что пропускная способность Vibora на 500% выше, чем пропускная способность Flask. Правда, когда я почитал код использованного здесь бенчмарка, оказалось, что Flask тут был настроен далеко не самым оптимальным образом: на одно ядро приходился всего один воркер. Когда я это исправил, я получил то, что показано в следующей таблице.
Фреймворк | Пропускная способность, запросов в секунду |
Flask | 11925 |
Vibora | 14148 |
То есть, на самом деле, Vibora обходит Flask лишь на 18%. Flask — это один из протестированных мной синхронных фреймворков с самой низкой пропускной способностью. Поэтому я полагаю, что более удачная синхронная платформа, несмотря на впечатляющий график, показанный выше, окажется гораздо быстрее, чем Vibora.
Ещё одна проблема заключается в том, что многие бенчмарки отдают приоритет показателям пропускной способности, а не результатам измерения задержек (в бенчмарке Vibora, например, о задержках даже не упоминается). Но, если пропускная способность может быть улучшена благодаря использованию большего количества серверов, задержки, характерные для некоей платформы, работающей под нагрузкой, это не улучшит.
Повышение пропускной способности, на самом деле, приносит пользу только тогда, когда задержки находятся в приемлемом диапазоне значений.
Дальнейшие рассуждения, предположения и результаты наблюдений
Хотя мой бенчмарк и достаточно реалистичен, если рассматривать его через призму используемых в нём технологий, нагрузка, которой я подвергаю сервер, гораздо однороднее той, которой подвергаются реальные серверы. А именно, в моём бенчмарке все запросы вызывают обращения к базе данных, при этом с тем, что пришло из базы данных, всегда делается одно и то же. В реальных приложениях всё, как правило, выглядит иначе. Например, в них могут встречаться быстрые и медленные операции. Некоторые из операций предусматривают интенсивное использование подсистемы ввода-вывода, а некоторые сильно нагружают процессор. Правильным мне кажется исходить из предположения о том (и опыт подсказывает мне, что так оно и есть), что в реальных приложениях диапазон изменения значений задержек будет гораздо больше.
Интуиция подсказывает мне, что в таких условиях ситуация с производительностью асинхронных приложений будет ещё сложнее. Общедоступные результаты наблюдений, сделанных различными людьми, эту идею подтверждают.
Так, Дэн Мак-Кинли писал о своём опыте работы с системой, основанной на фреймворке Twisted и используемой в Etsy. Похоже, что эта система страдала от хронической изменчивости задержек:
[Консультант по Twisted] сказал, что, хотя Twisted отличается высоким уровнем общей пропускной способности, некоторые запросы могут сталкиваться с очень сильными задержками. А это [для систем Etsy] было проблемой, потому что PHP-фронтенд обращался к Twisted сотни или тысячи раз на один веб-запрос.
Майк Байер, автор SQLAlchemy, несколько лет назад написал материал об асинхронном Python-коде и базах данных. Там он рассматривал асинхронный код с несколько иной точки зрения. Он тоже делал бенчмарки и выяснил, что asyncio отличается меньшей эффективностью, чем можно было бы ожидать.
На ресурсе rachelbythebay.com опубликована статья, в которой речь идёт о беспорядке, возникающем от использования конфигураций, основанных на gevent. Хочу отметить, что и я сталкивался с проблемами (хотя и не относящимися к производительности), когда использовал gevent в продакшне.
Ещё кое-что, что мне хотелось бы упомянуть, касается поведения фреймворков во время тестирования. Что интересно, каждый из исследованных мной асинхронных фреймворков давал пренеприятнейшие сбои.
Например, uvicorn останавливал родительский процесс, не останавливая дочерних процессов. А это означало, что мне нужно было «охотиться» за этими процессами, которые всё ещё «держались» за порт 8001. Однажды AIOHTTP выдал внутреннюю критическую ошибку, которая касалась файловых дескрипторов, но не остановился. А это значит, что его не смог бы перезапустить какой-нибудь менеджер процессов. Это, на мой взгляд, прямо-таки смертный грех. Проблемы были и у Daphne, но я уже не помню точно, что именно случилось.
Все эти ошибки случались нечасто, их легко можно было исправить с помощью SIGKILL. Но факт остаётся фактом: не хотелось бы мне столкнуться с чем-то таким в продакшне. В то же время, у меня, например, не было никаких проблем с Gunicorn и UWSGI, за исключением того, что мне сильно не понравилось то, что UWSGI не завершает работу в том случае, если приложение не было правильно загружено.
Итоги
В итоге могу порекомендовать всем тем, кому нужна высокая производительность Python-кода, просто использовать обычные синхронные решения. При этом стоит заменить как можно большие объёмы Python на что-то более быстрое. В случае с веб-серверами, если важна пропускная способность системы, стоит обратить внимание на все фреймворки за исключением Flask. Правда, даже Flask в паре с UWSGI отличается показателями задержек, которые ставят его в один ряд с самыми лучшими фреймворками.
Пользуетесь ли вы серверными решениями, написанными на Python?