Межпроцессное взаимодействие

Управление памятью | Механизм системных вызовов

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

PostgreSQL использует модель «процесс на соединение» — postmaster порождает worker-процессы через fork(), каждый обслуживает клиентское подключение. Сотни процессов работают с общим буферным пулом, координируют доступ к одним и тем же страницам данных, обмениваются сигналами о готовности. Все механизмы IPC (Inter-Process Communication, межпроцессная коммуникация), которые разберём дальше, видны в архитектуре PostgreSQL.

Копирование vs разделение

Pipe и Unix domain socket передают данные через ядро: процесс-отправитель вызывает write(), данные копируются из пользовательского пространства в буфер ядра, процесс-получатель вызывает read(), данные копируются из буфера ядра в его пользовательское пространство. Два копирования на каждую передачу.

Процесс A                    Ядро                      Процесс B
┌──────────┐   write()   ┌──────────────┐   read()   ┌──────────┐
|  буфер   | ----------> | буфер ядра   | ----------> |  буфер   |
|  4 КБ    |   copy #1   | (pipe/socket)|   copy #2  |  4 КБ    |
└──────────┘             └──────────────┘             └──────────┘

Для коротких сообщений — команд, уведомлений, строк результатов — двойное копирование незаметно. Но PostgreSQL выделяет под shared_buffers десятки гигабайт: при shared_buffers = 128 ГБ передача буферного пула через pipe потребовала бы 256 ГБ копирований. Это физически невозможно для данных, с которыми сотни процессов работают одновременно и непрерывно.

Разделяемая память через mmap решает проблему иначе: несколько процессов отображают один и тот же физический регион в свои адресные пространства. Каждый процесс обращается к данным напрямую, без вызовов read()/write() и без участия ядра на горячем пути. Ноль копирований.

Процесс A                                     Процесс B
┌──────────────────┐                           ┌──────────────────┐
| vaddr 0x7f...000 |                           | vaddr 0x7f...800 |
└────────┬─────────┘                           └────────┬─────────┘
         |         page table A                         |
         +-----------> ┌────────────┐ <-----------------+
                        | физические |    page table B
                        | фреймы RAM |
                        | (128 ГБ)   |
                        └────────────┘

PostgreSQL при запуске создаёт сегмент разделяемой памяти и отображает его во все worker-процессы. Каждый worker читает и пишет страницы буферного пула напрямую, без системных вызовов. Но zero-copy порождает новую проблему: когда два процесса одновременно модифицируют один буфер — данные повреждаются. Нужна координация.

Семафоры: координация доступа к разделяемой памяти

Мьютекс (pthread_mutex_t) защищает критическую секцию: только один поток может владеть мьютексом в любой момент. В отображении памяти мы видели PTHREAD_PROCESS_SHARED мьютекс, размещённый в разделяемой памяти. Этого достаточно для простых случаев, но мьютекс — это бинарный инструмент: «занят» или «свободен».

Семафор (semaphore) обобщает мьютекс до счётчика. Значение семафора показывает, сколько единиц ресурса доступно. sem_wait() (ждать) уменьшает счётчик на 1: если значение было больше нуля — поток проходит, если ноль — блокируется до тех пор, пока кто-то не вызовет sem_post(). sem_post() (сигнализировать) увеличивает счётчик на 1 и будит одного ожидающего.

Разница с мьютексом принципиальна: мьютекс привязан к владельцу — только захвативший поток может его освободить. Семафор — безличный счётчик: один процесс может вызвать sem_wait(), другой — sem_post(). Это делает семафоры инструментом для сценариев «producer-consumer» (производитель-потребитель) между процессами.

