Добавить в корзинуПозвонить
Найти в Дзене
Триалогия

Пишем операционную систему Триалогия - IPC между процессами: каналы, разделяемая память и сигналы

Статья про "Мост между ядром и пользователем" была о вертикальном направлении - как приложение вызывает ядро? Через kcall, кольцевой буфер , trap. Но это лишь половина истории. Оконный менеджер (WindowManager), это отдельный процесс, Сетевой менеджер (NetworkManager) тоже и им надо обмениваться данными друг с другом горизонтально, процесс с процессом. Это IPC - межпроцессное взаимодействие и у меня для него три механизма. На деле кольцо kcall из прошлой статьи само по себе лишь частный случай из них. В самом низу лежит разделяемая память. Одни и те же физические страницы в двух процессах, оба видят одни и те же байты, но без структуры. На ней строится канал. Разделяемая память плюс кольцевой буфер, плюс событие-будильник дают упорядоченный поток сообщений. А на самом верху стоит сигнал который вообще не передаёт данных, а лишь даёт толчок "что-то случилось, сделай что-нибудь". Пройдёмся по ним по порядку. Идея проста, один процесс создаёт область памяти, другой отображает те же физиче
Оглавление
Триалогия - Пишем операционную систему
Триалогия - Пишем операционную систему

IPC - как процессы говорят друг с другом

Статья про "Мост между ядром и пользователем" была о вертикальном направлении - как приложение вызывает ядро? Через kcall, кольцевой буфер , trap. Но это лишь половина истории. Оконный менеджер (WindowManager), это отдельный процесс, Сетевой менеджер (NetworkManager) тоже и им надо обмениваться данными друг с другом горизонтально, процесс с процессом. Это IPC - межпроцессное взаимодействие и у меня для него три механизма. На деле кольцо kcall из прошлой статьи само по себе лишь частный случай из них.

Три механизма, поставленные друг на друга

Три способа как процессы говорят друг с другом
Три способа как процессы говорят друг с другом

В самом низу лежит разделяемая память. Одни и те же физические страницы в двух процессах, оба видят одни и те же байты, но без структуры. На ней строится канал. Разделяемая память плюс кольцевой буфер, плюс событие-будильник дают упорядоченный поток сообщений. А на самом верху стоит сигнал который вообще не передаёт данных, а лишь даёт толчок "что-то случилось, сделай что-нибудь". Пройдёмся по ним по порядку.

Разделяемая память- фундамент

Разделяемая память
Разделяемая память

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

int shmid = shm_create(size, SHM_FLAG_PUBLIC_RO); // создать физические страницы

shm_grant(shmid, peer_pid, SHM_PERM_READ); // кому можно отобразить?

void* p = shm_map(shmid, 0); // взять в своё адресное пространство

// ... пользоваться вместе ...

shm_destroy(shmid);

shm_grant это якорь безопасности. Не каждый может отобразить любую память, процесс создатель должен сперва разрешить конкретно кому то или сделать доступной любому. Интереснее часть API, та котороя находится за ним. Назовем их механизмами безопасности.

typedef struct {

uint32_t id; // (generation << 20) | (slot+1) — против устаревших ID

pid_t owner_pid;

phyaddr* phys_pages; // все физические страницы

volatile uint32_t ref_inflight; // операции не окончена (против use-after-free)

volatile bool destroyed; // tombstone: destroy выполняется

shm_grant_entry_t grants[SHM_MAX_GRANTS]; // кому можно отображать

/* ... */

} shm_object_t;

Самая трудная задача это уборка. Что будет, если процесс A уничтожит память, пока B читает внутри неё? Use-after-free - обращение к уже освобождённой памяти. Против этого и созданы эти три слоя.

  • каждая не законченая операция "пиннит" объект через ref_inflight и shm_destroy ждёт, пока этот счётчик не станет нулём.
  • destroy ставит tombstone (destroyed), так что новые попытки отображения сразу падают по ошибке.
  • в id записывается счётчик (Generation). Когда слот переиспользуют, счётчик (Generation) растёт и старый "устаревший" ID больше не совпадает и отвергается.
  • Вдобавок есть флаг KERNEL_OWNED для памяти, которая должна пережить уничтожение своего потока-создателя, важно для каналов.

Каналы - упорядоченный поток

