Операционные системы Системные вызовы и межпроцессное взаимодействие
0%

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

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

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

Системный вызов — это единственная легальная дверь наружу. И одновременно самая горячая точка всей системы: типичный веб-сервер делает миллионы переходов в секунду, а каждый переход стоит на два порядка дороже обычного вызова функции. Понимать, что происходит при пересечении этой границы, — значит понимать, почему ваш код упирается в потолок производительности именно там, где упирается.

Вторая половина статьи — про то, как процессы разговаривают друг с другом. Изоляция, которая делает систему надёжной, создаёт проблему: браузеру нужно передать декодированный кадр из процесса-рендерера в процесс-композитор, nginx нужно отдать запрос в php-fpm, а Postgres — синхронизировать сотни бэкендов вокруг общего буферного пула. Все механизмы IPC — это разные компромиссы между стоимостью копирования, сложностью синхронизации и тем, сколько безопасности вы готовы отдать за скорость.

Мы уже касались границы ядра в архитектурах ядер и обещали разобрать futex в процессах и планировщиках. Пора закрыть оба долга.

Часть I. Системные вызовы

Что физически происходит при переходе

Обычный вызов функции — это call: процессор кладёт адрес возврата на стек и прыгает. Привилегии не меняются, адресное пространство не меняется, стоимость — единицы наносекунд.

Системный вызов меняет уровень привилегий процессора. На x86-64 это переход из ring 3 в ring 0, и сделать его произвольным прыжком нельзя — иначе изоляция ничего не стоила бы. Процессор предоставляет ровно одну специальную инструкцию, которая одновременно поднимает привилегии и передаёт управление по адресу, заранее записанному ядром в защищённый регистр.

На x86-64 это инструкция syscall. Она:

  1. Сохраняет RIP (адрес возврата) в RCX, а RFLAGS — в R11. Никакого стека, никакой памяти — только регистры, потому что стека ядра ещё нет.
  2. Маскирует флаги по значению IA32_FMASK (в частности, гасит IF — прерывания выключены на входе).
  3. Загружает RIP из IA32_LSTAR — этот MSR ядро проинициализировало при загрузке адресом entry_SYSCALL_64.
  4. Загружает сегментные селекторы из IA32_STAR, повышая CPL до 0.

Дальше работает ядро: swapgs подменяет GS на per-CPU область ядра, оттуда достаётся указатель на стек ядра этого потока, регистры складываются в структуру pt_regs, и управление уходит в do_syscall_64(), который индексирует таблицу sys_call_table номером из RAX.

ABI Linux на x86-64 стоит выучить наизусть — он всплывает в дизассемблере, в seccomp-фильтрах, в eBPF-программах:

Роль Регистр
номер вызова rax
аргументы 1–6 rdi, rsi, rdx, r10, r8, r9
результат rax
затираются инструкцией rcx, r11

Обратите внимание на четвёртый аргумент: r10, а не rcx. В обычном SysV ABI для пользовательских функций четвёртый аргумент идёт в rcx — но syscall затирает rcx адресом возврата, поэтому ABI ядра здесь расходится с ABI языка. Именно поэтому нельзя просто «объявить сисколл как функцию».

Другие архитектуры устроены аналогично, но с другими регистрами. На aarch64 номер лежит в x8, аргументы в x0x5, инструкция — svc #0, результат — в x0. На RISC-V номер в a7, инструкция ecall.

Возврат тоже нетривиален: ядро возвращает отрицательное значение от -1 до -4095 как код ошибки. Преобразование в привычные -1 плюс errno делает libc, а не ядро:

// Голый системный вызов без libc — так это выглядит на самом деле
#include <asm/unistd.h>

static long raw_write(int fd, const void *buf, unsigned long n) {
    long ret;
    __asm__ volatile (
        "syscall"
        : "=a"(ret)                                   // выход: rax
        : "a"(__NR_write), "D"(fd), "S"(buf), "d"(n)  // вход: rax, rdi, rsi, rdx
        : "rcx", "r11", "memory"                      // затирается инструкцией
    );
    return ret;   // -EBADF == -9, а НЕ -1 с errno == EBADF
}

Три способа передать байты между процессами и число копий

Полный путь одного вызова

Ключевой момент, который стоит увидеть в этой диаграмме: системный вызов — это не обязательно блокировка, но это всегда возможность блокировки. Одна и та же строчка read() либо вернётся через 300 нс из page cache, либо снимет ваш поток с процессора на 10 мс ожидания диска. Различие невидимо в коде и колоссально в поведении.

Почему это дорого и что сделала с этим Meltdown

Голая стоимость перехода на современном x86 — примерно 50–70 нс: сохранение регистров, переключение стека, сериализация конвейера. Уже это в сотни раз дороже вызова функции.