PostgreSQL использует семафоры для ожидания лёгких блокировок (lightweight locks, LWLock). Когда worker хочет модифицировать буферную страницу, он пытается захватить LWLock. Если блокировка свободна — захват происходит через атомарную операцию CAS (Compare-And-Swap) без участия ядра, за десятки наносекунд. Если блокировка занята — worker добавляет себя в очередь ожидания и засыпает на sem_wait(). Когда владелец освобождает блокировку, он вызывает sem_post() для первого ожидающего, и тот просыпается. Семафор здесь не защищает ресурс напрямую — он служит механизмом ожидания: быстрый путь (fast path) обходится без семафора, медленный путь (contention path) использует его для усыпления и пробуждения.

Помимо sem_wait() и sem_post(), POSIX (Portable Operating System Interface) определяет sem_trywait() — неблокирующую попытку захвата. Если значение семафора больше нуля, sem_trywait() уменьшает его и возвращает 0. Если ноль — немедленно возвращает ошибку EAGAIN вместо блокировки. Это полезно, когда процесс может сделать другую работу вместо ожидания. sem_timedwait() блокируется с тайм-аутом — если за указанное время семафор не стал доступен, возвращается ETIMEDOUT.

Именованные и неименованные семафоры

POSIX определяет два вида семафоров. Именованный семафор создаётся через sem_open() с именем в файловой системе (обычно в /dev/shm). Аргумент mode (0600) задаёт права доступа — кто может открыть этот семафор:

#include <semaphore.h>
 
// Создать или открыть семафор с начальным значением 1
sem_t *sem = sem_open("/pg_buffer_lock", O_CREAT, 0600, 1);
 
sem_wait(sem);        // захватить: значение 1 -> 0
// ... работа с разделяемой памятью ...
sem_post(sem);        // освободить: значение 0 -> 1
 
sem_close(sem);       // закрыть в текущем процессе
// sem_unlink("/pg_buffer_lock");  -- удалить объект (вызывает владелец жизненного цикла)

Именованный семафор доступен любому процессу, знающему имя. Жизненный цикл похож на POSIX shared memory: объект существует до явного sem_unlink() или перезагрузки.

Неименованный семафор (sem_init()) размещается в памяти — либо в стеке/куче (для потоков одного процесса), либо в разделяемой памяти (для разных процессов):

#include <semaphore.h>
#include <sys/mman.h>
 
// Семафор в разделяемой памяти (из 01-memory-mapping.md)
sem_t *sem = mmap(NULL, sizeof(sem_t),
                  PROT_READ | PROT_WRITE,
                  MAP_SHARED | MAP_ANONYMOUS, -1, 0);
 
sem_init(sem, 1, 1);  // pshared=1: межпроцессный; начальное значение=1
 
sem_wait(sem);
// ... критическая секция ...
sem_post(sem);
 
sem_destroy(sem);
munmap(sem, sizeof(sem_t));

Второй аргумент sem_init()pshared: 0 означает использование между потоками одного процесса, 1 — между разными процессами. При pshared=1 семафор должен лежать в памяти, доступной всем участникам, то есть в MAP_SHARED регионе.

PostgreSQL исторически использовал оба подхода: именованные семафоры на платформах, где System V семафоры ненадёжны, и неименованные семафоры в разделяемой памяти — на Linux.

POSIX очереди сообщений

Разделяемая память с семафорами даёт максимальную производительность, но требует от процессов самостоятельно управлять форматом данных, буферами и синхронизацией. Когда процессам нужно обмениваться отдельными сообщениями, а не работать с общим буфером, POSIX предоставляет более высокоуровневый механизм — очереди сообщений (message queues).

mq_open() создаёт именованную очередь. mq_send() помещает сообщение, mq_receive() извлекает. Каждое сообщение — массив байтов с приоритетом: сообщения с более высоким приоритетом извлекаются первыми.

#include <mqueue.h>
#include <fcntl.h>
#include <string.h>
 
struct mq_attr attr = {
    .mq_maxmsg = 10,       // максимум 10 сообщений в очереди
    .mq_msgsize = 256       // максимальный размер сообщения (байт)
};
 
