В одном проекте сервис работы с электронной подписью был реализован с использованием модуля xmlrpc.server из стандартной библиотеки Python. Допустимая производительность обеспечивалась несколькими экземплярами сервиса, размещенными после балансировщика нагрузки. Возник интерес достичь требуемую производительность с использованием только одного экземпляра сервиса. Модуль xmlrpc.server, являясь высокоуровневой сетевой библиотекой Python для реализации последовательных XML-RPC серверов, при непрерывной интенсивной работе с большим объемом вычислений (счетными задачами) в прикладной логике начинает накапливать входящие соединения, ожидающие обработки, что приводит к долгим ожиданиям ответов клиентами. Один из способов исключения таких ситуаций - создать несколько экземпляров приложения и поставить их за балансировщиком нагрузки. В данной же статье рассмотрим решение этой задачи в рамках одного экземпляра приложения. Какие есть способы повысить производительность последовательного xmlrpc.server: распараллеливание обработки входящих запросов, сделать его не последовательным (асинхронным), комбинировать приведенные способы. Далее реализуем эти способы повышения производительности xmlrpc.server и сравним их производительность между собой и с эталонной реализацией. Для реализации вариантов можно использовать только свой код и код из стандартной библиотеки Python! Такое ограничение может быть обусловлено использованием приложения в составе сертифицированного изделия, в котором нет широкого выбора сторонних библиотек Python или не желанием выполнения административных процедур по внесению новой зависимости в проект. В качестве счетной задачи будем вызывать функцию определение числа из последовательности Фибоначчи (модуль load): Возвращаемое значение может быть очень велико и выйти из допустимого диапазона XML-RPC type (от -2147483648 до 2147483647, согласно документации), поэтому переводим его в строку. sys.set_int_max_str_digits отключает ограничение на преобразование значения типа int в str (появилось в Python 3.11, поэтому и проверяем на наличие такого атрибута). Константы решения (модуль const): "localhost" - ip адрес сервера, 6677 - порт сервера, 500 - тайм-аут одного запроса в секундах, 1000 - количество отправляемых клиентом асинхронных запросов; 20 - количество потоков в пуле, из которого будут отправляться запросы на сервер; 0 - по умолчанию синхронные и асинхронные сервера будут работать без дочерних процессов. Для измерения времени выполнения работы сервера будем использовать модуль timeit на отправку запросов клиентами. Количество запусков таймера - 5 запусков (значение по умолчанию), количество циклов повторений - 1 цикл (для текущих условий такое значение будет рассчитано самим модулем timeit). Для моделирования непрерывной интенсивной работы сервера будем использовать следующего клиента (модуль client): Немного о клиенте: клиент запускает указанное количество запросов (number) в пуле потоков, состоящем из max_workers потоков; обязательный параметр - порядковый номер числа последовательности Фибоначчи (load_value, должно быть передано в последовательности *args); запросы выполняются в потоках, отдельных от главного, чтобы придать эффект массовой мгновенной нагрузки (пока выполняется запрос GIL освобождается и захватывается следующим из пула потоков); команда запуска: "load" имя вызываемого метода, 150000 параметр вызываемого метода (соответствует разовому выполнению метода load примерно за 300 миллисекунд). Для запуска синхронного сервера будем использовать команду: Модуль serve_forever (синхронный вариант сервера) будет иметь следующий вид: Для запуска асинхронного сервера будем использовать команду: Для асинхронного варианта сервера содержимое модуля aserve_forever следующее: Серверное и клиентское приложения будем запускать в виртуальной машине, которой выделено 16 ядер ЦПУ, 16 Гб ОЗУ и 35 Гб ПЗУ (SSD). Таким образом, команда запуска клиента будет выглядеть: * для клиента к эталонной реализации будет использована команда с уменьшенным числом потоков в пуле: Эталоном можно назвать эту реализацию по причине отсутствия с нашей стороны каких либо изменений в коде приложения. Дополнительных параметров командной строки нам не потребуется. Сервер будем инстанцировать так: Команда запуска сервера: Результат времени выполнения сервером клиентских запросов: 316,0 c Анализ результата: время выполнения получилось ожидаемо большим, так как поступившие запросы сервер обрабатывает последовательно, одно за другим. Первый способ уменьшить время работы сервера - использовать для класса SimpleXMLRPCServer примесь ForkingMixIn из модуля socketserver, которая позволит обрабатывать запросы в дочерних процессах. Новый процесс будет создаваться каждый раз при поступлении нового запроса и завершать свою работу сразу, как только запрос будет обработан и клиенту будет отправлен ответ. Под капотом ForkingMixIn новый процесс получает запрос уже в виде принятого сокета. Понятно, что примесь ThreadingMixIn нам не подойдет, так как счетные задачи не получится распараллелить из-за наличия GIL у среды исполнения Python. У ForkingMixIn есть атрибут max_children, значение которого определяет максимальное количество созданных для обработки процессов, при достижении которого сервер не приступит к обработке нового запроса пока их количество не уменьшится. По умолчанию, его значение равно 40, мы же параметризуем это значение: Инстанцируем сервер (проверку на наличие параметра fork пока пропустим): Команда запуска сервера: Результат времени выполнения сервером клиентских запросов: 38,4 с Поглядим на перечень работающих процессов в момент обработки сервером клиентских запросов и в момент его простоя. Видим, что при простое никаких дочерних процессов у серверного приложения нет. Анализ результата: время выполнения уменьшилось в 8,2 раз. Таким образом, написав несколько строчек кода, мы существенно улучшили производительность сервера. В принципе, на этом можно было и остановиться, результат вполне приемлем. Но нами рассмотрены еще не все ранее предложенные способы. Как можно улучшить результат? Переходим к следующему варианту. У предыдущего варианта с примесью ForkingMixIn процессы создаются и уничтожаются каждый раз, когда необходимо обработать запрос. Время создания и уничтожения процесса конечно и оно существенно. Для исключения этого фактора используем пул процессов, то есть заранее создадим нужное количество процессов. Таким образом не потребуется создавать новый процесс при каждом поступившем запросе, так же не потребуется его уничтожать после обработки каждого запроса. По сути это решение - балансировка нагрузки внутри одного экземпляра сервиса на несколько запущенных дочерних процессов. Для этих целей воспользуемся классом Pool из модуля multiprocessing. Назовем класс нового сервера PoolXMLRPCServer, который мы определим в модуле pool_server. Для указания количества процессов в пуле, как и в предыдущем варианте, используем параметр processes: Инстанцируем сервер: Содержимое модуля pool_server: Так как вся логика XML-RPC будет использоваться в процессах из пула, основная задача логики в основном процессе будет создание пула процессов и передача в него принятых входящих соединений. Поэтому наш сервер будет наследоваться от TCPServer, за реализацию XML-RPC протокола будет отвечать класс обработчика запросов (по умолчанию используем стандартный SimpleXMLRPCRequestHandler), а для определения какой метод прикладной логики вызвать используем экземпляр стандартного диспетчера XMLRPCDispatcher. Методы диспетчера для регистрации прикладной логики определим для сервера через прокси-методы с одноименным названием (register_instance, register_function, register_introspection_functions и register_multicall_functions). Перед запуском бесконечного цикла опросов на наличие входящих соединений создадим пул процессов с указанным количеством процессов в нем, а также определим инициализирующий метод initializer, который поместит в глобальную область каждого процесса экземпляр нашего диспетчера и класс для обработки запросов. Этот метод будет вызываться единожды для каждого процесса в момент создания пула процессов. Для передачи в пул процессов входящего соединения на обработку перепишем метод process_request базового класса, где с помощью доступного нам механизма pool.apply_async укажем функцию pool_process_request для такой обработки и принятое входящее соединение в виде ее аргументов. Сама функция аналогична стандартному обработчику из базового класса, за тем исключение, что обработка ошибок (тоже стандартная) реализована в ней же, и здесь же мы высвобождаем ресурсы входящего соединения. Команда запуска сервера: Результат времени выполнения сервером клиентских запросов: 35,2 с Опять взглянем на перечень работающих процессов в момент обработки сервером клиентских запросов и в момент его простоя. Видим, что картина не меняется, дочерние процессы не создаются и не уничтожаются для каждого клиентского запроса, а постоянно находятся в пуле процессов. Анализ результата: добились улучшение производительности на 8,3% по сравнению с предыдущим вариантом или в 8,9 раз - по сравнению с эталоном. Возможно ли еще как-то улучшить полученный результат? В предыдущем варианте с пулами процессов у нас всю обработку входящих соединений осуществляют дочерние процессы, а именно: обработка транспортного уровня (извлечение и запись данных из/в файло-подобных потоков), обработка протокольного уровня (проверка формата XML-RPC, получение вызываемого метода и его параметров) и, наконец, выполнение прикладной логики в определенном методе. С другой стороны, применив технику параллелизма, не была рассмотрена применимость техники конкурентности. Исправимся. Воспользуемся асинхронными инструментами, предоставленными нам средой разработки и исполнения. Асинхронная обработка транспортного уровня очень хорошо представлена в стандартной библиотеке asyncio. Для работы с входящим соединением будем использовать высокоуровневое API библиотеки - потоковых читателя (StreamReader) и писателя (StreamWriter). Обработка протокольного уровня - это, по большей части, счетная задача, тем не менее вынесем и ее из пула процессов. Таким образом сформулируем задачу - разработать асинхронный XML-RPC сервер с пулом процессов, в которых должна выполняться только прикладная логика не адаптированная для асинхронного исполнения, а прикладную логику, которая поддерживает механизм конкурентности, как и транспортно-протокольную логику, будем выполнять конкурентно в цикле событий основного процесса. Назовем класс асинхронного сервера AsyncPoolXMLRPCServer. Для его реализации сначала создадим асинхронную базу (механизм), в которой определим класс AsyncXMLRPCServer по аналогии с SimpleXMLRPCServer. Для этого изучим реализацию и диаграмму наследования класса SimpleXMLRPCServer. По результатам анализа составим новую диаграмму наследования, которую потребуется реализовать для выполнения поставленной выше задачи. Такое решение (вид новой диаграммы наследования) объясняется желанием соблюсти при его (решении) реализации структуру стандартной синхронной библиотеки и по максимуму использовать ее (стандартной синхронной библиотеки) же код. И так, нам необходимо реализовать следующие шесть классов: AsyncXMLRPCServer AsyncXMLRPCDispatcher, AsyncStreamServer, AsyncXMLRPCRequestHandler, AsyncBaseHTTPRequestHandler, AsyncStreamRequestHandler, а также одну функцию - async_http_parse_headers. Сам код можно посмотреть в репозитории на GitHub. Класс AsyncXMLRPCServer наследуется, по аналогии с синхронным вариантом, от класса асинхронного диспетчера AsyncXMLRPCDispatcher и асинхронного потокового сервера AsyncStreamServer, а также содержит экземпляр класса асинхронного обработчика запросов AsyncXMLRPCRequestHandler. Метод call_func реализует запуск определенного протокольным уровнем прикладного метода в асинхронном или синхронном режиме. Класс AsyncXMLRPCDispatcher наследуется от своего синхронного варианта и адаптирует логику его методов _marshaled_dispatch и _dispatch для работы в асинхронном режиме в новых методах _async_marshaled_dispatch и _async_dispatch соответственно. Класс AsyncXMLRPCRequestHandler наследуется, также по аналогии с синхронным вариантом, от AsyncBaseHTTPRequestHandler, а для использовании готовой реализации части логики протокольного уровня наследуется от XML-RPC обработчика SimpleXMLRPCRequestHandler. В классе содержится единственный метод async_do_POST, который реализует логику метода do_POST его синхронного варианта для работы в асинхронном режиме. Класс AsyncBaseHTTPRequestHandler, в свою очередь, наследуется от AsyncStreamRequestHandler (по аналогии с синхронным вариантом), а также использует часть логики протокольного уровня путем наследования от класса HTTP обработчика BaseHTTPRequestHandler. Сам класс реализует логику методов handle, handle_one_request и parse_request своего синхронного варианта в асинхронном режиме в соответствующих методах async_handle, async_handle_one_request и async_parse_request. Кроме этого класс асинхронно получает заголовки http-запроса, извлекаемые функцией async_http_parse_headers, которая адаптирует логику своего синхронного варианта в асинхронный вид. Классы AsyncStreamServer и AsyncStreamRequestHandler - базовые классы для определения интерфейсов сервера и обработчика запросов соответственно. Структуры интерфейсов аналогичны интерфейсам соответствующих суперклассов синхронных версий. Но в отличии от последних асинхронные базовые классы не только определяют интерфейс, но и реализуют его для работы с потоковыми читателями и писателями из стандартной библиотеки asyncio. Для старта сервера AsyncStreamServer используем функцию asyncio.start_server, которой мы указали метод _handle_stream для обработки поступившего входящего соединения, этому методу и передаются в аргументах потоковый читатель и потоковый писатель полученного соединения. Далее поток выполнения проходит через конструкцию, аналогичную как и в синхронных серверах. Дойдя до метода finish_stream мы конкурентно выполняем метод инициализации асинхронного обработчика запросов AsyncStreamRequestHandler, для этого оборачиваем его (метод инициализации) в асинхронный метод создания __new__ (иначе получим ошибку TypeError: __init__() should return None, not 'coroutine' , так как метод инициализации всегда должен возвращать None). Далее в обработчике запросов поток выполнения опять идет через конструкцию, аналогичную как и у синхронных обработчиков. Такой трюк нам позволяет провернуть совпадение используемых интерфейсов потоковых читателя и писателя с файло-подобным объектом. Для этого мы подменим в методе setup асинхронного обработчика файло-подобный объект на чтение - потоковым читателем, а файло-подобный объект на запись - потоковым писателем. Создав асинхронную базу, реализуем, наконец-то класс AsyncPoolXMLRPCServer (модуль pool_server): Импортируем в модуле pool_server класс AsyncXMLRPCServer (из реализованной ранее асинхронной базы). Перед началом ожидания входящих соединений в цикле событий мы создаем пул процессов, при условии, что у нас указано не нулевое количество воркеров и среди методов прикладной логики есть хоть один метод, не помеченный как async. Ранее мы упоминали про метод call_func, который выполняет прикладные методы в асинхронном или синхронном режимах, в этом классе добавим ему логику на проверку наличия пула процессов и, при его наличии, запуска прикладного метода в нем. Репозиторий с асинхронной базой клонируем в текущую директорию. Для указания количества процессов в пуле используем параметр max_workers: Инстанцируем сервер: Команда запуска сервера: Результат времени выполнения сервером клиентских запросов: 34,8 с Анализ результата: за счет асинхронности обработки запросов на транспортном уровне у нас опять получилось незначительно улучшить производительность. Кроме того мы исключили транспортную и протокольную логику из пула процессов и добавили возможность выполнять прикладную логику конкурентно. Для наглядности сведем в таблицу полученные значения времени выполнения для каждого из вариантов. Также, ради интереса, приведем результат асихронного сервера без пула процессов (команда для запуска такого сервера: python3 aserve_forever.py). Вариант решения Время обработки 1000 запросов, с Время уменьшилось на, % Кратность улучшения Эталон 316,0 0 1 Асинхронный 310,0 1,9 1,02 Форки процессов 38,4 87,8 8,2 Пул процессов 35,2 88,9 8,9 Асинхронный с пулом процессов 34,8 89,0 9,1 В итоге нам получилось улучшить производительность XML-RPC сервера в 9,1 раз (уменьшить время обработки на 89,0 %), используя только лишь стандартные библиотеки Python. Кратко укажем плюсы и минусы вариантов. Вариант решения Плюсы Минусы Эталон + работает из коробки - медленный Асинхронный + для быстрой прикладной логики (время ее выполнения сопоставимо или менее времени обработки транспортного и протокольного уровня) положительный эффект есть + возможность асинхронного выполнения адаптированной прикладной логики - практически нет эффекта для долгих счетных задач Форки процессов + существенное улучшение производительности + реализация почти из коробки - не оптимальная работа с дочерними процессами Пул процессов + производительность лучше, чем у форков процессов - если уж так углубляться, то необходимо использовать (рассмотреть) все возможности Python Асинхронный с пулом процессов + производительность лучше, чем у пула процессов + более глубокое использование возможностей Python + возможность асинхронного выполнения адаптированной прикладной логики - возможно это уже overhead, ведь и в стандартной библиотеки Python до сих пор отсутствует асинхронная XML-RPC библиотека Дальнейшее улучшение производительности вижу в смене протокола, имеющим более компактный формат данных (например, JSON-RPC), но это уже потребует написания соответствующего клиента к серверу. Также, вероятно, улучшит показатели использование другой реализации цикла событий (например, uvloop, на основе C-библиотеки libuv, или самим реализовать). А кто какие варианты еще видит в улучшении производительности стандартного XML-RPC сервера, используя только стандартные библиотеки Python? Как думаете, вынос логики, отличной от прикладной, из пула процессов оправдывает себя? Для удобства работы с реализованными вариантами создал отдельный репозиторий на GitHub, где привел Dockerfile для создания Docker-образа и упрощения запуска контейнеров. Построенный образ основан на образе python:3 (на момент написания статьи это версия 3.11). Последовательность действий и команды указаны в README-файле.  Архитектор ПО