- PVSM.RU - https://www.pvsm.ru -
Терминал один: ./log. Терминал два: ./clc. Терминал три: ./app. В выводе первого появляется LOG("Hello, world, from app! my pid=..."). Вызовы log_write("Hello") и calc_add(1, 2) в app — обычные С-функции, но выполняются они в процессах log и clc. Вся сериализация параметров функций скрыта в X-макросах, вручную это делать не нужно.
Для использования в своих приложениях: подключить в исходники #include "libsrpc.h", описать прототипы экспортируемых функций в ./src/libsrpc_rpc_functions.h (это часть исходников самой библиотеки, а не отдельный публичный API-заголовок), пересобрать libsrpc.so и слинковать её в своё приложение с флагом -Wl,--no-as-needed. Подробнее — в ./example.
Есть классическая проблема: изолированные процессы — хорошо для надёжности, но хочется иногда вызвать функцию из другого процесса, как будто она лежала в той же библиотеке. Стандартные IPC — это всегда протокол: описал интерфейс, сгенерировал стабы, поднял сервер, прописал адреса. Simplerpc предлагает путь короче: пишешь обычную функцию на Си, линкуешь библиотеку — и она автоматически становится RPC-методом, доступным другим процессам на той же машине.
Архитектура держится на трёх решениях.
Во-первых, данные едут не через сокет, а по разделяемой памяти — SHMEM. Пул отображается во все процессы по одному и тому же виртуальному адресу, поэтому указатель, полученный в одном процессе, остаётся валидным в другом, и данные между процессами можно передавать вообще без копирования. Сокет используется только для сигналов: регистрация функций, передача дескриптора памяти (SCM_RIGHTS), обнаружение смерти клиента.
Во-вторых, свой аллокатор — транзакционный TLSF с robust-mutex, встроенным прямо в структуру пула. Обратная сторона удобства единого адресного пространства в том, что любой из процессов может в любой момент “умереть” — по exit(), SIGKILL или segfault — и оставить после себя либо недоделанную операцию с аллокатором, либо блоки, которые больше никому не нужны. Первая часть этой проблемы решается самим аллокатором: перед каждой записью в метаданные пула старое значение сохраняется в журнал отката, и следующий процесс, захвативший мьютекс и получивший EOWNERDEAD, возвращает пул в состояние до начала прерванной операции. Внешний контроллёр для этого не нужен, пул консистентен сам по себе.
В-третьих, вместо стандартных примитивов синхронизации — POSIX-семафоры в разделяемой памяти и lock-free MPMC-очередь для передачи задач от клиента к исполнителям.
Вторая часть проблемы — утечки блоков, оставшихся после умершего владельца, — решается отдельным механизмом владения и сборкой мусора; ему посвящён отдельный раздел ниже.
Самое интересное — как демон оказывается в системе. Исполняемый файл демона не лежит на диске. Он встроен в libsrpc.so в виде C-массива, записывается в анонимный memfd целиком в оперативной памяти и запускается через fexecve(). При загрузке библиотеки любым приложением демон форкается автоматически. Если процессов несколько — проигравший гонку bind() на абстрактном Unix-сокете молча завершается. Когда последний клиент отключается — демон выходит, а память исчезает вместе с последней ссылкой на memfd. Никакого PID-файла, никакого init-скрипта, никакого сервиса. Библиотека сама себя разворачивает.
При использовании несколькими приложениями аллокатора из общего пула возникает задача: как гарантировать, что блок, на который ссылается один процесс, не будет освобождён из-под него другим процессом или демоном — в том числе если владелец блока внезапно “умрёт”.
Для предотвращения утечек, возникающих из-за смерти процесса, реализован сборщик мусора (GC), возвращающий в пул блоки, выделенные умершим процессом. Каждый блок в пуле помечен UID процесса-владельца, а демон узнаёт о смерти клиента по обрыву Unix-сокета, после чего обходит кучу по UID умершего и освобождает его пользовательские блоки. Служебные блоки запросов так сразу не освобождаются, потому что их ещё может читать исполнитель в другом процессе: они помечаются на отложенную очистку и достаются GC только тогда, когда ни один поток их больше не держит.
Сама модель владения устроена просто. В заголовке каждого блока есть массив владельцев на двенадцать UID. libsrpc_shmem_malloc() записывает туда UID выделившего процесса, а в пул блок возвращается только тогда, когда из массива вычеркнуты все владельцы. libsrpc_shmem_link() добавляет в массив UID вызывающего процесса, а libsrpc_shmem_free() вычёркивает только UID того, кто его вызвал — если после этого владельцы ещё остались, блок остаётся выделенным. Отсюда получается способ передать блок другому процессу без копирования данных: владелец отдаёт указатель соседу, сосед делает link, после чего прежний владелец делает free и “отцепляется” от блока, а данные остаются лежать там же, где и лежали, но принадлежат уже соседу.
Такая “чистоплотность” GC вынуждает решать ещё одну задачу — гарантированный доступ к выделенным блокам чужого процесса, ведь он может внезапно завершиться во время обращения к его данным, и демон освободит блок прямо из-под читающего. Для этого в версии 0.3.0 добавлен механизм блокировки блоков от освобождения их GC в случае завершения другого процесса.
После RPC-вызова вызывающий процесс может выполнить libsrpc_shmem_proc_lock(idx, ptr): библиотека берёт исполнителя, ответившего в слоте idx последнего запроса, проверяет, что блок ptr действительно принадлежит ему, и записывает его UID в тело запроса. Пока UID там записан, пользовательские блоки этого процесса после его “смерти” в пул не возвращаются, а складываются в список GC и ждут снятия блокировки через libsrpc_shmem_proc_unlock() или libsrpc_shmem_proc_all_unlock(). Блокировка вешается на процесс целиком, а не на один указатель, поэтому достаточно предъявить любой его блок, чтобы защитить и все остальные. На один запрос можно удержать до четырёх UID.
Удержание привязано к потоку, а не к процессу: UID записывается в блок запроса, который библиотека хранит в thread-local переменной и переиспользует для всех RPC-вызовов этого потока. Поэтому снять удержание может только тот поток, который его взял, а соседний поток того же процесса работает со своим блоком запроса и чужих удержаний не видит. Последующие RPC-вызовы потока удержание не сбрасывают, оно остаётся в силе до явного unlock, но idx в libsrpc_shmem_proc_lock() всегда относится к последнему запросу, так что исполнителя нужно удерживать сразу после нужного вызова, пока следующий вызов не перезаписал слоты ответов.
При этом удержание умирает вместе с держателем. Решая, можно ли уже вернуть в пул блоки умершего процесса, демон смотрит только запросы живых процессов, а блок запроса потока освобождается при завершении самого потока. Если держатель “умрёт” или просто завершит поток, забыв снять удержание, отложенные блоки на ближайшем проходе GC всё равно вернутся в пул, и забытый unlock не превращается в вечную утечку.
Этот механизм не защищает от освобождения блока чужим процессом по free, поэтому “живые” процессы должны договариваться о механизме владения памятью, например через robust-mutex, размещённый в той же разделяемой памяти рядом с данными.
Всё это продемонстрировано в примере ./example/list/, где разделяемый между процессами двусвязный список хранит строки. Пример состоит из двух программ: list_db определяет у себя RPC-функцию list_api() и тем самым становится её исполнителем, а list_cli эту функцию не определяет, поэтому её обычный вызов уходит по RPC в list_db. Функция принимает код операции и указатель: DAL_LIST_OP_GET_HEAD возвращает голову списка, DAL_LIST_OP_INSERT вставляет узел, DAL_LIST_OP_REMOVE удаляет его.
При старте list_db проверяет через libsrpc_fnreg_num_get(), не зарегистрирован ли уже другой исполнитель list_api(), и если да — завершается, так как список в примере один. Затем выделяет голову списка в разделяемой памяти и инициализирует в ней robust-mutex с атрибутом PTHREAD_PROCESS_SHARED, через который все процессы договариваются о доступе к ссылкам списка. Голова принадлежит list_db, поскольку выделил её он, после чего процесс просто ждёт вызова all_exit().
Добавление строки (--add) показывает передачу владения узлом:
list_cli_node_t *node = libsrpc_shmem_malloc(sizeof(*node) + len + 1);
/* заполнить size и data */
void *ptr = list_api(DAL_LIST_OP_INSERT, node);
libsrpc_shmem_free(node);
list_cli выделяет узел в пуле, записывает в него строку и передаёт указатель в list_api(). На стороне list_db вставка начинается с libsrpc_shmem_link(node), после чего у узла два владельца, и только потом узел под мьютексом вставляется в список. Вернувшись из RPC-вызова, list_cli делает libsrpc_shmem_free(node), но блок при этом не освобождается, а лишь теряет одного из владельцев, и узел остаётся в списке, принадлежа теперь только list_db. Если list_cli после этого завершится, его “смерть” на список никак не повлияет.
Печать списка (--print) показывает блокировку от GC. list_cli получает голову списка вызовом list_api(DAL_LIST_OP_GET_HEAD, NULL), и поскольку пул во всех процессах отображён по одному адресу, по этому указателю можно сразу читать. Но перед этим list_cli вызывает libsrpc_shmem_proc_lock(0, list_head), удерживая UID исполнителя из единственного слота ответа. Библиотека проверит, что голова действительно принадлежит list_db, и с этого момента все пользовательские блоки list_db, включая переданные ему узлы, переживут его внезапную “смерть” до тех пор, пока list_cli не снимет блокировку. Дальше list_cli захватывает мьютекс списка, обходит узлы, печатает строки, отпускает мьютекс и вызывает libsrpc_shmem_proc_unlock().
Удаление (--del) ищет строку в том же порядке — голова, блокировка, мьютекс, обход, — и найденный узел передаёт в list_api(DAL_LIST_OP_REMOVE, node). В list_db узел вынимается из списка и освобождается по libsrpc_shmem_free(), и поскольку list_db к этому моменту его единственный владелец, блок возвращается в пул.
Запускается пример так же, как и остальные, демон поднимается автоматически первым же процессом, слинкованным с libsrpc.so. Терминал один: ./build/example/list_db, в нём будут видны приходящие вызовы list_api. Терминал два — клиент, новые узлы вставляются в начало списка, поэтому печатаются в обратном порядке:
$ ./build/example/list_cli --add hello --add world --print
size: 30, data: world
size: 30, data: hello
$ ./build/example/list_cli --del hello --print
size: 30, data: world
$ ./build/example/all_exit
Последняя команда рассылает all_exit() всем, кто его определил, и list_db завершается.
Библиотека организована так: guard daemon (фоновый координатор), транспорт (Unix-сокеты и SCM_RIGHTS), аллокатор TLSF с транзакциями, сборщик мусора в разделяемой памяти с hazard pointers, RPC через X-макросы (один файл — единственный источник истины), динамическая линковка через weak alias, дескрипторы процессов и потоков, lock-free очереди и синхронизация. В bench/ можно найти бенчмарки, если захотите сравнить сами.
Попробовать три команды: mkdir build && cd build && cmake .. && make, затем в одном терминале ./log, в другом ./app. Готово.
Только Linux, только процессы на одной машине, ранняя стадия развития — API стабильностью не отличается. Функции с переменным числом аргументов не поддерживаются, указатели осмыслены только если адресуют разделяемый пул. Блокировка от GC не спасает от чужого free, владельцев у блока не больше двенадцати, а удержанных UID на один запрос — не больше четырёх.
github.com [1], лицензия Apache-2.0. Примеры в example/.
Расскажите, с какими сценариями работы в разделяемой между процессами памяти сталкивались, и как решали возникающие задачи.
Автор: dsn76
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/linux/459249
Ссылки в тексте:
[1] github.com: https://github.com/dsn76/simplerpc
[2] Источник: https://habr.com/ru/articles/1089496/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1089496
Нажмите здесь для печати.