Доброго всем дня!

Хочу рассказать о своём практическом опыте реализации взаимодействия между процессами в среде 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

Комментарии (21)


  1. Zekori
    00.00.0000 00:00

    Если сообщения короткие, то udp предпочтительнее tcp.


    1. ftc Автор
      00.00.0000 00:00
      +1

      Да, но тогда строго говоря надо самостоятельно следить за потерями пакетов и что-то предпринимать по этому поводу (мы ж не можем просто "забить" на непришедший ответ).
      Понятно, что в рамках одной машины пакеты скорее всего теряться не будут, но строго говоря никто этого не гарантирует :-)


      1. POPSuL
        00.00.0000 00:00

        Строго говоря, не нужно на одной машине совмещать несколько вещей, таких как веб-сервис и какой-то микросеривис.

        Да и ваш тестовый пример возможно запустить только на PHP 7.4, который уже месяца 3-4 как EOL.


        1. ftc Автор
          00.00.0000 00:00

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

          Тестовый пример на то и тестовый, переписать на 8 версию не так сложно (тем более, что сборку расширения под 8.2 я уже починил).


  1. ValeriyPu
    00.00.0000 00:00

    Спасибо за статью, сейчас как раз пишу то же самое, только в виде генератора ).
    Смущает получившаяся у вас скорость, всего 30 тысяч запросов в секунду.
    Это следствие следствие сложной обработки запроса сервером?


    1. ftc Автор
      00.00.0000 00:00
      +1

      В тестовом сервере обработка заключается в банальном проходе по строке и подсчёту числа символов с разными кодами. Потому вряд ли в скорости обработки дело.
      Возможно сказывается время на первичную установку коннекта. Для интереса прогнал тест с 1000000 запросов, результаты вот:

      Testing 1000000 requests, reconnect after 1000000 size 128 
      Testing shared memory channel: 
      Total: 20.809 seconds, 48055.132 RPS
      Testing 1000000 requests, reconnect after 1000000 size 2048
      Testing shared memory channel:
      Total: 217.817 seconds, 4591.005 RPS


  1. pae174
    00.00.0000 00:00

    Странно себя ведёт решение с TCP сокетами - при постоянных переподключениях работает даже лучше, чем при переиспользовании соединения.

    Вероятно это срабатывает алгоритм Нейгла. Можно попробовать запустить тест без него. В PHP для этого есть socket_set_option и флаг TCP_NODELAY.


    1. ftc Автор
      00.00.0000 00:00
      +1

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

      Testing 1000 requests, reconnect after 1000 size 128
      Testing IP socket:
      Total: 28.123 seconds, 35.558 RPS
      Testing IP socket with TCP_NODELAY:
      Total: 28.151 seconds, 35.523 RPS
      Testing Unix socket:
      Total: 10.174 seconds, 98.294 RPS
      Testing shared memory channel:
      Total: 0.039 seconds, 25698.345 RPS
      Testing 1000 requests, reconnect after 1 size 128
      Testing IP socket:
      Total: 10.303 seconds, 97.057 RPS
      Testing IP socket with TCP_NODELAY:
      Total: 10.306 seconds, 97.029 RPS
      Testing Unix socket:
      Total: 10.203 seconds, 98.009 RPS
      Testing shared memory channel:
      Total: 10.396 seconds, 96.191 RPS


  1. Apoheliy
    00.00.0000 00:00

    Для TCP проблематично ускоряться при переподключениях: накладных расходов много, также на сервере создаётся каждый раз поток. Предположил бы, что здесь сильным ограничителем выступает клиентская сторона (плюс синхронный обмен).

    Проверить это можно, например, получив tcpdump. Или написав клиента на C++.

    Также согласен с pae174: похоже у вас nodelay не отрабатывает. Для проверки попробуйте пакет (в обе стороны) разбивать на собственно длину (отдельно посылается 4 байта) и собственно данные. И смотрите в tcp дамп.


    1. ftc Автор
      00.00.0000 00:00
      +1

      Пожалуй, это стоит отдельно поизучать. Займусь как-нибудь на досуге, самому интересно, странный очень эффект.


  1. rPman
    00.00.0000 00:00
    +1

    к сожалению php не предоставляет красивый способ использования ни shared memory ни mapped files, работая с ними как с файлами. На сериализацию и копирование данных тратится заметно много времени, что видно по тестам длинные/короткие запросы.

    Если блок памяти общий, замапленный на адресное пространство, его можно использовать приложением напрямую, т.е. можно было бы создать объект в памяти одновременно доступный в двух приложениях. Сам факт сообщения мог бы нести только ссылку на этот объект в памяти — 4..8 байта.

    p.s. вопрос, как работает shm_get_var в php?


    1. ftc Автор
      00.00.0000 00:00

      Имеете в виду, положить php-объект сразу в расшаренную память? Звучит интересно. Чисто технически, можно написать расширение, которое будет создавать объекты (ну, хотя бы строки), которые сразу доступны из обоих процессов.

      Про shm_get_var не в курсе, но вполне возможно она делает именно то, что вы описываете.

      Да, когда я писал расширения к Perl, там у меня как раз была сборка Perl-объекта на стороне C++ и возврат его в Perl целиком. Единственное неудобство - так как в Perl сборщик мусора основан на ссылках (refcount), надо очень аккуратно за ними следить, я в какой-то момент долго разбирался, почему память утекает.


      1. rPman
        00.00.0000 00:00

        Ага, скорее расширение писать с доступом сразу к нужным полям объекта из c++ приложения, компилируя расширение параллельно с сервером
        p.s. не смог собрать ваше расширение для php8, ругается на TSRMLS_CC, сильно не заморачивайтесь, просто сообщил


        1. ftc Автор
          00.00.0000 00:00
          +1

          Пофиксил, теперь должно собираться.


    1. SerafimArts
      00.00.0000 00:00
      +2

      p.s. вопрос, как работает shm_get_var в php?

      Эта штука вначале пропускает сдвиг для заголовка, потом переходит по нужному смещению (на основе индекса переменной, смещение следующего элемента записано в каждом элементе), а потом вытаскивает нужные данные и десериализует их. Таким образом, для N переменной формата int64 требуется N считываний, а потом распаковка формата (например такого для 2го элемента со значением int(23)):


      02 00 00 00 00 00 00 00 // << Это идентификатор
      05 00 00 00 00 00 00 00 // << Это размер данных (вроде бы о_0)
       ( 00 00 00 00 00 00 00
       i  :  2  3  ; 00 00 00 00 00 00 00 00 00 00 00 // << Это данные

      Поэтому использование sysvshm — это самый медленный и неоптимальный способ работы с SystemV, в отличие, например, от всех остальных решений (sysvshm vs. shmop vs. sync vs. ffi), которые предоставляют прямой доступ к памяти как к файлу: https://gist.github.com/SerafimArts/4c8cd05f20384551a65e3a2958557028


      +------------+------------+-----+--------+-----+-----------+---------+--------+
      | benchmark  | subject    | set | revs   | its | mem_peak  | mode    | rstdev |
      +------------+------------+-----+--------+-----+-----------+---------+--------+
      | WriteBench | benchShm   |     | 100000 | 5   | 472.232kb | 0.338μs | ±3.09% |
      | WriteBench | benchShmop |     | 100000 | 5   | 472.232kb | 0.174μs | ±2.25% |
      | WriteBench | benchSync  |     | 100000 | 5   | 472.232kb | 0.158μs | ±1.51% |
      | WriteBench | benchFFI   |     | 100000 | 5   | 472.232kb | 0.154μs | ±1.41% |
      +------------+------------+-----+--------+-----+-----------+---------+--------+

      P.S. Если же говорить про возможность просто сложить указатель в shmop, то чисто технически это возможно, т.к. и в fcgi и в mod_php память у всех доступна между процессами/потоками (т.е. никакого SIGSEGV и проч. не должно случиться). Т.е. достаточно на посылаемой стороне взять этот указатель, а на получаемой восстановить его в executor_globals. Однако стоит учитывать, что GC на "обеих сторонах" свой собственный, так что в момент refcount = 0 можно словить гонку состояний. Даже если не учитывать то, что любые операции с этим объектом будут подвержены сабжевой ошибке. Да и refcount тоже без атомиков работает вроде как)))


      1. SerafimArts
        00.00.0000 00:00
        +1

        P.S. Вот более точные бенчмарки со всеми возможными (что придумалось) способами работы с памятью https://github.com/SerafimArts/SharedMemoryBench


        1. ftc Автор
          00.00.0000 00:00

          И совершенно логичным образом всех заруливает "ручная" работа с памятью через FFI :-)


          1. SerafimArts
            00.00.0000 00:00

            В основном потому что там вырезаны вообще все проверки, которые есть в оригинальном коде.


            1) Вот оригинал, а вот жалкая пародия


            2) Ну и запись: Неповторимый оригинал и жалкая пародия


            А так да, через FFI можно добиться производительности выше, если знаешь что делаешь (ну кроме APCu, он там вон ещё быстрее по бенчам если приводить данные к типам, но я не копал, вряд ли он SysV использует, вполне возможно mmap какой-нибудь).


            1. ftc Автор
              00.00.0000 00:00

              APCu я бы тут отнёс к читерству, потому что добраться из не-php процесса до этого самого APCu будет проблемно.

              Да, почитал, там внутрях SysV https://github.com/krakjoe/apcu/blob/master/apc_shm.c


  1. alex-open-plc
    00.00.0000 00:00

    Помимо IPC есть еще и SHM (Named Shared Memory)... И, кстати, на win - тоже (уж не знаю, какую ботву в M$ курили...)
    POSIX стандарт.


    1. ftc Автор
      00.00.0000 00:00

      Подозреваю, что "под капотом" что shmget/shmat, что shm_open/mmap обращаются к одним и тем же механизмам ядра, API просто разное.
      Потому думаю, что существенной разницы в производительности не будет. Но для POSIX разделяемой памяти, опять же, надо своё расширение к PHP писать (как и с семафорами).