А потом случились Meltdown и Spectre. Митигация Meltdown — KPTI (Kernel Page Table Isolation) — держит для пользовательского режима отдельный набор таблиц страниц, где ядро почти не отображено. Каждый вход и выход из ядра теперь требует записи в CR3, а это сброс части TLB. Плюс IBRS/retpoline против Spectre-v2. В сумме на уязвимых процессорах стоимость вырастает в 3–8 раз.

# Что именно включено на вашей машине
$ grep . /sys/devices/system/cpu/vulnerabilities/*
/sys/devices/system/cpu/vulnerabilities/meltdown:Mitigation: PTI
/sys/devices/system/cpu/vulnerabilities/spectre_v2:Mitigation: Retpolines; IBPB: conditional; ...
/sys/devices/system/cpu/vulnerabilities/spec_store_bypass:Mitigation: Speculative Store Bypass disabled

# Простой замер: миллион раз самый дешёвый вызов
$ cat > bench.c <<'EOF'
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
int main(void) {
    struct timespec a, b;
    clock_gettime(CLOCK_MONOTONIC, &a);
    for (int i = 0; i < 1000000; i++) syscall(SYS_getpid);  // libc кэширует getpid(), поэтому зовём напрямую
    clock_gettime(CLOCK_MONOTONIC, &b);
    printf("%.1f нс на вызов\n",
           ((b.tv_sec-a.tv_sec)*1e9 + (b.tv_nsec-a.tv_nsec)) / 1e6);
    return 0;
}
EOF
$ gcc -O2 bench.c -o bench && ./bench
441.7 нс на вызов

# Тот же бинарь с отключёнными митигациями (mitigations=off в загрузчике):
78.3 нс на вызов

Практический вывод для прикладного программиста: уменьшайте не время одного вызова, а их количество. Три стратегии, в порядке возрастания сложности: буферизация (stdio, bufio.Writer в Go), векторные вызовы (readv/writev, sendmmsg), кольцевые очереди (io_uring).

vDSO: вызов, который не пересекает границу

Некоторые операции ядро умеет делать без ядра. clock_gettime() в 99 % случаев — это просто чтение счётчика TSC и умножение на калибровочные коэффициенты. Данные для этого ядро кладёт в страницу, которую отображает read-only в каждый процесс, а код, который их читает, — в другую страницу, называемую vDSO (virtual dynamic shared object).

$ cat /proc/self/maps | tail -4
7ffd4a5f2000-7ffd4a613000 rw-p 00000000 00:00 0    [stack]
7ffd4a7e5000-7ffd4a7e9000 r--p 00000000 00:00 0    [vvar]    # данные ядра, read-only
7ffd4a7e9000-7ffd4a7eb000 r-xp 00000000 00:00 0    [vdso]    # код, исполняемый в ring 3
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]

# Что именно экспортирует vDSO
$ objdump -T /lib/modules/$(uname -r)/vdso/vdso64.so 2>/dev/null | grep LINUX
0000000000000870 g DF .text  ... LINUX_2.6  clock_gettime
0000000000000bc0 g DF .text  ... LINUX_2.6  gettimeofday
0000000000000c10 g DF .text  ... LINUX_2.6  getcpu
0000000000000d40 g DF .text  ... LINUX_2.6  clock_getres
0000000000000ba0 g DF .text  ... LINUX_2.6  time

Ускорение — примерно на порядок: 25–30 нс вместо 400+. Это не оптимизация ради оптимизации: логирование с временной меткой на каждую строку в высоконагруженном сервисе — вполне реальный профиль, где vDSO спасает несколько процентов CPU.

Важная деталь: vDSO работает не всегда. Если источник времени в системе — не TSC, а, скажем, HPET (что бывает на виртуалках с плохой конфигурацией), ядро не может отдать чтение в userspace, и clock_gettime честно уходит в syscall. Проверяйте:

$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc                              # хорошо
$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm

Идея не уникальна для Linux. FreeBSD делает то же самое через vdso_timehands в общей странице, macOS — через commpage по фиксированному адресу, Windows — через KUSER_SHARED_DATA по адресу 0x7FFE0000, откуда GetTickCount и QueryPerformanceCounter читают время без перехода в ядро.

ABI как контракт: где он стабилен, а где нет

Здесь проходит одно из самых существенных различий между семействами ОС, и оно напрямую влияет на то, как вы собираете и распространяете софт.

Linux. Номера системных вызовов и их семантика — вечный контракт. Линус формулирует это как «we do not break userspace»: бинарник, собранный под ядро 2.6, обязан работать на 6.x. Отсюда археология вроде stat/stat64/statx, четырёх версий clone и epoll_create/epoll_create1. Это позволяет статически линковать всё, включая сисколлы (так делают Go и Rust с musl), и запускать бинарник на любом ядре.

FreeBSD, NetBSD, OpenBSD. Номера стабильны внутри мажорной версии, между версиями — через слой COMPAT (COMPAT_FREEBSD12 и т. п.), который надо включать в конфиге ядра. Формально правильный способ — динамически линковаться с системной libc.

OpenBSD пошёл дальше всех: ядро проверяет откуда пришёл системный вызов. Механизм (msyscall, позже pinsyscalls) регистрирует адресные диапазоны, из которых сисколлы разрешены — фактически только код libc и ld.so. Вызов из произвольной страницы, например из ROP-цепочки на стеке, убивает процесс. Побочный эффект: языки со статической линковкой сисколлов (в том числе исторически Go) требовали специальной поддержки, чтобы вообще работать на OpenBSD.

macOS/XNU. Номера не стабильны и меняются между релизами. Apple явно запрещает прямые сисколлы: единственный поддерживаемый интерфейс — libSystem.dylib. Причём вызовы разделены на классы через старшие биты номера: BSD-вызовы (0x2000000 | n), Mach-ловушки (отрицательные номера), machdep. Статически слинкованный «Linux-стиль» бинарник на macOS просто сломается при следующем обновлении ОС — Go на darwin по этой причине ходит через libSystem.

Windows NT. Слоёв ещё больше. ReadFile из kernel32.dll — это Win32-обёртка, которая зовёт NtReadFile из ntdll.dll, и только там лежит инструкция syscall с номером из SSDT. Номера меняются буквально между сборками одной версии Windows — поэтому антивирусы и EDR-решения, которые хукают сисколлы напрямую, регулярно ломаются после обновлений. Документированной границей является Win32 API, а не сисколл.

Ошибки, которые ABI навязывает вашему коду

Три вещи, на которых спотыкаются почти все, и все три — прямое следствие того, как устроена граница ядра.

1. EINTR: вызов может прерваться сигналом. Если во время блокирующего вызова процессу пришёл сигнал с обработчиком, ядро выкидывает вас из ядра с EINTR. Флаг SA_RESTART заставляет ядро перезапустить вызов автоматически — но не все вызовы рестартуемы. Вызовы с таймаутом (poll, select, nanosleep, futex с таймаутом, clock_nanosleep) не рестартуются никогда, потому что ядро не знает, сколько времени уже истекло.

// Единственный корректный способ вызвать что-то блокирующее
ssize_t n;
do {
    n = read(fd, buf, len);
} while (n < 0 && errno == EINTR);

// Ещё коварнее — close(). На Linux дескриптор освобождён ДАЖЕ при EINTR,
// поэтому повтор close() закроет чужой fd, переиспользованный другим потоком.
// На HP-UX — наоборот. Переносимого решения нет: на Linux просто не повторяйте.
if (close(fd) < 0 && errno != EINTR) perror("close");

2. Частичные операции. write() имеет право записать меньше, чем вы просили, и вернуть успех. Для сокетов и пайпов это норма — буфер заполнился. Код write(fd, buf, n) без проверки возврата — это молчаливая потеря данных под нагрузкой.

// Обёртка, которую надо иметь в любом проекте на C
ssize_t write_all(int fd, const void *buf, size_t n) {
    const char *p = buf;
    size_t left = n;
    while (left > 0) {
        ssize_t w = write(fd, p, left);
        if (w < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        left -= w;
        p += w;
    }
    return (ssize_t)n;
}

3. errno валиден только после ошибки. Успешный вызов errno не трогает и не обнуляет — там останется мусор от прошлой неудачи. Проверяйте возвращаемое значение, и только потом читайте errno. Отдельный случай — функции вроде strtol, у которых легальный результат неотличим от ошибки: для них надо явно писать errno = 0 перед вызовом. И помните: errno — это макрос, разворачивающийся в *__errno_location(), то есть thread-local; в обработчике сигнала его надо сохранять и восстанавливать.

Наблюдение и батчинг

# Сколько времени процесс проводит в каких вызовах
$ strace -c -f -p $(pgrep -n nginx)
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 41.22    0.184203          12     15062           epoll_wait
 22.87    0.102191           3     31048           recvfrom
 18.44    0.082412           2     29981           writev
  9.13    0.040802           1     28104      2211 accept4

# strace тормозит цель в разы (каждый вызов — две остановки через ptrace).
# В проде используйте eBPF — оверхед единицы процентов:
$ sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter /comm == "nginx"/ { @[args->id] = count(); }'

# Гистограмма латентности конкретного вызова
$ sudo funclatency-bpfcc -m 'vfs_read'

# Ловим EINTR и прочие ошибки, не трогая исходники
$ sudo bpftrace -e 'tracepoint:syscalls:sys_exit_read /args->ret < 0/ { @err[-args->ret] = count(); }'

Радикальный способ снизить число переходов — io_uring. Вместо «один вызов на одну операцию» процесс и ядро делят два кольцевых буфера в общей памяти: процесс кладёт запросы (SQE) в очередь отправки, ядро складывает результаты (CQE) в очередь завершения.

Устройство колец io_uring

// Скелет на liburing: 256 операций чтения — и один вход в ядро
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);           // 0, либо IORING_SETUP_SQPOLL

for (int i = 0; i < 256; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fds[i], bufs[i], 4096, 0);
    io_uring_sqe_set_data64(sqe, i);          // тег: порядок завершения произвольный
}
io_uring_submit(&ring);                       // ← единственный системный вызов

struct io_uring_cqe *cqe;
unsigned head; int done = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
    int idx = (int)io_uring_cqe_get_data64(cqe);
    if (cqe->res < 0) fprintf(stderr, "op %d: %s\n", idx, strerror(-cqe->res));
    done++;
}
io_uring_cq_advance(&ring, done);

Trade-off честный и жёсткий. Плюсы: пропускная способность вырастает кратно на I/O-bound нагрузках, работают операции, которых нет в AIO (openat, statx, accept, send/recv, splice), с SQPOLL можно вообще не входить в ядро. Минусы: модель памяти требует явных барьеров (liburing прячет их, ручная работа с кольцами — источник тонких багов), отладка сложнее, и главное — история уязвимостей. Google отключил io_uring в ChromeOS и Android, многие контейнерные песочницы блокируют его seccomp-фильтром. Прежде чем закладываться на него, убедитесь, что целевая среда его не запрещает.

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

Карта механизмов

Вся эта зоопарк упорядочивается по трём осям: как адресуются (анонимно между роднёй / по имени в ФС / по сети), что передаётся (поток байтов / сообщения с границами / общая память) и сколько раз копируются данные.

Практический алгоритм выбора важнее знания всех API наизусть:

Пайпы: примитив, на котором стоит Unix

pipe(2) возвращает пару дескрипторов: из fd[0] читают, в fd[1] пишут. Между ними — кольцевой буфер в ядре, по умолчанию 16 страниц (64 КиБ) на Linux, 64 КиБ на FreeBSD, меньше на некоторых системах.

Свойства, которые надо знать:

  • Обратное давление бесплатно. Буфер полон — писатель блокируется. Это встроенная защита от того, что быстрый producer съест всю память. В своих очередях эту логику приходится писать руками.
  • Атомарность до PIPE_BUF. POSIX гарантирует, что запись объёмом ≤ PIPE_BUF (4096 байт на Linux, 512 — минимум по стандарту) не перемешается с записями других писателей. Больше — может разорваться. Отсюда правило: многопроцессное логирование в один пайп безопасно, только если строки короче 4 КиБ.
  • EOF и SIGPIPE. Когда закрыт последний пишущий конец, read() возвращает 0. Когда закрыт последний читающий, write() шлёт SIGPIPE, который по умолчанию убивает процесс. Это, кстати, механизм, благодаря которому yes | head -1 завершается, а не крутится вечно. В библиотеках SIGPIPE почти всегда надо игнорировать и обрабатывать EPIPE.
# Размер буфера конкретного пайпа и лимиты системы
$ cat /proc/sys/fs/pipe-max-size          # потолок для непривилегированного F_SETPIPE_SZ
1048576
$ cat /proc/sys/fs/pipe-user-pages-soft   # сколько страниц суммарно может занять один UID
16384

# Наглядно: обратное давление в действии
$ (yes > /tmp/fifo &) ; sleep 1; cat /proc/$(pgrep -n yes)/status | grep State
State:  S (sleeping)      # писатель спит — читателя нет, буфер полон

Всегда используйте pipe2(fds, O_CLOEXEC) вместо pipe(). Разница в том, что O_CLOEXEC ставится атомарно: между pipe() и fcntl(F_SETFD) другой поток может успеть сделать fork+exec и утащить дескриптор в чужой процесс. То же касается socketpair с SOCK_CLOEXEC, open с O_CLOEXEC, accept4 вместо accept. Это не педантизм — это реальная дыра, через которую дескрипторы утекают в дочерние процессы.

Unix-domain сокеты и передача дескрипторов

AF_UNIX — рабочая лошадь современного IPC. Дуплекс, три типа (SOCK_STREAM, SOCK_DGRAM, SOCK_SEQPACKET — последний даёт границы сообщений плюс надёжность), интеграция с epoll/kqueue, аутентификация пира.

Разница между семействами ОС здесь заметная:

Возможность Linux FreeBSD OpenBSD macOS
Abstract namespace (\0name) да нет нет нет
SOCK_SEQPACKET да да (с 9.x) да нет
Учётные данные пира SO_PEERCRED LOCAL_PEERCRED, getpeereid getpeereid LOCAL_PEERCRED, getpeereid
Права файла сокета при connect проверяются проверяются проверяются проверяются

Последнюю строку стоит пояснить: исторически 4.4BSD права на файл сокета при подключении игнорировала, и переносимый код на это не закладывался. Современные системы их проверяют, но привычка защищаться правами каталога, а не самого файла, осталась — и она правильная.

Абстрактные сокеты Linux (имя начинается с нулевого байта, файла в ФС нет) удобны, но у них нет прав доступа — их видит любой процесс в том же network namespace. Для чего-то приватного используйте путь в каталоге с правами 0700.

Главная суперсила AF_UNIX — SCM_RIGHTS, передача открытых файловых дескрипторов между процессами. Это не передача номера: ядро создаёт в таблице получателя новую запись, указывающую на тот же struct file. Получатель приобретает права, которые он сам, возможно, получить не смог бы.

// Отправка дескриптора соседнему процессу
int send_fd(int sock, int fd_to_send) {
    char iobuf[1] = {'F'};                            // хотя бы 1 байт данных обязателен
    struct iovec iov = { .iov_base = iobuf, .iov_len = 1 };
    union {                                            // union гарантирует выравнивание
        char buf[CMSG_SPACE(sizeof(int))];
        struct cmsghdr align;
    } u = {0};

    struct msghdr msg = {
        .msg_iov = &iov, .msg_iovlen = 1,
        .msg_control = u.buf, .msg_controllen = sizeof(u.buf),
    };

    struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
    cm->cmsg_level = SOL_SOCKET;
    cm->cmsg_type  = SCM_RIGHTS;
    cm->cmsg_len   = CMSG_LEN(sizeof(int));
    memcpy(CMSG_DATA(cm), &fd_to_send, sizeof(int));

    return sendmsg(sock, &msg, 0) < 0 ? -1 : 0;
}

На этом механизме держится половина современного Linux-стека:

  • systemd socket activation — systemd открывает слушающий сокет, передаёт его сервису; сервис можно перезапустить, не потеряв ни одного входящего соединения.
  • Wayland — клиент рендерит в memfd, передаёт fd композитору; кадр не копируется вообще.
  • Песочница Chrome — процесс-рендерер лишён прав открывать файлы; брокер открывает и передаёт готовые дескрипторы.
  • Docker/containerdrunc передаёт консольный pty-дескриптор наружу через сокет.
  • Плавный перезапуск сервера — новый процесс получает слушающий сокет и обрабатывает соединения с нулевым downtime.

Аналоги в других системах устроены иначе. macOS/XNU строится вокруг Mach ports: передаётся не дескриптор, а право на порт (send right, receive right, send-once), и вся система — от launchd до XPC — это маршрутизация прав. Windows использует DuplicateHandle, где процесс-источник явно дублирует хендл в целевой процесс по его PID (нужны соответствующие права доступа). Философски это одно и то же — передача возможности (capability), — но модели угроз разные: у Mach право можно передать транзитивно, у Windows нужен доступ к целевому процессу.

Разделяемая память: максимум скорости, максимум ответственности

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

Три способа её получить в Linux, в порядке от современного к архаичному:

// 1. memfd_create — предпочтительный современный способ.
//    Безымянный объект в памяти, передаётся по SCM_RIGHTS, поддерживает "печати".
int fd = memfd_create("frame-buffer", MFD_CLOEXEC | MFD_ALLOW_SEALING);
ftruncate(fd, SIZE);
void *p = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// Печати: после этого НИКТО, включая нас, не изменит размер и содержимое.
// Получатель может проверить печати и безопасно читать без копии —
// иначе злонамеренный отправитель мог бы ужать файл под ним (SIGBUS).
fcntl(fd, F_ADD_SEALS, F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_WRITE);

// 2. POSIX shm — переносимо на все Unix. Имя видно в /dev/shm (это tmpfs).
int fd2 = shm_open("/myapp-cache", O_CREAT | O_RDWR, 0600);
ftruncate(fd2, SIZE);
void *q = mmap(NULL, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd2, 0);
// Не забыть shm_unlink() — иначе объект переживёт процесс и утечёт.

// 3. System V shm — legacy. Оставлен для совместимости, новый код писать не надо.
int id = shmget(ftok("/etc/hosts", 'A'), SIZE, IPC_CREAT | 0600);
void *r = shmat(id, NULL, 0);
# System V IPC не привязан к жизни процесса — классический источник утечек
$ ipcs -m
------ Shared Memory Segments --------
key        shmid      owner   perms  bytes     nattch  status
0x00000000 32769      postgres 600   536870912 8
0x0052e2c1 65538      oracle   640   4294967296 0        # 4 ГиБ висят без единого процесса

$ ipcrm -m 65538                      # убирать приходится руками

# POSIX shm виден как обычные файлы — гораздо удобнее
$ ls -la /dev/shm/
-rw------- 1 postgres postgres 268435456 Jul 16 10:02 PostgreSQL.1804289383

Два обязательных предупреждения.

Первое: указатели внутри разделяемой области бессмысленны. mmap без MAP_FIXED отдаёт разные виртуальные адреса разным процессам (тем более при ASLR). Всё, что живёт в общей памяти, должно адресоваться смещениями от базы. Попытка положить туда std::map или Go-слайс кончается сегфолтом у второй стороны.

Второе: синхронизация целиком на вас, и она должна переживать смерть участника. Обычный pthread_mutex в разделяемой памяти при падении держателя оставляет всех остальных висеть навсегда. Правильная конструкция:

pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);  // иначе UB между процессами
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);     // выживание при крахе
pthread_mutex_init(shared_mutex, &attr);

// В каждом месте захвата:
int rc = pthread_mutex_lock(shared_mutex);
if (rc == EOWNERDEAD) {
    // Держатель умер, не отпустив. Данные под защитой — в неизвестном состоянии.
    repair_shared_state();                       // восстановить инварианты
    pthread_mutex_consistent(shared_mutex);      // объявить снова пригодным
} else if (rc == ENOTRECOVERABLE) {
    // Кто-то получил EOWNERDEAD и не починил. Мьютекс мёртв навсегда.
    abort();
}

Robust-мьютексы — Linux-специфика (реализованы через set_robust_list и futex). FreeBSD поддерживает их с 11.x, macOS — нет. Это одна из причин, почему кроссплатформенный софт часто остаётся на сокетах: сокет при падении процесса просто закрывается, и вторая сторона получает EOF. Ядро само чистит за вами — а в разделяемой памяти чистить некому.

futex: как блокировка стоит ноль, пока нет конкуренции

Мьютекс, который на каждый захват ходит в ядро, — это 400 нс на операцию. Неприемлемо. Идея futex (fast userspace mutex, Franke & Russell, 2002): в ядро надо ходить только когда действительно нужно кого-то усыпить или разбудить. Пока конкуренции нет, всё решается одной атомарной инструкцией.

Классическая трёхзначная схема Ульриха Дреппера:

Ключевая тонкость: FUTEX_WAIT(addr, expected) атомарно проверяет *addr == expected и только тогда засыпает. Без этой атомарности была бы гонка: поток решает уснуть, между проверкой и сном другой поток отпускает мьютекс и шлёт FUTEX_WAKE — некому просыпаться, и первый спит вечно. Именно эту гонку futex и закрывает.

# Быстрый путь в strace не виден вообще — в этом весь смысл.
$ strace -f -e trace=futex ./app_uncontended
+++ exited with 0 +++                     # ни одного вызова

# Под конкуренцией futex-вызовы вылезают наружу и становятся видны в профиле
$ strace -c -f -e trace=futex ./app_contended
% time     seconds  usecs/call     calls    errors syscall
100.00    2.847291         101     28091      1204 futex

# Кто именно ждёт и сколько (bcc)
$ sudo /usr/share/bcc/tools/offcputime -f -p $(pgrep -n app) 30 > out.stacks

Практический признак проблемы: много futex в strace -c — это почти всегда не «медленный futex», а слишком крупная критическая секция или false sharing. Ядро тут ни при чём; исправляется шардированием блокировок или per-CPU структурами.

Futex — фундамент, на котором построено всё: pthread_mutex, pthread_cond, sem_t, std::mutex, sync.Mutex в Go (Go имеет собственный планировщик горутин, но в конечном счёте паркует потоки ОС через futex), parking_lot в Rust. Аналоги: FreeBSD — _umtx_op, OpenBSD — futex(2) (добавлен ради совместимости), macOS — __ulock_wait/__ulock_wake (приватный API, поэтому все ходят через os_unfair_lock), Windows — WaitOnAddress/WakeByAddressSingle, публичный с Windows 8.

Сигналы и почему их так тяжело готовить

Сигналы — древнейший механизм уведомления, и одновременно самый неудобный. Обработчик выполняется асинхронно, прерывая произвольную инструкцию. Из него можно вызывать только async-signal-safe функции (список — в man 7 signal-safety): write можно, printf нельзя (внутри блокировка stdio — рискуете самодедлоком), malloc нельзя, почти вся libc нельзя.

Проблем ещё три. Стандартные сигналы не ставятся в очередь — десять SIGCHLD подряд могут прийти как один. Их нельзя надёжно ждать вместе с сокетами в epoll. И доставка по PID подвержена гонке переиспользования: процесс умер, PID отдали другому, ваш kill() убил невиновного.

Современные решения:

// 1. signalfd — сигнал превращается в читаемый дескриптор (Linux)
sigset_t mask;
sigemptyset(&mask); sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL);              // обязательно заблокировать!
int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
// Теперь sfd живёт в epoll рядом с сокетами, и обработка идёт в обычном коде,
// без ограничений async-signal-safety.

// 2. pidfd — ссылка на процесс, невосприимчивая к переиспользованию PID
int pidfd = pidfd_open(child_pid, 0);             // Linux 5.3+
pidfd_send_signal(pidfd, SIGTERM, NULL, 0);       // либо этот процесс, либо ESRCH
// pidfd тоже поллится: epoll разбудит вас, когда процесс завершится.

// 3. Классический self-pipe trick — переносимо везде, где нет signalfd
// Обработчик делает единственную безопасную вещь: write(pipe_w, "x", 1)

Аналоги: во всех BSD и macOS роль signalfd играет kqueue с фильтром EVFILT_SIGNALEVFILT_PROC — роль pidfd), что многие считают более элегантным решением; FreeBSD дополнительно имеет pdfork/pdkill — процессы-как-дескрипторы, идея из Capsicum, появившаяся раньше Linux-овских pidfd.

Сколько это стоит на самом деле

Порядки величин на типичном сервере x86-64 (Linux 6.x, митигации включены), round-trip «отправил — получил ответ»:

Механизм Латентность Комментарий
Атомарная операция в общей памяти 20–100 нс без конкуренции, futex не будится
Spinlock под слабой конкуренцией ~100 нс жжёт CPU, годится только для очень коротких секций
futex — медленный путь 1–5 мкс сон + пробуждение + переключение контекста
pipe, round-trip 3–8 мкс 2 копии, 4 перехода в ядро
AF_UNIX SOCK_STREAM 4–10 мкс плюс работа сетевого слоя
TCP по loopback 15–30 мкс полный стек: та же обработка, что для сети
Сигнал 2–5 мкс плюс непредсказуемость момента доставки
D-Bus через демон 100–300 мкс два хопа, сериализация, маршрутизация

Отсюда простое эвристическое правило: до десятков тысяч сообщений в секунду берите AF_UNIX и не думайте. Разделяемая память оправдана, когда счёт идёт на сотни тысяч сообщений или на гигабайты в секунду — и вы готовы платить за это собственной реализацией синхронизации и отладкой гонок.

Заодно развеем миф: «TCP на localhost — это то же самое, что unix-сокет». Нет. Loopback проходит весь сетевой стек: сборку заголовков, контрольные суммы (отключаемые), маршрутизацию, netfilter. Разница в 3–5 раз по латентности и заметная по CPU — при том, что AF_UNIX даёт вдобавок аутентификацию пира и передачу дескрипторов. Именно поэтому Postgres, MySQL, Redis и php-fpm по умолчанию слушают unix-сокет.

Типичные заблуждения

«Разделяемая память всегда быстрее». Быстрее по копированию — да. Но вы получаете когерентность кэшей на каждой записи, false sharing на соседних переменных в одной кэш-линии, и всю сложность lock-free программирования. Плохо написанный SPSC-буфер в shm легко проигрывает pipe, где ядро уже сделало всё правильно.

«volatile достаточно для синхронизации через общую память». Нет. volatile в C и C++ запрещает компилятору кэшировать значение в регистре, но ничего не говорит процессору о порядке. Нужны _Atomic/std::atomic с явными memory ordering, а на слабых моделях (aarch64, POWER) отсутствие барьеров ломается сразу и невоспроизводимо.

«SIGKILL можно перехватить и красиво завершиться». Нет: SIGKILL и SIGSTOP не блокируются, не игнорируются и не обрабатываются — они реализованы в планировщике, а не в процессе. Для graceful shutdown ловите SIGTERM. Отдельно: процесс в состоянии D (uninterruptible sleep) не убивается даже SIGKILL, пока не завершится I/O.

«write() записал — значит, данные дошли». write() в файл кладёт данные в page cache; долговечность даёт только fsync() (см. файловые системы). write() в сокет — только в буфер отправки; доставку он не гарантирует вообще.

«System V IPC устарел, но безобиден». Сегменты shmget переживают процессы и держат память до перезагрузки или ручного ipcrm. На хостах, где перезапускается Oracle или старый Postgres, это регулярно съедает десятки гигабайт.

«Номер сисколла — это стабильная вещь везде». Только в Linux. На macOS и Windows прямой сисколл в обход системной библиотеки сломается при следующем апдейте, а на OpenBSD — прямо сейчас, потому что ядро проверяет адрес, откуда пришёл вызов.

«strace можно оставить в проде». Каждый перехваченный вызов — это две остановки процесса через ptrace. Замедление в 5–50 раз, изменение таймингов и связанные с этим гейзенбаги. В проде — только eBPF.

Как это выглядит в реальных системах

PostgreSQL. Один большой сегмент разделяемой памяти (буферный пул, WAL-буферы, таблица блокировок), созданный до fork бэкендов. Синхронизация — собственные LWLock поверх атомиков и futex-подобного ожидания. Именно поэтому в Linux надо настраивать vm.overcommit_memory и huge pages: сегмент огромный и трогается весь.

nginx → php-fpm. AF_UNIX по умолчанию. Мастер-процесс открывает слушающий сокет и делает fork; воркеры наследуют дескриптор и все зовут accept на одном сокете (SO_REUSEPORT уводит распределение соединений в ядро, убирая thundering herd).

Wayland. Протокол сообщений идёт по AF_UNIX, а пиксели — по memfd, переданному через SCM_RIGHTS. Кадр 4K — это 33 МиБ; копировать его 60 раз в секунду через сокет физически невозможно, поэтому передаётся только дескриптор. Подробнее — в графическом стеке.

Chrome и Firefox. Многопроцессность плюс брокер: рендерер лишён почти всех сисколлов через seccomp-bpf, а всё нужное получает дескрипторами. Модель изоляции разбирается в безопасности и изоляции.

Docker/containerd/runc. Слои общаются через gRPC поверх AF_UNIX (/run/containerd/containerd.sock); права на файл сокета — это фактически вся модель авторизации, из-за чего доступ к docker.sock эквивалентен root на хосте.

systemd. Активация по сокету, sd_notify через AF_UNIX SOCK_DGRAM, journald принимает логи через сокет и умеет доставать учётные данные отправителя через SO_PEERCRED — поэтому подделать поле _PID в журнале нельзя. Детали — в системах инициализации.

Мини-итог

  • Системный вызов — аппаратный переход привилегий, а не вызов функции. На x86-64 это syscall, номер в rax, аргументы в rdi/rsi/rdx/r10/r8/r9, четвёртый в r10 из-за затирания rcx.
  • После KPTI и Spectre-митигаций переход стоит 200–500 нс. Оптимизируют не сам вызов, а их количество: буферизация, векторные вызовы, io_uring.
  • vDSO убирает переход для времени и getcpu — но только если clocksource это позволяет.
  • Стабильность ABI: Linux — навсегда, BSD — через COMPAT, macOS и Windows — только через системную библиотеку, OpenBSD дополнительно проверяет адрес источника вызова.
  • Три вещи, которые ABI навязывает коду: цикл по EINTR, обработка частичных записей, чтение errno только после ошибки.
  • Выбор IPC — это выбор числа копий. Сокет: 2 копии, но ядро делает всё остальное. splice: 0 копий, но данные вам недоступны. Разделяемая память: 0 копий и вся сложность синхронизации на вас.
  • SCM_RIGHTS — передача не номера, а возможности. Фундамент socket activation, Wayland, песочниц браузеров.
  • futex делает неконкурентную блокировку бесплатной; много futex в профиле означает слишком крупную критическую секцию, а не медленное ядро.
  • Разделяемая память требует robust-мьютексов и смещений вместо указателей. Если это звучит дорого — вернитесь к сокетам, ядро почистит за вас само.

Источники

Книги:

  • W. Richard Stevens, Stephen Rago. Advanced Programming in the UNIX Environment, 3rd ed. — канонический разбор системных вызовов, сигналов, пайпов.
  • W. Richard Stevens. UNIX Network Programming, Volume 2: Interprocess Communications, 2nd ed. — до сих пор лучшая книга именно по IPC.
  • Michael Kerrisk. The Linux Programming Interface — 1500 страниц, из которых главы 43–55 покрывают весь IPC Linux; де-факто справочник.
  • Robert Love. Linux System Programming, 2nd ed. — короче и практичнее.
  • Marshall Kirk McKusick et al. The Design and Implementation of the FreeBSD Operating System, 2nd ed. — как то же самое устроено в BSD.
  • Pavel Yosifovich et al. Windows Internals, Part 1, 7th ed. — SSDT, ALPC, объектный менеджер.

Статьи:

Документация и man-страницы:

Что дальше

Мы разобрали, как процесс просит ядро о чём угодно и как два процесса на одной машине обмениваются данными. Осталось самое интересное продолжение той же истории: что происходит, когда собеседник находится на другом континенте. socket() выглядит как обычный дескриптор, за write() прячутся очереди, буферы, алгоритм Nagle, TCP-конечный автомат, netfilter и кольцо драйвера — и каждый из этих слоёв умеет добавить свою миллисекунду.

Следующая статья: Сетевой стек операционной системы: от сокета до драйвера.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов