Как стать автором
Обновить

XML-RPC: Ускоряем работу сервера, пользуясь только стандартной библиотекой Python

Уровень сложностиСредний
Время на прочтение16 мин
Количество просмотров1.5K

В одном проекте сервис работы с электронной подписью был реализован с использованием модуля xmlrpc.server из стандартной библиотеки Python. Допустимая производительность обеспечивалась несколькими экземплярами сервиса, размещенными после балансировщика нагрузки. Возник интерес достичь требуемую производительность с использованием только одного экземпляра сервиса.

Модуль xmlrpc.server, являясь высокоуровневой сетевой библиотекой Python для реализации последовательных XML-RPC серверов, при непрерывной интенсивной работе с большим объемом вычислений (счетными задачами) в прикладной логике начинает накапливать входящие соединения, ожидающие обработки, что приводит к долгим ожиданиям ответов клиентами. Один из способов исключения таких ситуаций - создать несколько экземпляров приложения и поставить их за балансировщиком нагрузки. В данной же статье рассмотрим решение этой задачи в рамках одного экземпляра приложения. Какие есть способы повысить производительность последовательного xmlrpc.server:

  • распараллеливание обработки входящих запросов,

  • сделать его не последовательным (асинхронным),

  • комбинировать приведенные способы.

Далее реализуем эти способы повышения производительности xmlrpc.server и сравним их производительность между собой и с эталонной реализацией.

Начальные условия

Для реализации вариантов можно использовать только свой код и код из стандартной библиотеки Python! Такое ограничение может быть обусловлено использованием приложения в составе сертифицированного изделия, в котором нет широкого выбора сторонних библиотек Python или не желанием выполнения административных процедур по внесению новой зависимости в проект.

В качестве счетной задачи будем вызывать функцию определение числа из последовательности Фибоначчи (модуль load):

import sys
if hasattr(sys, "set_int_max_str_digits"):
    sys.set_int_max_str_digits(0)  # disable the integer string conversion length limitation    
    

def load(n: int) -> str:
    fib1 = fib2 = 1
    n -= 2
    while n > 0:
        fib1, fib2 = fib2, fib1 + fib2
        n -= 1
    return str(fib2)   

Возвращаемое значение может быть очень велико и выйти из допустимого диапазона XML-RPC type (от -2147483648 до 2147483647, согласно документации), поэтому переводим его в строку.

sys.set_int_max_str_digits отключает ограничение на преобразование значения типа int в str (появилось в Python 3.11, поэтому и проверяем на наличие такого атрибута).

Константы решения (модуль const):

HOST = "localhost"
PORT = 6677
TIMEOUT = 500
CALL_NUMBER = 1000
CLIENT_MAX_WORKERS = 20
SYNC_MAX_WORKERS = 0
ASYNC_MAX_WORKERS = 0
  • "localhost" - ip адрес сервера,

  • 6677 - порт сервера,

  • 500 - тайм-аут одного запроса в секундах,

  • 1000 - количество отправляемых клиентом асинхронных запросов;

  • 20 - количество потоков в пуле, из которого будут отправляться запросы на сервер;

  • 0 - по умолчанию синхронные и асинхронные сервера будут работать без дочерних процессов.

Для измерения времени выполнения работы сервера будем использовать модуль timeit на отправку запросов клиентами. Количество запусков таймера - 5 запусков (значение по умолчанию), количество циклов повторений - 1 цикл (для текущих условий такое значение будет рассчитано самим модулем timeit).

Для моделирования непрерывной интенсивной работы сервера будем использовать следующего клиента (модуль client):

import concurrent.futures

from operator import methodcaller
from typing import Any, List
from xmlrpc.client import ServerProxy

from const import *


def remote_call(host: str, port: int, method_name: str, *args, **kwargs) -> Any:
    result = None
    procedure = methodcaller(method_name, *args)
    with ServerProxy(f'http://{host}:{port}') as proxy:
        result = procedure(proxy)

    return result
  

def start_for_timeit(method_name: str, *args, 
                     host: str=HOST, port: int=PORT, timeout: int=TIMEOUT, 
                     number: int=CALL_NUMBER, max_workers: int=CLIENT_MAX_WORKERS):
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        load_futures = [executor.submit(remote_call, host, port, method_name, *args) for _ in range(number)]
        concurrent.futures.wait(load_futures, timeout)

Немного о клиенте:

  • клиент запускает указанное количество запросов (number) в пуле потоков, состоящем из max_workers потоков;

  • обязательный параметр - порядковый номер числа последовательности Фибоначчи (load_value, должно быть передано в последовательности *args);

  • запросы выполняются в потоках, отдельных от главного, чтобы придать эффект массовой мгновенной нагрузки (пока выполняется запрос GIL освобождается и захватывается следующим из пула потоков);

  • команда запуска:

python3 -m timeit -s 'from client import start_for_timeit' 'start_for_timeit("load", 150000)'
  • "load" имя вызываемого метода,

  • 150000 параметр вызываемого метода (соответствует разовому выполнению метода load примерно за 300 миллисекунд).

Для запуска синхронного сервера будем использовать команду:

python3 serve_forever.py параметры

Модуль serve_forever (синхронный вариант сервера) будет иметь следующий вид:

from xmlrpc.server import SimpleXMLRPCServer

from const import *
from load import load


def parse_args():
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('--host', default=HOST, type=str,
                        help=f'Specify server address [default: {HOST}]')
    parser.add_argument('--port', default=PORT, type=int,
                        help=f'Specify alternate port [default: {PORT}]')
                        
    # здесь будем добавлять дополнительные параметры    
    # в зависимости от варианта реализации
    # ...
                        
    return parser.parse_args()
  

if __name__ == '__main__':

    args = parse_args()
                        
    # здесь будем инстанцировать экземпляр сервера
    # в зависимости от варианта реализации
    # server = ...

    server.register_introspection_functions()
    server.register_function(load)
    server.serve_forever()

Для запуска асинхронного сервера будем использовать команду:

python3 aserve_forever.py параметры

Для асинхронного варианта сервера содержимое модуля aserve_forever следующее:

import asyncio

from const import *
from load import load


def parse_args():
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('--host', default=HOST, type=str,
                        help=f'Specify server address [default: {HOST}]')
    parser.add_argument('--port', default=PORT, type=int,
                        help=f'Specify alternate port [default: {PORT}]')
                        
    # здесь будем добавлять дополнительные параметры    
    # в случае необходимости
    # ...
    
    return parser.parse_args()
  

async def main():
    args = parse_args()
                        
    # здесь будем инстанцировать экземпляр сервера
    # server = ...
    
    server.register_introspection_functions()
    server.register_function(load)

    await server.serve_forever()
    

asyncio.run(main())

Серверное и клиентское приложения будем запускать в виртуальной машине, которой выделено 16 ядер ЦПУ, 16 Гб ОЗУ и 35 Гб ПЗУ (SSD). Таким образом, команда запуска клиента будет выглядеть:

python3 -m timeit -s 'from client import start_for_timeit' 'start_for_timeit("load", 150000)'

* для клиента к эталонной реализации будет использована команда с уменьшенным числом потоков в пуле:

python3 -m timeit -s 'from client import start_for_timeit' 'start_for_timeit("load", 150000, max_workers=5)'

Эталон

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

from xmlrpc.server import SimpleXMLRPCServer
server = SimpleXMLRPCServer((args.host, args.port))

Команда запуска сервера:

python3 serve_forever.py

Результат времени выполнения сервером клиентских запросов: 316,0 c

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

Форки процессов

Первый способ уменьшить время работы сервера - использовать для класса SimpleXMLRPCServer примесь ForkingMixIn из модуля socketserver, которая позволит обрабатывать запросы в дочерних процессах. Новый процесс будет создаваться каждый раз при поступлении нового запроса и завершать свою работу сразу, как только запрос будет обработан и клиенту будет отправлен ответ. Под капотом ForkingMixIn новый процесс получает запрос уже в виде принятого сокета. Понятно, что примесь ThreadingMixIn нам не подойдет, так как счетные задачи не получится распараллелить из-за наличия GIL у среды исполнения Python. У ForkingMixIn есть атрибут max_children, значение которого определяет максимальное количество созданных для обработки процессов, при достижении которого сервер не приступит к обработке нового запроса пока их количество не уменьшится. По умолчанию, его значение равно 40, мы же параметризуем это значение:

parser.add_argument('--processes', type=int, default=SYNC_MAX_WORKERS,
                    help=f'Number of worker processes to process the requests [default: {SYNC_MAX_WORKERS}]')
parser.add_argument('--fork', action='store_true',
                    help='Fork a new subprocess to process the request')

Инстанцируем сервер (проверку на наличие параметра fork пока пропустим):

from socketserver import ForkingMixIn
from xmlrpc.server import SimpleXMLRPCServer

class ForkingXMLRPCServer(ForkingMixIn, SimpleXMLRPCServer):
    max_children = args.processes

server = ForkingXMLRPCServer((args.host, args.port))

Команда запуска сервера:

python3 serve_forever.py --processes 10 --fork

Результат времени выполнения сервером клиентских запросов: 38,4 с

Поглядим на перечень работающих процессов в момент обработки сервером клиентских запросов и в момент его простоя.

Работающие процессы на сервере в момент обработки запросов
Работающие процессы на сервере в момент обработки запросов
Работающие процессы на сервере в момент его простоя
Работающие процессы на сервере в момент его простоя

