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

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

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

PostgreSQL использует модель «процесс на соединение» — postmaster порождает backend-процессы через [[linux/foundations/processes#как-появляются-новые-процессы-fork|fork()]], каждый обслуживает клиентское подключение. Сотни процессов работают с общим буферным пулом, координируют доступ к одним и тем же страницам данных, обмениваются сигналами (асинхронные уведомления ядра процессу) о готовности. Это типичная задача IPC (Inter-Process Communication, межпроцессная коммуникация): сотни процессов, общий буфер — как они это устраивают?

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

Самый простой ответ на вопрос об обмене данными — pipe или Unix domain socket. Но что, если речь о гигабайтах, с которыми сотни процессов работают одновременно?

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() и без участия ядра на горячем пути. Ноль копирований — zero-copy.

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

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

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

Zero-copy порождает проблему: если два процесса одновременно модифицируют один буфер — данные повреждаются. Для простой защиты подойдёт мьютекс, размещённый в разделяемой памяти (PTHREAD_PROCESS_SHARED). Но мьютекс — бинарный инструмент: «занят» или «свободен». А что, если нужно пустить одновременно не одного, а N процессов к ограниченному ресурсу? Мьютекс не подойдёт — нужен счётчик.

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

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

PostgreSQL использует семафоры для ожидания лёгких блокировок (lightweight locks, LWLock). Когда backend-процесс хочет модифицировать буферную страницу, он пытается захватить LWLock. Если блокировка свободна — захват происходит через атомарную инструкцию без перехода в ядро, за десятки наносекунд. Если блокировка занята — процесс добавляет себя в очередь ожидания и засыпает на 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.

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

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

Именованный семафор создаётся через sem_open() с POSIX-именем вида /name — это идентификатор в отдельном пространстве имён ядра, а не путь в реальной файловой системе. На Linux объект обычно виден в /dev/shm как файл sem.NAME (tmpfs), но обращаться к нему нужно именно по POSIX-имени. Аргумент 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>
 
// Семафор в разделяемой памяти
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 семафор должен лежать в любой области разделяемой памяти, доступной всем участникам: shm_open + mmap, анонимный MAP_SHARED или System V shmget.

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: устаревший, но живой

На production-серверах с legacy-кодом ipcs -m часто показывает сегменты разделяемой памяти, о существовании которых никто не знает — они пережили аварийное завершение процесса и остались в ядре. Это признак System V IPC: объекты привязаны к ядру, а не к процессу, и не исчезают сами.

System V IPC появился в AT&T Unix System V (1983) и долгое время был единственным стандартным способом межпроцессного взаимодействия в Unix. Три механизма: разделяемая память (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 до версии 9.3 использовал System V shared memory (shmget) для выделения shared_buffers. Начиная с версии 9.3 основной механизм — анонимный mmap (MAP_SHARED | MAP_ANONYMOUS), хотя System V сегмент минимального размера по-прежнему создаётся для защиты директории данных: сегмент работает маркером, что эту data directory уже использует работающий postmaster. Версия 12 добавила параметр shared_memory_type, позволяющий явно выбрать реализацию.

Почему PostgreSQL ушёл от большого System V сегмента к анонимному mmap — и почему новые программы обычно предпочитают POSIX и fd-based IPC:

System V объекты не представлены файловыми дескрипторами. Это означает, что select(), poll(), epoll не могут ожидать события на System V семафоре или очереди — нельзя интегрировать IPC в event loop (цикл ожидания событий на нескольких fd одновременно). 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 не оставляет артефактов вовсе — при завершении всех процессов, использующих маппинг, память освобождается автоматически.

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

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

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

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

Все разобранные механизмы передают данные. Но иногда нужно передать не байты, а сам доступ к уже открытому ресурсу — сокет, файл. Ни разделяемая память, ни pipe, ни очередь сообщений этого не умеют.

Файловый дескриптор — индекс в таблице процесса, и у каждого процесса своя таблица. Число «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() — расширенную форму send(), принимающую помимо данных управляющее сообщение (struct msghdr) с метаданными, в данном случае с передаваемым дескриптором:

#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 (смещение, режим, флаги) — один и тот же.

HAProxy использует передачу файловых дескрипторов при бесшовной перезагрузке (seamless reload). Старый процесс держит открытые listener-сокеты на портах 80/443. При запуске новый процесс с опцией -x <unix_socket> подключается к admin-сокету старого и по команде expose-fd listeners получает listener-дескрипторы через Unix domain socket. Новый процесс принимает уже привязанные сокеты и продолжает обслуживать соединения без повторного bind() — входящий трафик не прерывается.

Есть второй способ передать дескриптор между процессами — наследование через fork() + exec(). Он не требует SCM_RIGHTS: открытые fd переходят в дочерний процесс автоматически, если для них не выставлен флаг FD_CLOEXEC. Так работает systemd socket activation (активация по сокету): systemd создаёт сокеты до старта сервиса, затем запускает его через exec() — дескрипторы наследуются обычным образом. Переменные окружения LISTEN_FDS (сколько fd передано) и LISTEN_PID (для какого процесса) лишь сообщают сервису, где их искать: fd 3, 4, … начиная с третьего. Сервис получает уже открытые и привязанные сокеты без вызовов bind() и listen(). Это позволяет запускать сервис по первому входящему соединению: systemd слушает порт, при поступлении запроса стартует сервис и тот подхватывает сокет. SCM_RIGHTS здесь не нужен — процессы связаны через exec(), а не через независимый Unix socket.

Механизм 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). Интегрируется с [[linux/programming/io-multiplexing#epoll-регистрация-и-готовность|epoll]]/[[linux/programming/io-multiplexing#io_uring-устранение-системных-вызовов|io_uring]]. Сценарий: клиент-серверный обмен (PostgreSQL принимает локальные соединения на /var/run/postgresql/.s.PGSQL.5432), координация (Docker daemon).

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

POSIX очередь сообщений — структурированные сообщения с приоритетами, без необходимости устанавливать соединение. Дескриптор совместим с [[linux/programming/io-multiplexing#epoll-регистрация-и-готовность|epoll]]. Ограничение — фиксированный размер сообщения, задаваемый при создании. Сценарий: координация демонов, приоритетная диспетчеризация задач.

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

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

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

Sources


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