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

$storage->set('key', 'value');

echo $storage->get('key'); // string(5) "value"

Хорошо... Давайте подумаем что теоретически мы можем использовать для работы с
подобным классом задач?

Что по готовым инструментам?

Если учитывать, что у нас имеется нагрузка в полтора землекопа, то для этого случая более чем достаточно обычных файлов. Чтение и запись в конкретный файл доступен любому клиенту, а для взаимных блокировок доступа следует использовать flock. Жёсткий диск (ну или SSD) в этом случае подвергаться издевательствам не будет и проживёт ещё долго. И все счастливы!

Как только наш проект становится популярнее - неизбежно получим увеличение количества RPS и вроде как "бутылочное горлышко" в виде тормозящей дисковой подсистемы (но это не точно), а при некорректно сформированной структуре - полную ликвидацию доступных inode в нашей доблестной ext4.

Да и вообще, использовать файлы в 2022ом году - не солидно.

Если наш проект использует классическую схему с использованием реляционной БД, то простейшим способом будет просто создать рядом таблицу, куда всё это дело будем складывать. А если у нас MySQL, то можно использовать движок MEMORY, который должен увеличить производительность.

Если думать над инструментами далее, то приходят в голову более специализированные средства вроде Redis, Tarantool и Memcached. Причём под задачу с командами get, set, delete идеально подходит именно последний: Всё API у него и состоит как раз из описанных выше методов. А за счёт хранения всего и вся в памяти получаем намного более быстрый доступ к данным.

Однако, в случае работы с каким-нибудь Memcached при повышении нагрузки можно начать получать с некоторой периодичностью замечательное сообщение: "SERVER IS MARKED DEAD" и тут или организовывать кластер с лоад балансером... Кластер с балансером под кеш в рамках одного сервера? Серьёзно?

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

P.S. Вообще, признаться, согласен, подводка к статье довольно слабая и можно было придумать что-то поинтереснее =)

Память есть? А если найду?

Есть ни на чём не основанное подозрение, что вызов $value = 42 уже должно писать данные в эту самую оперативную память, и проблема в данном случае состоит только в том, чтобы прочитать потом это записанное значение из параллельных и последующих процессов или потоков.

Причём наши дальнейшие действия будут зависеть как раз от того что используется на сервере: Потоки или процессы. А для того, чтобы это выяснить следует немного погрузиться во внутреннее устройство популярных серверов:

Про Apache с mod_php (если утрировать и не учитывать различные MPM, вроде более классического prefork) всё не так интересно: Есть сервер, который ловит запросы. Каждый запрос создаёт новый физический поток и начинает его вертеть. После завершения работы срабатывает подчистка и память потока уничтожается. Все счастливы. И даже если не лезть внутрь устройства PHP TSRM, то довольно очевидно, что память у каждого треда общая (без учёта собственного стека и TLS) и чисто теоретически, можно обойтись обычным FFI::new(), просто передав ему третий аргумент и выделив память через malloc, не удаляя её во время общей чистки после завершения обработки запроса.

В случае же с FCGI режимом всё чуть сложнее: Как мы возможно знаем, типичное устройство FPM выглядит следующим образом:

                              ┌┄┄ fpm-воркер
nginx/apache ┅┅ fpm-мастер ┅┅┅┽┄┄ fpm-воркер
                              └┄┄ fpm-воркер

Мастер-процесс создаёт воркера через форк, что фактически является созданием отдельного подпроцесса с копированием памяти. Так что выделение памяти через FFI::new() в рамках воркера будет влиять на сам воркер и будет жить пока жив этот подпроцесс. Память же в соседнем форке будет совершенно независимой и никак не связана с соседями.

Но постойте-ка, кажется, что можно выделить общую "межпроцессовую" память, которая будет доступна любому желающему? Это действительно так и более того, сам механизм opcache по умолчанию использует именно её
(в различных вариациях), предоставляя кеш опкодов любому желающему процессу PHP.

Моделей же общей памяти (DSM) несколько: Как минимум System-V, POSIX и mmap. Под Windows же используется механизм похожий на mmap. И в зависимости от платформы и используемого расширения модель памяти (API доступа к ней в т.ч.) может отличаться.