Видим, что при простое никаких дочерних процессов у серверного приложения нет.

Анализ результата: время выполнения уменьшилось в 8,2 раз. Таким образом, написав несколько строчек кода, мы существенно улучшили производительность сервера.

В принципе, на этом можно было и остановиться, результат вполне приемлем. Но нами рассмотрены еще не все ранее предложенные способы. Как можно улучшить результат? Переходим к следующему варианту.

Пул процессов

У предыдущего варианта с примесью ForkingMixIn процессы создаются и уничтожаются каждый раз, когда необходимо обработать запрос. Время создания и уничтожения процесса конечно и оно существенно. Для исключения этого фактора используем пул процессов, то есть заранее создадим нужное количество процессов. Таким образом не потребуется создавать новый процесс при каждом поступившем запросе, так же не потребуется его уничтожать после обработки каждого запроса. По сути это решение - балансировка нагрузки внутри одного экземпляра сервиса на несколько запущенных дочерних процессов. Для этих целей воспользуемся классом Pool из модуля multiprocessing. Назовем класс нового сервера PoolXMLRPCServer, который мы определим в модуле pool_server. Для указания количества процессов в пуле, как и в предыдущем варианте, используем параметр processes:

parser.add_argument('--processes', type=int, default=SYNC_MAX_WORKERS,
                    help=f'Number of worker processes to process the requests [default: {SYNC_MAX_WORKERS}]')

Инстанцируем сервер:

from pool_server import PoolXMLRPCServer
server = PoolXMLRPCServer((args.host, args.port), args.processes)

Содержимое модуля pool_server:

import socket
from multiprocessing import Pool
from socketserver import TCPServer
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCDispatcher


def initializer(dispatcher, requestHandlerClass):    
    global server_dispatcher
    server_dispatcher = dispatcher  
    global RequestHandlerClass
    RequestHandlerClass = requestHandlerClass


def pool_process_request(request, client_address):
    global server_dispatcher
    global RequestHandlerClass
    try:
        RequestHandlerClass(request, client_address, server_dispatcher)

    except Exception:
        import sys
        print('-'*40, file=sys.stderr)
        print('Exception occurred in during processing of request from',
            client_address, file=sys.stderr)
        import traceback
        traceback.print_exc()
        print('-'*40, file=sys.stderr)
    finally:        
        try:
            #explicitly shutdown.  socket.close() merely releases
            #the socket and waits for GC to perform the actual close.
            request.shutdown(socket.SHUT_WR)
        except OSError:
            pass #some platforms may raise ENOTCONN here
        request.close()
        

class PoolXMLRPCServer(TCPServer):

    def __init__(self, addr, process_number: int, XMLRPCDispatcher=SimpleXMLRPCDispatcher, 
                 requestHandlerClass=SimpleXMLRPCRequestHandler, logRequests=True, 
                 allow_none=False, encoding=None, bind_and_activate=True, use_builtin_types=False):
        super().__init__(addr, requestHandlerClass, bind_and_activate)
        
        self.requestHandler = requestHandlerClass
        self.dispatcher = XMLRPCDispatcher(allow_none, encoding, use_builtin_types)
        self.dispatcher.logRequests = logRequests

        self.process_number = process_number

    def register_instance(self, instance, allow_dotted_names=False) -> None:
        self.dispatcher.register_instance(instance, allow_dotted_names)

    def register_function(self, function=None, name=None):
        return self.dispatcher.register_function(function, name)

    def register_introspection_functions(self) -> None:
        self.dispatcher.register_introspection_functions()

    def register_multicall_functions(self) -> None:
        self.dispatcher.register_multicall_functions()

    def serve_forever(self, poll_interval=0.5) -> None:
        self.pool = Pool(
            self.process_number, 
            initializer=initializer, 
            initargs=(self.dispatcher,self.requestHandler)
        )
        super().serve_forever(poll_interval)

    def process_request(self, request, client_address) -> None:
        self.pool.apply_async(pool_process_request, args=(request, client_address))

    def server_close(self) -> None:
        super().server_close()
        if hasattr(self, 'pool'):
            self.pool.close()
Немного пояснений по коду

Так как вся логика 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 для такой обработки и принятое входящее соединение в виде ее аргументов. Сама функция аналогична стандартному обработчику из базового класса, за тем исключение, что обработка ошибок (тоже стандартная) реализована в ней же, и здесь же мы высвобождаем ресурсы входящего соединения.

Команда запуска сервера:

python3 serve_forever.py --processes 10

Результат времени выполнения сервером клиентских запросов: 35,2 с

Опять взглянем на перечень работающих процессов в момент обработки сервером клиентских запросов и в момент его простоя.