mqd_t mq = mq_open("/task_queue", O_CREAT | O_RDWR, 0600, &attr);
 
// Отправка с приоритетом 1
const char *msg = "checkpoint_request";
mq_send(mq, msg, strlen(msg) + 1, 1);
 
// Приём: сообщение с наивысшим приоритетом извлекается первым
char buf[256];
unsigned int prio;
mq_receive(mq, buf, sizeof(buf), &prio);
// buf = "checkpoint_request", prio = 1
 
mq_close(mq);
// mq_unlink("/task_queue");  -- удаление объекта

Компиляция: gcc sender.c -o sender -lrt.

Очередь поддерживает уведомления: mq_notify() регистрирует сигнал или поток, который будет вызван при поступлении сообщения в пустую очередь. Это позволяет обойтись без постоянного опроса.

Внутри ядра очередь реализована как буфер в RAM. Лимиты настраиваются через /proc/sys/fs/mqueue/: msg_max (максимальное число сообщений на очередь, по умолчанию 10), msgsize_max (максимальный размер одного сообщения, по умолчанию 8192 байт), queues_max (максимальное число очередей в системе). При попытке создать очередь с параметрами, превышающими лимиты, mq_open() вернёт EINVAL.

Когда буфер очереди полон, mq_send() блокируется, пока получатель не извлечёт сообщение — встроенный backpressure (обратное давление — замедление производителя при переполнении), аналогичный заполнению буфера pipe. mq_timedsend() позволяет ограничить время ожидания.

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

System V IPC: устаревший, но живой

До появления POSIX IPC единственным стандартным способом межпроцессного взаимодействия в Unix был System V IPC, появившийся в AT&T Unix System V (1983). Три механизма: разделяемая память (shmget/shmat), семафоры (semget/semop) и очереди сообщений (msgget/msgsnd/msgrcv).

API отличается от POSIX принципиально. Вместо файловых дескрипторов и имён — числовые ключи и идентификаторы:

#include <sys/ipc.h>
#include <sys/shm.h>
 
// Создать ключ из пути и id
key_t key = ftok("/tmp/pg_shmem", 42);
 
// Создать/открыть сегмент разделяемой памяти
int shmid = shmget(key, 128UL * 1024 * 1024 * 1024,
                   IPC_CREAT | 0600);
 
// Присоединить к адресному пространству
void *ptr = shmat(shmid, NULL, 0);
 
// ... работа с памятью ...
 
// Отсоединить
shmdt(ptr);
 
// Удалить (отложенное — после отсоединения всех процессов)
shmctl(shmid, IPC_RMID, NULL);

PostgreSQL до версии 12 использовал System V shared memory (shmget) для выделения shared_buffers. Начиная с версии 12 основной механизм — POSIX mmap с MAP_SHARED | MAP_ANONYMOUS, хотя System V сегмент минимального размера по-прежнему создаётся для обнаружения других экземпляров PostgreSQL на том же порту.

Причины перехода на POSIX API:

System V объекты не представлены файловыми дескрипторами. Это означает, что select(), poll(), epoll не могут ожидать события на System V семафоре или очереди — нельзя интегрировать IPC в event loop. POSIX очереди сообщений возвращают дескриптор (mqd_t), совместимый с epoll.

Жизненный цикл System V объектов привязан к ядру, а не к процессу: сегмент разделяемой памяти существует, пока его явно не удалят через shmctl(IPC_RMID) или ipcrm. Если процесс завершился аварийно — объект остаётся. Утилита ipcs показывает все существующие объекты:

$ ipcs -m
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch
0x002a0052 32769      postgres   600        56         5

POSIX shared memory (shm_open) тоже требует явного shm_unlink, но объекты видны в файловой системе /dev/shm, а mmap MAP_ANONYMOUS не оставляет артефактов вовсе — при завершении всех процессов, использующих маппинг, память освобождается автоматически.