Вариантов же у нас несколько:

  • sysvshm: Поставляется вместе с PHP (по крайней мере в официальной Docker сборке PHP), но не работает на Windows, т.к. использует только System-V API.

  • shmop: Поставляется вместе с PHP. Работает как на *nix (Linux/Unix/MacOS/etc) используя System-V, так и на Windows, предоставляя альтернативный доступ через Win32 API.

  • sync: Установить его можно только из PECL или через, например, apt, eсли мы говорим, например, об основном репозитории пакетов от Ondřej Surý).

  • ffi: Уберплюшка, с помощью которой можно сделать что угодно средствами почти что самого PHP. Доступен и включён (правда в режиме preload) сразу же.

Вариантов много, но зачем столько? Кажется, что они дублируют друг-друга. И если
разницы нет, то зачем платить больше следует выбирать исходя из:

  1. Скорости

  2. Удобства

  3. Переносимости

Давайте просто попробуем написать простой пример чтения и записи пары значений для оценки скорости работы, возможностей и удобства АПИ разных расширений.

SysVShm vs Shmop vs Sync

Начнём по-порядку, пожалуй. Простейшая реализация под sysvshm будет выглядеть следующим образом:

const MEMORY_ID = 0xDEAD_BEEF;
const MEMORY_SIZE = 120;
const MEMORY_PERMISSIONS = 0o666;

$shm = shm_attach(MEMORY_ID, MEMORY_SIZE, MEMORY_PERMISSIONS);

// Записываем два числа
shm_put_var($shm, 1, 42);
shm_put_var($shm, 2, 23);

// Считываем последнее число
echo shm_get_var($shm, 2);

Всё довольно просто:

  • Мы создаём (или подключаемся) область памяти с идентификатором 0xDEAD_BEEF размером в 120 байт и правами RW для любого пользователя в системе.

  • Затем записываем два int64_t (это размер int для PHP x64) числа и считываем последнее.

Считывание данных из таких ячеек может происходить в абсолютно произвольных процессах. Что-то вроде переменной $_SESSION, однако данные там доступны для любого желающего запроса, консольного скрипта или даже программы, написанной не на PHP.

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

Расширение shmop предоставляет более низкоуровневый доступ к общей памяти.
А работа с областью памяти очень похожа на работу с файлом: У нас есть один большой кусок текста, а мы сами можем считывать и записывать байты куда и как угодно по сдвигу (относительно начала) и указывая размер, который хотим считать.

const MEMORY_ID = 0xDEAD_BEEF;
const MEMORY_SIZE = 120;
const MEMORY_PERMISSIONS = 0o666;

$shmop = shmop_open(MEMORY_ID, 'c', MEMORY_PERMISSIONS, MEMORY_SIZE);

// Записываем числа 42 и 23 предварительно их пакуя
// по оффсету 8 и 16 соответсвенно.
shmop_write($shmop, pack('q', 42), 1 * 8);
shmop_write($shmop, pack('q', 23), 2 * 8);

// Считываем 8 байт начниная с оффсета 8, а затем конвертируем
// данные обратно в int64.
$data = shmop_read($shmop, 1 * 8, 8);
echo unpack('q', $data)[1];

Уже на этом этапе понятно, что расширение sysvshm удобнее для пользователя, нежели shmop, так как не приходится заботиться о том как считывать и писать данные. Просто указываешь числовые ключи для переменной и всё.

Расширение же sync имеет объектно-ориентированный интерфейс, но API в целом очень похоже на shmop. Запись и чтение требует предварительной конвертации данных:

const MEMORY_NAME = 'IDDQD';
const MEMORY_SIZE = 120;

$sync = new \SyncSharedMemory(MEMORY_NAME, MEMORY_SIZE);

// Записываем числа 42 и 23 с аналогичной запаковкой
$sync->write(pack('q', 42), 1 * 8);
$sync->write(pack('q', 23), 2 * 8);

// Считываем 8 байт начниная с оффсета 8 с аналогичной распаковкой
$data = $sync->read(1 * 8, 8);
echo unpack('q', $data)[1];

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

  • sysvshm

    • + Доступно сразу (в официальных и полуофициальных сборках)

    • - Только *nix

    • + Удобно читать и писать

  • shmop

    • + Почти доступно сразу (в windows достаточно раскомментировать, а под *nix через apt/aptitude/etc)

    • + Любая платформа

    • - Чтение по оффсетам и лимитам, ручная конвертация

  • sync

    • - Доступно как PECL (собираем исключительно руками или через apt в Sury-сборках)

    • + Любая платформа

    • - Чтение по оффсетам и лимитам, ручная конвертация