Работающие процессы на сервере в момент обработки запросов
Работающие процессы на сервере в момент обработки запросов
Работающие процессы на сервере в момент его простоя
Работающие процессы на сервере в момент его простоя

Видим, что картина не меняется, дочерние процессы не создаются и не уничтожаются для каждого клиентского запроса, а постоянно находятся в пуле процессов.

Анализ результата: добились улучшение производительности на 8,3% по сравнению с предыдущим вариантом или в 8,9 раз - по сравнению с эталоном. Возможно ли еще как-то улучшить полученный результат?

Асинхронный сервер с пулом процессов

В предыдущем варианте с пулами процессов у нас всю обработку входящих соединений осуществляют дочерние процессы, а именно: обработка транспортного уровня (извлечение и запись данных из/в файло-подобных потоков), обработка протокольного уровня (проверка формата XML-RPC, получение вызываемого метода и его параметров) и, наконец, выполнение прикладной логики в определенном методе. С другой стороны, применив технику параллелизма, не была рассмотрена применимость техники конкурентности. Исправимся. Воспользуемся асинхронными инструментами, предоставленными нам средой разработки и исполнения. Асинхронная обработка транспортного уровня очень хорошо представлена в стандартной библиотеке asyncio. Для работы с входящим соединением будем использовать высокоуровневое API библиотеки - потоковых читателя (StreamReader) и писателя (StreamWriter). Обработка протокольного уровня - это, по большей части, счетная задача, тем не менее вынесем и ее из пула процессов.

Таким образом сформулируем задачу - разработать асинхронный XML-RPC сервер с пулом процессов, в которых должна выполняться только прикладная логика не адаптированная для асинхронного исполнения, а прикладную логику, которая поддерживает механизм конкурентности, как и транспортно-протокольную логику, будем выполнять конкурентно в цикле событий основного процесса.

Назовем класс асинхронного сервера AsyncPoolXMLRPCServer. Для его реализации сначала создадим асинхронную базу (механизм), в которой определим класс AsyncXMLRPCServer по аналогии с SimpleXMLRPCServer. Для этого изучим реализацию и диаграмму наследования класса SimpleXMLRPCServer.

Диаграмма наследования класса 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):

import asyncio
import inspect

from asyncio.events import AbstractEventLoop
from concurrent.futures import ProcessPoolExecutor
from functools import partial

from aioserver.aioserver.xmlrpc.server import AsyncXMLRPCServer


class AsyncPoolXMLRPCServer(AsyncXMLRPCServer):

    def __init__(self, addr, max_workers: int, *args, **kwargs):
        super().__init__(addr, *args, **kwargs)

        self.max_workers = max_workers

    async def serve_forever(self):

        if self.max_workers > 0 and self.has_not_async_function():
            self.executor = ProcessPoolExecutor(self.max_workers)
            self.loop: AbstractEventLoop = asyncio.get_running_loop()

        await super().serve_forever()

        if hasattr(self, 'executor'):
            self.executor.shutdown(wait=False, cancel_futures=True)

    async def call_func(self, method, *args, **kwargs):
        if inspect.iscoroutinefunction(method):
            return await method(*args, **kwargs)
        elif not hasattr(self, 'loop'):
            return method(*args, **kwargs)
            
        call_method = partial(method, *args, **kwargs)
        return await self.loop.run_in_executor(self.executor, call_method)
Немного пояснений по коду

Импортируем в модуле pool_server класс AsyncXMLRPCServer (из реализованной ранее асинхронной базы).

Перед началом ожидания входящих соединений в цикле событий мы создаем пул процессов, при условии, что у нас указано не нулевое количество воркеров и среди методов прикладной логики есть хоть один метод, не помеченный как async. Ранее мы упоминали про метод call_func, который выполняет прикладные методы в асинхронном или синхронном режимах, в этом классе добавим ему логику на проверку наличия пула процессов и, при его наличии, запуска прикладного метода в нем.

Репозиторий с асинхронной базой клонируем в текущую директорию. Для указания количества процессов в пуле используем параметр max_workers:

parser.add_argument('--max_workers', type=int, default=ASYNC_MAX_WORKERS,
                    help=f'Number of worker processes in pool to process the request [default: {ASYNC_MAX_WORKERS}]')

Инстанцируем сервер:

from pool_server import AsyncPoolXMLRPCServer
server = AsyncPoolXMLRPCServer((args.host, args.port), args.max_workers)

Команда запуска сервера:

python3 aserve_forever.py --max_workers 10

Результат времени выполнения сервером клиентских запросов: 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-файле.

Теги:
Хабы:
Всего голосов 3: ↑1 и ↓2+1
Комментарии4

Публикации

Истории

Работа

Python разработчик
122 вакансии
Data Scientist
61 вакансия

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область