Лимиты System V настраиваются через sysctl: kernel.shmmax (максимальный размер одного сегмента), kernel.shmall (суммарное количество страниц разделяемой памяти), kernel.sem (параметры массивов семафоров: максимум семафоров на массив, максимум массивов, максимум операций на вызов semop, суммарный максимум семафоров).

На старых системах с PostgreSQL администратор часто встречал ошибку could not create shared memory segment: Invalid argumentshmmax по умолчанию был 32 МБ, недостаточно для буферного пула. Стандартная рекомендация была выставить kernel.shmmax равным объёму RAM. С переходом на mmap в PostgreSQL 12 эта проблема исчезла — mmap MAP_ANONYMOUS не зависит от System V лимитов.

Ещё одно принципиальное ограничение System V: пространство имён глобально для всей системы. Ключ ftok() генерируется из пути к файлу и проектного идентификатора, но коллизии возможны. Два независимых приложения могут случайно получить одинаковый ключ и обращаться к чужому сегменту. POSIX API использует строковые имена ("/my_shm") — коллизии менее вероятны, а права доступа контролируются стандартными mode битами.

Передача файловых дескрипторов между процессами

Файловый дескриптор — индекс в таблице процесса, и у каждого процесса своя таблица. Число «3» в процессе A и число «3» в процессе B указывают на разные ресурсы. Нельзя просто отправить число через pipe и ожидать, что получатель сможет им воспользоваться.

Но Unix domain socket поддерживает механизм передачи дескрипторов через вспомогательные данные (ancillary data) с типом SCM_RIGHTS (Socket Control Message — Rights, передача прав). Ядро при получении такого сообщения создаёт в таблице дескрипторов получателя новую запись, указывающую на тот же open file description, что и у отправителя.

Процесс A (отправитель)            Ядро                Процесс B (получатель)
┌────────────────┐                                     ┌────────────────┐
| fd 5 -> [desc] |-- sendmsg() -->  ядро создаёт  --> | fd 3 -> [desc] |
└────────────────┘   SCM_RIGHTS     новый fd в B       └────────────────┘
                                    указывающий на
                                    тот же open file
                                    description

Отправитель использует sendmsg() с управляющим сообщением:

#include <sys/socket.h>
#include <sys/un.h>
 
void send_fd(int unix_sock, int fd_to_send) {
    struct msghdr msg = {0};
    struct iovec iov;
    char buf[1] = {'F'};  // хотя бы один байт данных обязателен
 
    iov.iov_base = buf;
    iov.iov_len = 1;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
 
    // Вспомогательные данные: один файловый дескриптор
    char cmsgbuf[CMSG_SPACE(sizeof(int))];
    msg.msg_control = cmsgbuf;
    msg.msg_controllen = sizeof(cmsgbuf);
 
    struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));
    memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int));
 
    sendmsg(unix_sock, &msg, 0);
}

Получатель извлекает дескриптор из recvmsg():

int receive_fd(int unix_sock) {
    struct msghdr msg = {0};
    struct iovec iov;
    char buf[1];
 
    iov.iov_base = buf;
    iov.iov_len = 1;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
 
    char cmsgbuf[CMSG_SPACE(sizeof(int))];
    msg.msg_control = cmsgbuf;
    msg.msg_controllen = sizeof(cmsgbuf);
 
    recvmsg(unix_sock, &msg, 0);
 
    struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
    int received_fd;
    memcpy(&received_fd, CMSG_DATA(cmsg), sizeof(int));
    return received_fd;
}

Номер дескриптора у получателя, как правило, отличается от номера у отправителя — ядро выбирает наименьший свободный слот в таблице получателя. Но open file description (смещение, режим, флаги) — один и тот же.

