Межпроцессное взаимодействие
Предпосылки
файловые дескрипторы (pipe, dup2), отображение памяти (mmap MAP_SHARED, POSIX shared memory), сокеты (Unix domain sockets).
← Управление памятью | Механизм системных вызовов →
Виртуальная память изолирует процессы друг от друга: два процесса могут использовать один и тот же виртуальный адрес, и каждый увидит свои данные. Изоляция — фундамент безопасности и стабильности. Но эта же изоляция создаёт проблему: процессам нужно обмениваться данными.
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.
Задача: семафор как ограничитель параллелизма
Три worker-процесса обрабатывают запросы, но одновременно к внешнему API можно отправлять не более двух запросов. Семафор инициализирован значением 2.
Частая ошибка: использовать мьютекс — но мьютекс пропустит только один процесс, а нужно два.
Правильный вариант:
sem_init(sem, 1, 2). Первые два процесса проходятsem_wait()(значение 2 → 1 → 0). Третий блокируется. Когда любой из первых двух вызываетsem_post(), третий просыпается.
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 argument — shmmax по умолчанию был 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.
Задача: почему нельзя передать дескриптор через pipe?
Частая ошибка: записать число
fdчерезwrite(pipe, &fd, sizeof(fd))— получатель прочитает число, ноread(fd, ...)вернёт ошибкуEBADF(Bad file descriptor), потому что это число — индекс в таблице отправителя, а не получателя.Правильный вариант: использовать Unix domain socket с
sendmsg()/SCM_RIGHTS. Ядро при обработкеSCM_RIGHTSсоздаёт запись в таблице дескрипторов получателя, указывающую на тот же open file description. 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
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapters 45—57: IPC: https://man7.org/tlpi/
man 7 sem_overview: https://man7.org/linux/man-pages/man7/sem_overview.7.htmlman 7 mq_overview: https://man7.org/linux/man-pages/man7/mq_overview.7.htmlman 7 sysvipc: https://man7.org/linux/man-pages/man7/sysvipc.7.htmlman 3 sem_open: https://man7.org/linux/man-pages/man3/sem_open.3.htmlman 3 sem_init: https://man7.org/linux/man-pages/man3/sem_init.3.htmlman 3 mq_open: https://man7.org/linux/man-pages/man3/mq_open.3.htmlman 7 unix— Unix domain sockets,SCM_RIGHTS: https://man7.org/linux/man-pages/man7/unix.7.html- PostgreSQL source:
src/backend/storage/ipc/— реализация IPC в PostgreSQL: https://github.com/postgres/postgres/tree/master/src/backend/storage/ipc - W. Richard Stevens, Stephen A. Rago, 2013, Advanced Programming in the UNIX Environment — 3rd edition, Chapters 15, 17: https://www.pearson.com/en-us/subject-catalog/p/advanced-programming-in-the-unix-environment/P200000009506/