Доброго всем дня!
Хочу рассказать о своём практическом опыте реализации взаимодействия между процессами в среде Linux и попытках сделать обмен максимально возможно эффективным. Сравним разные виды сокетов, задействуем примитивы синхронизации между процессами и мельком глянем, что ещё нам предлагает операционная система.
По условию, один из процессов написан на C++, второй на PHP, потому дополнительно мы рассмотрим доступность соответствующих API из PHP а также что делать, когда для нужного API PHP-обёртка отсутствует. Хотя предложенный подход не ограничивается конкретно этими языками и может быть применён для организации обмена между приложениями, реализованными на более-менее любом языке.
Постановка задачи
Итак, изначально задача заключалась в следующем. Есть некоторый сервис, написанный на C++, работающий в виде демона. Требуется к этому сервису отправлять запросы из web-приложения, реализованного на PHP. Сами по себе запросы и ответы достаточно короткие (порядка килобайта в среднем размер запроса и порядка сотен байт размер ответа). Однако запросов и ответов может быть достаточно много (на один запрос к веб-приложению, оно может сгенерировать несколько десятков запросов к сервису, а фоновые задачи веб-приложения могут генерировать такие запросы тысячами, а то и десятками тысяч).
Грубо говоря, нам надо уметь организовывать канал для передачи не слишком больших блоков данных между двумя приложениями. Конечно, если приложения расположены на разных серверах, основным вариантом будет какой-либо протокол поверх TCP/IP (и тут тоже есть что поизучать и что сравнить), но в нашем случае приложения работают на одном сервере, потому вариантов у нас тоже будет больше.
Обзор способов решения
Итак, для начала прикинем, какие у нас есть вообще варианты:
1. Использовать Redis, RabbitMQ или любой другой брокер сообщений. Вариант стандартный, пишется просто. Из минусов - будет завсегда медленнее чем общение между процессами напрямую (хотя бы потому, что для передачи "чего угодно" через Redis, надо передать "что угодно" из клиента в Redis, потом из Redis серверу). Сравнивать мы этот способ ни с чем не будем, но для полноты картины упомянуть его надо.
2. Задействовать старые добрые TCP сокеты. Поверх самих сокетов можно делать какой угодно протокол, но понятно, что чем он будет проще, тем лучше.
3. Unix Domain Sockets. С точки зрения "пользователя" почти то же самое, что и TCP Sockets, только по сети не работает.
4. Использовать какие-то внутрисистемные средства, вроде именованных каналов (named pipes), очередей (message queues) или разделяемой памяти (shared memory). Здесь вариантов достаточно много, мы остановимся на разделяемой памяти (поскольку сравнение, находимое беглым гуглением [2], говорит о том, что разделяемая память работает быстрее всего).
Методика тестирования
Для сравнения способов обмена данными, реализуем тестовые клиент и сервер (клиент на PHP, сервер на C++ соответственно). В качестве запроса будет выступать случайная строка (заданного размера), в качестве ответа - также случайная строка, размером в 8 раз меньше, чем исходная.
Для каждого вида взаимодействия клиент будет отправлять некоторое заданное количество запросов N, поочерёдно. После каждых M запросов клиент будет переподключаться к серверу заново (это поможет отследить влияние скорости установления канала, а стало быть производительность в случае короткоживущих клиентов).
Сервер будет обрабатывать каждого клиента в отдельном потоке, блокирующим образом (так проще, ядер CPU на всех хватит, а оптимизация использования CPU уже выходит за рамки этой статьи).
В качестве тестового стенда используется сервер со следующими характеристиками:
1. CPU - 2 x Intel(R) Xeon(R) CPU E5-2650 v4 @ 2.20GHz
2. RAM - 64Gb DDR4 ECC
3. OS - openSUSE Leap 15.4
Реализация обмена
TCP сокеты и Unix сокеты
Здесь всё достаточно просто. Чтобы не возиться с определением размера сообщения, оно передаётся в виде "4 байта длины", затем само тело сообщения. Соответственно и запрос и ответ передаются в таком виде. Для наших задач 4 байт на длину вполне хватит (даже более чем).
Разделяемая память
Идея разделяемой памяти заключается в том, что два процесса получают в совместное пользование блок оперативной памяти, в который могут читать и писать независимо друг от друга. Понятно, что записывая в него данные из одного процесса и читая из другого процесса мы можем передавать их. Однако же возникает вопрос - как процессу-читателю понять, что процесс-писатель уложил все нужные данные в разделяемую память и можно их читать? В первую очередь в голову приходит идея с какого-то сорта флагами (которые можно разместить например в первом байте разделяемого блока) - выставлять флаг в 1, если данные готовы. Звучит заманчиво, однако тогда читателю придётся в цикле проверять этот флаг (а такая проверка в цикле расходует CPU почём зря). Потому нужен какой-то механизм синхронизации, который бы позволил сигнализировать из одного процесса другому о доступности данных (причём так, чтобы управление передавалось читателю только когда поступил сигнал). Да, вы наверное уже догадались, что нам нужны семафоры или мьютексы, работающие между процессами.
Беглое гугление по фразе Linux semaphores
приводит нас к выводу, что почти всегда нам будет доступно как минимум два API работы с семафорами - POSIX semaphores и System V semaphores [3], [4]. Последнее, впрочем, уже считается несколько устаревшим и намного менее удобным. Выбором займёмся чуть позже, а пока распишем схему обмена.
У семафора есть собственно говоря две базовые операции - ожидание (уменьшение на 1, блокирование) и сигнализирование (увеличение на 1, разблокирование). Сам семафор представляет собой переменную-счётчик, операции над которым выполняются атомарно. При этом если какой-то процесс выполняет уменьшение семафора на 1 (ожидание), а семафор равен 0, процесс будет заблокирован до тех пор, пока кто-то не увеличит семафор на 1. Если же семафор увеличивают на 1 и есть какие-то ожидающие его процессы. один их них перестанет ждать (а значение семафора не изменится).
Для организации канала данных нам потребуется блок разделяемой памяти (в текущей реализации просто сделаем его больше, чем любое потенциально передаваемое сообщение) и два семафора (назовём их S и R соответственно).
Изначально оба семафора равны 0, в разделяемой памяти произвольный мусор. Сервер выполняет ожидание семафора S.
Когда клиент отправляет запрос серверу, он помещает запрос в разделяемую память (аналогично случаю с сокетами, первые 4 байта - длина, далее тело запроса), сигнализирует семафор S и ожидает семафор R.
Сервер разблокируется, читает данные из разделяемого блока, после чего обрабатывает их, кладёт ответ в разделяемый блок, сигнализирует семафор R и ожидает S.
Клиент разблокируется по сигналу семафора R, читает ответ из блока. На этом моменте цикл обмена данными замкнулся - сервер снова ждём семафор S, клиент получил ответ и может его обрабатывать.
Несложно видеть, что гонки в данном случае не критичны (клиент может начать ожидать R уже после того, как сервер его просигнализировал и положил ответ в разделяемую память; симметричная ситуация с сервером и семафором S). В любом случае к моменту чтения данных из разделяемого блока, в нём уже будут нужные данные. А также, с блоком в каждый момент времени работает только один процесс.
Семафоры в PHP
В случае с кодом на C++ всё достаточно просто - все системные API доступны непосредственно, возможно лишь потребуется добавить какие-то системные библиотеки в параметры линкера. С PHP же ситуация иная - с системными API он работает через механизм расширений. В случае с разделяемой памятью есть встроенное (ну ладно, не встроенное, а устанавливаемое) расширение shmop [5], которое даёт практически прямой доступ к функциям работы с блоками разделяемой памяти.
В случае с семафорами нас ждёт проблема - расширение для работы с семафорами есть, однако во-первых использует старое System V API, а во-вторых "под капотом" создаёт сразу три семафора, с которыми потом работает. В общем-то, вариантов остаётся примерно два:
1. Реализовать в коде C++ такую же логику работы с семафорами, как у "родного" расширения PHP.
2. Написать своё расширение для PHP, которое даст доступ к более удобному POSIX API.
После некоторого раздумья было принято решение реализовывать второй вариант (тем более, что опыт написания расширений, правда не для PHP, а для Perl, у меня когда-то давно был). В общем-то как оказалось, ничего сложного в этом нет, есть статья на хабре (хоть и старая) и целая книга PHP Internals Book, описывающая в деталях внутренности интерпретатора. Не будем здесь вдаваться в подробности, в итоге получилось расширение-обёртка (практически прозрачная) для POSIX semaphores API [9].
Ещё немного технических деталей
Поскольку жизненным циклом процесса PHP мы никак не управляем (а стало быть, не можем полноценно контролировать освобождение системных ресурсов, таких как блоки разделяемой памяти и семафоры), управление этими ресурсами было возложено на C++.
Для выделения блока разделяемой памяти нужен некоторый "ключ", получаемый функцией ftok
, а ей в свою очередь - какой-то реально существующий файл на диске. Потому этот файл приходится где-то создавать. Также, семафорам нужны имена (именно по имени осуществляется доступ). В текущей реализации, процесс-сервер выбирает себе некоторый случайный префикс и создаёт семафоры с именами вида /smc_send_<PREFIX>_<ID>
и /smc_receive_<PREFIX>_<ID>
. Конечно, лучше здесь использовать GUID, но длина префикса достаточна, чтобы и в таком варианте проблем особо не было. Да, ID здесь - это некоторый номер, уникальный в рамках процесса-сервера.
Также заслуживает внимания проблема "утечки" семафоров (в случае, если процесс-сервер будет аварийно завершен, например). Чтобы с этим побороться, можно сохранять PREFIX в какой-то файл, а при старте сервера проверять все семафоры с таким PREFIX и удалять их из системы.
Детально, процесс использования такого канала выглядит таким образом:
Клиент подключается к серверу через обычный сокет (TCP или Unix, неважно).
Сервер выделяет блок разделяемой памяти и оба семафора, после чего в ответном сообщении через сокет отправляет клиенту имена семафоров и ключ доступа к блоку памяти, полученный от
ftok
.Далее взаимодействие идёт уже через семафоры и блок памяти, по таймауту или по закрытию сокета с одной из сторон, все ресурсы освобождаются
Результаты сравнения
Итак, для начала. 1000 запросов, 2048 байт размер, без переподключений.
Total time, seconds | RPS | |
---|---|---|
TCP socket | 29.888 | 33.458 |
Unix socket | 10.439 | 95.794 |
Shared memory | 0.251 | 3978.483 |
Теперь попробуем переподключаться через каждые 10 запросов.
Total time, seconds | RPS | |
---|---|---|
TCP socket | 20.092 | 49.771 |
Unix socket | 10.465 | 95.553 |
Shared memory | 1.289 | 775.733 |
Странно, но TCP сокет стал работать быстрее. А вот разделяемая память ожидаемо замедлилась - затраты на установку соединения и создание системных ресурсов дают о себе знать.
Экстремальный случай, 1000 запросов, переподключение после каждого
Total time, seconds | RPS | |
---|---|---|
TCP socket | 10.544 | 94.84 |
Unix socket | 10.47 | 95.511 |
Shared memory | 10.645 | 93.941 |
Интересно, что TCP сокет ещё ускорился (такое ощущение, что оптимальный режим для него - переподключение после каждого запроса). При этом Unix сокет замедлился незначительно (как будто бы подключение занимает совсем небольшую долю от общего времени работы). Что же до варианта с разделяемой памятью - он работает примерно стольно же, сколько и Unix сокет, видимо по той причине, что основную часть времени занимает подключение.
Короткие запросы, без переподключений
Total time, seconds | RPS | |
---|---|---|
TCP socket | 27.915 | 35.823 |
Unix socket | 10.165 | 98.373 |
Shared memory | 0.036 | 28073.384 |
На времени работы через сокеты длина запроса практически не влияет, а вот вариант с разделяемой памятью существенно ускорился. Могу предположить, что в вариантах с сокетами основную часть времени занимает переключение контекста при системных вызовах (send и recv).
Ещё больше запросов, но короткие и тоже без переподключений
Total time, seconds | RPS | |
---|---|---|
TCP socket | 279.097 | 35.83 |
Unix socket | 101.697 | 98.331 |
Shared memory | 0.261 | 38275.623 |
Результаты практически аналогичны предыдущему варианту.
Выводы
Решение с разделяемой памятью оказалось существенно быстрее сокетов, потому для случаев, когда важна производительность, а процессы ограничены одной машиной, его вполне можно рекомендовать. Конечно, технически его реализовать труднее (а особенно аккуратно реализовать, поскольку задействуются системные ресурсы, за которыми надо следить и языковые средства тут не сильно помогают). Но выигрыш вполне может стоить потраченных усилий.
Странно себя ведёт решение с TCP сокетами - при постоянных переподключениях работает даже лучше, чем при переиспользовании соединения. Возможно, я не учёл что-то в коде. Либо же алгоритм sliding window начинает что-то портить. Выглядит как отдельная задача для изучения.
Unix сокеты работают стабильно и предсказуемо, производительность практически не меняется в зависимости от параметров.
Также могу отметить, что даже если какого-то системного API в вашем любимом языке программирования нет - это не повод отчаиваться, а повод его туда добавить. Практика показывает, что не так уж это и сложно.
Исходный код тестового проекта для сравнения доступен на гитхабе: https://github.com/denis-ftc-denisov/ipc_comparison.
Исходный код расширения для доступа к POSIX Semaphores API тоже доступен на гитхабе: https://github.com/denis-ftc-denisov/posix_semaphores.
Список использованных источников
1. IPC performance: Named Pipe vs Socket https://stackoverflow.com/questions/1235958/ipc-performance-named-pipe-vs-socket
2. IPC-Bench https://github.com/goldsborough/ipc-bench
3. System V IPC https://man7.org/linux/man-pages/man7/sysvipc.7.html
4. POSIX semaphores https://man7.org/linux/man-pages/man7/sem_overview.7.html
5. PHP Shared Memory https://www.php.net/manual/en/book.shmop.php
6. PHP Semaphore https://www.php.net/manual/en/book.sem.php
7. Пишем PHP extension https://habr.com/ru/post/125597/
8. PHP Internals Book https://www.phpinternalsbook.com/php7/extensions_design.html
9. POSIX semaphores https://github.com/denis-ftc-denisov/posix_semaphores