nginx использует этот механизм при горячем обновлении (hot upgrade). Master-процесс старой версии открывает слушающий сокет на порту 80/443 и передаёт его через Unix domain socket новому master-процессу. Новый процесс принимает дескриптор и начинает обслуживать входящие соединения, не прерывая трафик — порт не нужно закрывать и повторно привязывать.

systemd использует тот же принцип для socket activation (активация по сокету): systemd создаёт сокеты до запуска сервиса и передаёт дескрипторы через переменные окружения LISTEN_FDS и LISTEN_PID. Сервис получает уже открытые и привязанные сокеты — не нужно вызывать bind() и listen() самому. Это позволяет запускать сервис по первому входящему соединению: systemd слушает порт, при поступлении запроса стартует сервис и передаёт ему сокет.

Механизм SCM_RIGHTS передаёт не только сокеты — любой файловый дескриптор: обычный файл, pipe, eventfd, timerfd, даже другой Unix domain socket. Можно передать несколько дескрипторов в одном сообщении, упаковав массив int в CMSG_DATA. Ограничение — SCM_RIGHTS работает только через Unix domain socket, не через TCP/UDP и не через pipe.

Выбор механизма IPC

Каждый механизм преодолевает изоляцию процессов по-своему, с разными компромиссами. Выбор определяется тремя факторами: объём передаваемых данных, требования к задержке и сложность реализации.

Разделяемая память (mmap MAP_SHARED, POSIX shm_open) — максимальная пропускная способность и минимальная задержка: процессы работают с данными напрямую, без системных вызовов. Цена — необходимость синхронизации (семафоры, мьютексы) и сложность отладки гонок. Сценарий: буферный пул PostgreSQL (128 ГБ, сотни процессов), кеш-память между процессами.

Unix domain socket — надёжный двунаправленный канал между процессами на одной машине, 2—5 мкс на сообщение. Поддерживает передачу файловых дескрипторов (SCM_RIGHTS). Интегрируется с epoll/io_uring. Сценарий: клиент-серверный обмен (PostgreSQL принимает локальные соединения на /var/run/postgresql/.s.PGSQL.5432), координация (Docker daemon).

Pipe — простейший механизм: однонаправленный поток байтов с буфером 64 КБ в ядре. Не требует адреса или имени — создаётся одним pipe() и наследуется через fork(). Ограничение — только между родственными процессами (родитель-потомок). Сценарий: конвейеры shell, передача данных от worker к агрегатору.

POSIX очередь сообщений — структурированные сообщения с приоритетами, без необходимости устанавливать соединение. Дескриптор совместим с epoll. Ограничение — фиксированный размер сообщения, задаваемый при создании. Сценарий: координация демонов, приоритетная диспетчеризация задач.

По латентности и пропускной способности механизмы располагаются в таком порядке: разделяемая память (наносекунды, гигабайты в секунду) > Unix socket (~2—5 мкс, гигабайты в секунду) > pipe (~2—5 мкс, ограничен буфером 64 КБ) > очередь сообщений (~5—10 мкс, ограничена размером сообщения).

Сложность реализации растёт в обратном направлении: pipe проще всего (два вызова — pipe() и fork()), Unix socket требует адреса и цикла accept/connect, очередь сообщений — настройки атрибутов и управления жизненным циклом, разделяемая память — собственной синхронизации и аккуратного обращения с памятью. Отладка гонок в разделяемой памяти на порядок сложнее, чем отладка обмена сообщениями: при копировании данных через pipe ошибка приводит к неправильному значению в одном процессе, при гонке в разделяемой памяти — к повреждению структуры данных, видимому всем участникам.

На практике эти механизмы часто комбинируются. PostgreSQL использует разделяемую память для буферного пула и блокировок, POSIX семафоры для ожидания блокировок, сигналы (SIGUSR1, SIGUSR2) для уведомления worker-процессов об изменениях, и Unix domain socket для клиентских подключений. Каждый инструмент решает ту задачу, для которой он подходит лучше всего.

Sources


Управление памятью | Механизм системных вызовов