LINUX.ORG.RU

Simplerpc (libsrpc): Модель владения памятью

 , , ,


0

2

В предыдущей статье Simplerpc: Простой RPC на Си для Linux-приложений было краткое знакомство с framework-ом, в этой подробнее описана используемая в нём модель владения разделяемой памятью.

При использовании несколькими приложениями аллокатора из общего пула разделяемой памяти возникает несколько задач, которые можно по-разному решать. Пул отображается во все процессы по одному и тому же виртуальному адресу, поэтому указатель, полученный в одном процессе, остаётся валидным в другом, и данные между процессами можно передавать вообще без копирования. Обратная сторона такого удобства в том, что любой из процессов может в любой момент «умереть» — по exit(), SIGKILL или segfault — и оставить после себя либо недоделанную операцию с аллокатором, либо выделенные блоки, которые больше никому не нужны.

Первая задача решается самим аллокатором. В Simplerpc используется транзакционный аллокатор TLSF с robust-mutex, позволяющий откатить любую транзакцию (malloc, free, итп) в случае внезапной «смерти» процесса: перед каждой записью в метаданные пула старое значение сохраняется в журнал отката, и следующий процесс, захвативший мьютекс и получивший EOWNERDEAD, возвращает пул в состояние до начала прерванной операции. Внешний контроль для этого не нужен.

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

Сама модель владения устроена довольно просто. В заголовке каждого блока есть массив владельцев на двенадцать UID, libsrpc_shmem_malloc() записывает туда UID выделившего процесса, а в пул блок возвращается только тогда, когда из массива вычеркнуты все владельцы. libsrpc_shmem_link() добавляет в массив UID вызывающего процесса, а libsrpc_shmem_free() вычёркивает только UID того, кто его вызвал, и если после этого владельцы ещё остались, блок остаётся выделенным. Отсюда получается немного хитрый способ передать блок другому процессу без копирования данных: владелец отдаёт указатель соседу, сосед делает link, после чего прежний владелец делает free и «отцепляется» от блока, а данные остаются лежать там же, где и лежали, но принадлежат уже соседу.

Всё это продемонстрировано в примере ./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 завершается.

Ограничения те же, что и у всего Simplerpc: только Linux, только процессы на одной машине, ранняя стадия развития, API ещё меняется. Блокировка от GC не спасает от чужого free, указатели имеют смысл, только если адресуют разделяемый пул, владельцев у блока не больше двенадцати, а удержанных UID на один запрос — не больше четырёх.

Репозиторий: github.com, лицензия Apache-2.0. Пример целиком — в example/list/.

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



Проверено: hobbit ()
Последнее исправление: hobbit (всего исправлений: 1)

Меня слегка напрягло, что под прошлой статьёй был только один авторский комментарий, при том, что вопросов было много…

hobbit ★★★★★
()
Ответ на: комментарий от hobbit

Вопрос там был один, остальное троллинг.

dsn76
() автор топика
Для того чтобы оставить комментарий войдите или зарегистрируйтесь.