После того как мы получили подобные результаты могут возникнуть вопросы: А почему в последних расширениях shmop и sync доступ производится посредством оффсетов и лимитов. Почему там не сделано так же как в sysvshm?

Но если чуть задуматься, то ответ, возможно, придёт самостоятельно. Если функция shm_put_var позволяет записывать даже объекты, значит как и в случае с
какими-либо другими реализациями кеша поверх файлов - производится дополнительная произвольная сериализация данных.

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

Устройство SysVShm

Так что же там "под капотом" sysvshm расширения? Как оно позволяет записывать в память объекты и прочие не строковые и композитные данные?

Для начала, следует упомянуть, что для получения информации о System-V областях памяти можно воспользоваться командой ipcs:

$ ipcs -m
------ Shared Memory Creator/Last-op --------
key        shmid      owner      perms      bytes      nattch     status
0x00000000 158564353  root       600        524288     2                
0x00000000 32774      root       600        524288     2                
0x00000000 7          root       600        524288     2                
0xdeadbeef 304152584  root       666        120        0                
// ... etc

В консольном выводе можно увидеть список всех выделенных областей памяти с их правами, количеством "приаттаченных" процессов и прочей информацией, включая идентификатор и размер. Как можно понять, последний элемент в списке с key = 0xdeadbeef как раз и является той областью, что была создана нами. Внутри этой области как раз и содержатся записанные данные.

Обратите внимание, что выделенная область памяти не уничтожилась вместе с
завершением процесса. Это значит что мы можем к ней обращаться в любой
момент времени. Для принудительного уничтожения области памяти следует
воспользоваться соответствующей функцией shm_remove.

Помимо этого стоит упомянуть, что мы говорим исключительно System-V в
Linux/Unix-like. А упоминая shmop и sync и их переносимости на
Windows OS стоит повторно акцентировать внимание на то, что данное
поведение присуще только при использовании System-V API. Что значит
под Windows OS, где АПИ отличается - память будет удаляться автоматически
после завершения основного процесса (точнее после закрытия всех дескрипторов
на память, а не главного процесса как такового).

Давайте посмотрим что же там внутри? Ага, только просто так вывести внутренности этой области памяти не получится. Конечно, можно воспользоваться утилитой shmcat, которая доступна только под BSD, или написать код на С, воспользовавшись вызовом shmat. Но самым простым вариантом будет воспользоваться функцией чтения из расширения shmop, прочитав все данные от начала и до конца доступной нам памяти.

$ php -r "echo shmop_read(shmop_open(0xDEADBEEF, 'a', 0666, 120), 0, 120);"

Вывод этой области памяти буду следующими:

PHP_SM(xx(i:42;(i:23;

Т.е. мы записываем через sysvhm, а читаем через shmop. И работаем с одной
и той же областью памяти.

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

 P  H  P  _  S  M 00 00
 ( 00 00 00 00 00 00 00
 x 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00
 x 00 00 00 00 00 00 00
01 00 00 00 00 00 00 00
05 00 00 00 00 00 00 00
 ( 00 00 00 00 00 00 00
 i  :  4  2  ; 00 00 00 00 00 00 00 00 00 00 00
02 00 00 00 00 00 00 00
05 00 00 00 00 00 00 00
 ( 00 00 00 00 00 00 00
 i  :  2  3  ; 00 00 00 00 00 00 00 00 00 00 00

Из результата подобного исследования "снаружи", даже не влезая в код можно понять, что формат i:XX явно указывает на PHP-сериализацию, плюс расширение сохраняет дополнительно три 64-ричных значения: Идентификатор (ключ) значения, размер сериализованной строки и некую дополнительную информацию, что-то вроде такого:

$value      = 42;
$serialized = serialize($value);

$result = pack('qqqa*', 1, strlen($serialized), 40, $serialized);

Если говорить об оригинальном коде, то в память напрямую пишется структура,
которую можно увидеть по ссылке: https://github.com/php/php-src/blob/PHP-8.1.4/ext/sysvshm/php_sysvshm.h#L42-L47

Ладно, это всё замечательно и всё вроде в полном порядке. Однако структура не содержит флагов, отвечающих за информацию о формате записи данных, следовательно, очевидно, что в случае записи строки ($memory->set(1, 'example')) в память запишутся такие же сериализованные данные. А это значит, что хоть с точки зрения пользователя всё и удобно, однако в некоторых случаях сериализация избыточна, а в случае использования ext-igbinary в проекте даже вредна.

Как внимательный читатель уже мог заметить shmop позволяет записывать только строки, а следовательно всю сериализацию (если она потребуется, конечно) пользователь может контролировать самостоятельно. Что позволяет выжать из производительности доступа к общей памяти ещё больше попугаев.

Так что в результате получаем либо удобство, либо скорость. Магии не бывает. Поэтому, результаты бенчмарков ниже, с записью переменных - не удивительны:

PHPBench (1.2.0) running benchmarks...
with configuration file: ~/phpbench.json
with PHP version 8.1.4, xdebug ❌, opcache ✔

\Bench\WriteBench

    benchShm................................I4 - Mo4.491μs (±4.43%)
    benchShmop..............................I4 - Mo2.357μs (±1.56%)
    benchSync...............................I4 - Mo4.215μs (±13.78%)


+------------+------------+-----+--------+-----+-----------+---------+---------+
| benchmark  | subject    | set | revs   | its | mem_peak  | mode    | rstdev  |
+------------+------------+-----+--------+-----+-----------+---------+---------+
| WriteBench | benchShm   |     | 100000 | 5   | 591.984kb | 4.491μs | ±4.43%  |
| WriteBench | benchShmop |     | 100000 | 5   | 591.984kb | 2.357μs | ±1.56%  |
| WriteBench | benchSync  |     | 100000 | 5   | 591.984kb | 4.215μs | ±13.78% |
+------------+------------+-----+--------+-----+-----------+---------+---------+

В каждом из тестов происходит подключение и запись трёх значений разных типов. Причём явно видно, что sysvshm в явных аутсайдерах, на втором месте sync, а shmop опережает ближайшего конкурента примерно в 2 раза по скорости записи.

Второе место у sync обуславливается издержками на инициализацию соединения, которое, как можно заметить, довольно сильно отличается от конкурентов. И вынесение этого подключения за пределы бенчмарков подтверждает это предположение:

+------------+------------+-----+--------+-----+-----------+---------+--------+
| benchmark  | subject    | set | revs   | its | mem_peak  | mode    | rstdev |
+------------+------------+-----+--------+-----+-----------+---------+--------+
| WriteBench | benchShm   |     | 100000 | 5   | 591.984kb | 0.342μs | ±6.40% |
| WriteBench | benchShmop |     | 100000 | 5   | 591.984kb | 0.174μs | ±6.78% |
| WriteBench | benchSync  |     | 100000 | 5   | 591.984kb | 0.158μs | ±1.04% |
+------------+------------+-----+--------+-----+-----------+---------+--------+

Запись через sysvshm в 2 раза медленнее ближайшего конкурента, а shmop и sync примерно на одном уровне (это не погрешности тестов, запись через sync стабильно быстрее на ~10%).

Что ж, вроде с этим разобрались. Пора призывать Кракена!

FFI

Теперь давайте посмотрим как подобное расширение работает "изнутри". Помимо чисто исследовательских целей мы можем получить и практический результат: Например, если по скорости shmop побеждает sysvshm за счёт своей легковесности, то чисто теоретически, если мы воспользуемся ffi и уберём "лишние" проверки, то получим производительность IO ещё большую, нежели при работе с sync.

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

const MEMORY_ID = 0xDEAD_BEEF;
const MEMORY_SIZE = 120;

$ffi = FFI::cdef(<<<'ANSI_C'
    // Вообще, структура и выравнивание зависят от ОС, включая 
    // всякие неточности, вроде использование `int key`, вместо `key_t key`.
    //
    // Но в данном случае, кажется, код должен работать под Debian-like x64ю
    typedef struct shmid_ds {
        struct ipc_perm {
            int key; 
            unsigned int uid;
            unsigned int gid;
            unsigned int cuid;
            unsigned int cgid;
            unsigned int mode;
            unsigned short seq;
        } shm_perm;
        int shm_segsz;
        long shm_atime;
        long shm_dtime;
        long shm_ctime;
        int shm_cpid;
        int shm_lpid;
        unsigned short shm_nattch;
        unsigned short shm_unused;
        void *shm_unused2;
        void *shm_unused3;
    } shmid_ds;
    
    int shmget(int key, size_t size, int shmflg);
    void *shmat(int shmid, const void *shmaddr, int shmflg);
    int shmctl(int shmid, int cmd, struct shmid_ds *buf);
    ANSI_C);

Использовать этот код будем по аналогии с расширением shmop:

// Создание управляемой структуры (т.е. GC PHP её потом сам соберёт)
// shmid_ds с информацией о памяти.
$shm = $ffi->new('shmid_ds');

// Выделение памяти с флагом IPC_CREAT (Аналог режима 'c' в shmop).
// const IPC_CREAT = 01000;
$id = $ffi->shmget(MEMORY_ID, MEMORY_SIZE, 0o1000);

if ($id === -1) {
    throw new LogicException('Unable to attach or create shared memory segment');
}

// Получение информации о памяти и запись в структуру $shm
// const IPC_STAT = 2;
$result = $ffi->shmctl($id, 2, FFI::addr($shm));

if ($result !== 0) {
    throw new LogicException('Unable to get shared memory segment information');
}

// Получаем адрес выделенной памяти.
// Если третьим аргументом передать SHM_RDONLY (010000), то это будет
// аналогом режима "a" в функции shmop_open.
$addr = $ffi->shmat($id, null, 0);

Кажется не сложно, да? Да, но это всего лишь создание подключения. Теперь реализуем запись. Тут всё намного проще, достаточно просто скопировать память по нужному адресу.

// Записываем числа 42 и 23 с запаковкой
FFI::memcpy($addr + 1 * 8, pack('q', 42), 8);
FFI::memcpy($addr + 2 * 8, pack('q', 23), 8);

Хорошо, мы увидели как оно физически устроено и какие особенности каждый из вариантов предоставляет, так что теперь посмотрим насколько эффективен каждый из вариантов.

+------------+------------+-----+--------+-----+-----------+---------+--------+
| benchmark  | subject    | set | revs   | its | mem_peak  | mode    | rstdev |
+------------+------------+-----+--------+-----+-----------+---------+--------+
| WriteBench | benchShm   |     | 100000 | 5   | 591.984kb | 0.341μs | ±1.04% |
| WriteBench | benchShmop |     | 100000 | 5   | 591.984kb | 0.175μs | ±0.69% |
| WriteBench | benchSync  |     | 100000 | 5   | 591.984kb | 0.160μs | ±5.00% |
| WriteBench | benchFFI   |     | 100000 | 5   | 591.984kb | 0.151μs | ±0.41% |
+------------+------------+-----+--------+-----+-----------+---------+--------+

Признайтесь, вы удивлены? FFI показывает выше стабильность и скорость записи, нежели альтернативы (ну если не учитывать, что это микробенчмарки, которые могут зависеть от окружения, jit, фазы луны и с какой ноги встал с кровати...). Но если посмотреть исходный код, то результат вполне ожидаем. Перед вызовом memcpy в расширении shmop производится ещё 3 проверки и пара преобразований/вычислений, которые мы просто убрали из бенчмарков. С другой стороны, разница в 10-25μs на 15 циклов записи настолько незначительна, что ей можно просто пренебречь.

Если есть желание, что с результатами тестирования вы можете ознакомиться и поэкспериментировать самостоятельно.

Вместо Заключения

В любом случае, подключение к памяти хоть и одноразовое, но всё же требуется и FFI, даже если исключить "лишние" аллоцирования и проверки показывает результаты в целом хуже, нежели нативное расширение shmop, хоть и не значительные. Но довольно значительные издержки на поддержку подобного кода в будущем всё же минус.

Работа же через sysvshm является самым медленным вариантом работы из-за сериализации и десериализации всего сегмента памяти.

Расширение же sync не даёт никаких особых преимуществ, кроме более современного API. Но при этом его придётся ставить отдельно из PECL. Возможно поэтому оно не столь популярное, как shmop? Кто знает.

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


  1. pae174
    17.09.2025 21:09

    Есть еще APCU, который по сути бывший APC, которому отрезали опкэш и оставили только хранение данных.


    1. SerafimArts Автор
      17.09.2025 21:09

      Точно, мой косяк. Проблема только в том, что APCu по умолчанию использует mmap (https://www.php.net/manual/ru/apcu.configuration.php) и для System V его нужно собирать отдельно.

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