Анатомия канала Триалогия
Анатомия канала Триалогия

Разделяемая память сама по себе не говорит когда пришло новое сообщение. Именно это добавляет канал, он берёт разделяемую память и создает в ней два кольцевых буфера (кольцо A для отправки данных, кольцо B для получения) плюс два события для пробуждения. Получается двунаправленный упорядоченный поток сообщений. К каналу обращаются только через handle, никогда через указатель.

// handle = (slot << 24) | generation — устаревший handle отвергается

ipc_channel_handle_t ch = ipc_channel_create_h(IPC_CHANNEL_TYPE_CLIENT, peer_pid, count_a, size_a, count_b, size_b);

// послать: внутри pin (refcount увеличить), скопировать в кольцо, unpin, разбудить получателя

ipc_channel_send(ch, IPC_RING_A, data, len, msg_type, nic_id);

Тот же механизм pin, что и у разделяемой памяти, защищает и здесь, send пиннит канал, копирует сообщение в слот кольца, возвращает pin и будит событие получателя. Вариант try_send использует IRQ-безопасную функцию события, чтобы его можно было вызывать даже из обработчика прерываний. Сам кольцевой буфер в любом случае lock-free.

И здесь замыкается круг к статье про мост. Kаналы, это общий транспорт всей системы. KDP шлёт клавиатуру, мышь и консоль по каналу WindowManager, KNP передает сетевые пакеты между ядром и NetworkManager, ULES раздаёт события приложениям, а кольцо kcall каждого процесса само и есть такой канал. Чтобы буфер канала пережил сбой приложения, его разделяемая память всегда создаётся как KERNEL_OWNED.

Сигналы - асинхронный вызов

Сигналы - Триалогия
Сигналы - Триалогия

Иногда вовсе не хочется слать данные, а только сказать "прекрати действие" или "твой дочерний поток завершён». Для этого есть сигналы, и они поразительно просты. У каждого процесса есть битовая маска.

typedef struct {

uint32_t pending_signals; // битовая маска: бит N = ждёт сигнал N

signal_handler_t handlers[MAX_SIGNALS];

bool in_signal_handler;

} signal_state_t;

signal_send(pid, SIGKILL) просто ставит нужный бит в процессе-цели, мгновенно, без ожидания. Отправитель thread выполняется дальше, как ни в чём не бывало. Лишь на следующем тике таймера, прямо перед тем как процесс-цель вернётся в userspace, ядро вызывает signal_check_pending. Если бит установлен, либо вызывается зарегистрированный обработчик, либо выполняется действие по умолчанию - обычно "завершить процесс". Два сигнала нельзя перехватить: SIGKILL и SIGSTOP, они действуют всегда.

В системе это используется в трёх местах: Ctrl+C в терминале шлёт SIGINT работающей программе - "закрой окно", и WindowManager шлёт приложению SIGKILL, чтобы оно действительно завершилось. А ошибка памяти внутри становится сигналом SIGSEGV и завершает лишь виновника, а не всю систему.

С песней по жизни

IPC, это ровно тот самый медвежий угол, где я поймал не самые детские сбои, и все они крутились вокруг одного - уборки под нагрузкой. Когда окно закрывают, оконный менеджер шлёт SIGKILL, приложение уничтожается, Thread Reaper освобождает память его канала, и "горе тому", кто в этот самый миг ещё читает из неё. Весь защитный аппарат из pin-счётчиков, tombstone, ID generation и KERNEL_OWNED-памяти, это ответ на один вопрос - "как безопасно уничтожить то, что кто-то, возможно, прямо сейчас использует". Теперь это уже запустилось как надо, но цену (потраченное время и нервы) за это я заплатил. И одной мелочи ещё там нет - блокировка отдельных сигналов (blocked_signals) предусмотрена, но ещё не реализована.

Что дальше

Всё это предполагает, что процессы, которые могут передавать и принимать данные, вообще существуют. Как из файла на диске получается работающий процесс, со своим адресным пространством, загруженным кодом и первым потоком, обьясняет следующая часть - загрузчик ELF.

Было бы интересно увидеть ваши комментарии и улучшить статьи.

Предыдущая статья Содержание Следующая статья

*Система не стоит на месте, поэтому в дальнейшем тексты могут не совпадать с реальным положением