Системные вызовы и межпроцессное взаимодействие
Процесс живёт в изоляции. У него нет доступа к диску, к сетевой карте, к памяти соседа, к системному времени — вообще ни к чему за пределами собственного адресного пространства. Всё, что программа умеет делать сама, — это считать: складывать числа в регистрах и читать-писать свои страницы памяти. Любой полезный эффект в реальном мире требует попросить ядро.
Системный вызов — это единственная легальная дверь наружу. И одновременно самая горячая точка всей системы: типичный веб-сервер делает миллионы переходов в секунду, а каждый переход стоит на два порядка дороже обычного вызова функции. Понимать, что происходит при пересечении этой границы, — значит понимать, почему ваш код упирается в потолок производительности именно там, где упирается.
Вторая половина статьи — про то, как процессы разговаривают друг с другом. Изоляция, которая делает систему надёжной, создаёт проблему: браузеру нужно передать декодированный кадр из процесса-рендерера в процесс-композитор, nginx нужно отдать запрос в php-fpm, а Postgres — синхронизировать сотни бэкендов вокруг общего буферного пула. Все механизмы IPC — это разные компромиссы между стоимостью копирования, сложностью синхронизации и тем, сколько безопасности вы готовы отдать за скорость.
Мы уже касались границы ядра в архитектурах ядер и обещали разобрать futex в процессах и планировщиках. Пора закрыть оба долга.
Часть I. Системные вызовы
Что физически происходит при переходе
Обычный вызов функции — это call: процессор кладёт адрес возврата на стек и прыгает. Привилегии не меняются, адресное пространство не меняется, стоимость — единицы наносекунд.
Системный вызов меняет уровень привилегий процессора. На x86-64 это переход из ring 3 в ring 0, и сделать его произвольным прыжком нельзя — иначе изоляция ничего не стоила бы. Процессор предоставляет ровно одну специальную инструкцию, которая одновременно поднимает привилегии и передаёт управление по адресу, заранее записанному ядром в защищённый регистр.
На x86-64 это инструкция syscall. Она:
- Сохраняет
RIP(адрес возврата) вRCX, аRFLAGS— вR11. Никакого стека, никакой памяти — только регистры, потому что стека ядра ещё нет. - Маскирует флаги по значению
IA32_FMASK(в частности, гаситIF— прерывания выключены на входе). - Загружает
RIPизIA32_LSTAR— этот MSR ядро проинициализировало при загрузке адресомentry_SYSCALL_64. - Загружает сегментные селекторы из
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, аргументы в x0–x5, инструкция — 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
}
Полный путь одного вызова
CPU отдан другому потоку Sched-->>Impl: пробуждение по завершению I/O Impl-->>Entry: copy_to_user(), возврат числа байт end Entry->>Entry: обработка отложенной работы: сигналы, need_resched Entry->>HW: sysret (или iret, если состояние «грязное») HW->>Libc: CPL 0→3, RIP←RCX Libc->>Libc: ret < 0 ? (errno = -ret, return -1) : return ret Libc-->>App: 4096
Ключевой момент, который стоит увидеть в этой диаграмме: системный вызов — это не обязательно блокировка, но это всегда возможность блокировки. Одна и та же строчка 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) в очередь завершения.
// Скелет на 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 наизусть:
fork-родством?} B -- да --> C{Односторонний
поток?} C -- да --> D["pipe2(O_CLOEXEC)
простейший вариант"] C -- нет --> E["socketpair(AF_UNIX)
дуплекс + SCM_RIGHTS"] B -- нет --> F{Нужно ли
переживать
перезапуск сторон?} F -- да --> G["AF_UNIX по пути в ФС
+ права каталога как ACL"] F -- нет --> G G --> H{Объём и частота?} D --> H E --> H H -- "мегабайты, тысячи msg/s" --> I{Данные нужно
обрабатывать
в процессе?} H -- "мало и редко" --> J["Оставьте сокет.
Оптимизировать нечего"] I -- нет, только переслать --> K["splice / sendfile
ноль копий, ядро не будит вас"] I -- да --> L["memfd_create + mmap
fd передать через SCM_RIGHTS"] L --> M["Синхронизация:
атомики + futex,
robust-мьютексы"] M --> N{Держатель блокировки
может упасть?} N -- да --> O["PTHREAD_MUTEX_ROBUST
обработка EOWNERDEAD
ИЛИ вернитесь к сокетам"] N -- нет --> P["Так не бывает.
См. ветку «да»"] style D fill:#3b82f6,color:#fff style E fill:#3b82f6,color:#fff style K fill:#14b8a6,color:#fff style L fill:#a78bfa,color:#fff style O fill:#ef4444,color:#fff
Пайпы: примитив, на котором стоит 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/containerd —
runcпередаёт консольный pty-дескриптор наружу через сокет. - Плавный перезапуск сервера — новый процесс получает слушающий сокет и обрабатывает соединения с нулевым downtime.
Аналоги в других системах устроены иначе. macOS/XNU строится вокруг Mach ports: передаётся не дескриптор, а право на порт (send right, receive right, send-once), и вся система — от launchd до XPC — это маршрутизация прав. Windows использует DuplicateHandle, где процесс-источник явно дублирует хендл в целевой процесс по его PID (нужны соответствующие права доступа). Философски это одно и то же — передача возможности (capability), — но модели угроз разные: у Mach право можно передать транзитивно, у Windows нужен доступ к целевому процессу.
namespace прячет /etc S->>B: "нужен /etc/fonts/local.conf" (по socketpair) B->>B: проверка политики: путь в белом списке? B->>K: openat("/etc/fonts/local.conf", O_RDONLY) K-->>B: fd = 12 B->>K: sendmsg(sock, SCM_RIGHTS, [12]) K->>K: новая запись в таблице песочницы
на тот же struct file K-->>S: fd = 7 (номер другой, объект тот же) B->>K: close(12) Note over S,K: файл остаётся открытым:
счётчик ссылок на struct file равен 1 S->>K: read(7, ...) — разрешено, дескриптор уже открыт K-->>S: данные
Разделяемая память: максимум скорости, максимум ответственности
Когда профиль показал, что 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): в ядро надо ходить только когда действительно нужно кого-то усыпить или разбудить. Пока конкуренции нет, всё решается одной атомарной инструкцией.
Классическая трёхзначная схема Ульриха Дреппера:
ноль системных вызовов Занят --> Свободен: CAS(1→0) успешен
ноль системных вызовов Занят --> ЗанятСОжидающими: второй поток не смог CAS,
ставит val = 2 и зовёт
FUTEX_WAIT(addr, 2) ЗанятСОжидающими --> ЗанятСОжидающими: приходят ещё ожидающие,
все спят в очереди ядра ЗанятСОжидающими --> Свободен: unlock видит val == 2,
пишет 0 и зовёт
FUTEX_WAKE(addr, 1) note right of Свободен Быстрый путь: lock+unlock ≈ 20 нс. Ядро о существовании мьютекса вообще не знает. end note note right of ЗанятСОжидающими Медленный путь: ~1–5 мкс. Ядро держит хеш-таблицу очередей по физическому адресу — поэтому futex работает и в разделяемой памяти. end note
Ключевая тонкость: 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_SIGNAL (а EVFILT_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, объектный менеджер.
Статьи:
- Ulrich Drepper. «Futexes Are Tricky» — обязательное чтение перед любой попыткой написать свой мьютекс.
- Hubertus Franke, Rusty Russell. «Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux», OLS 2002 — оригинальная работа.
- Jens Axboe. «Efficient IO with io_uring» — от автора подсистемы.
- Livio Soares, Michael Stumm. «FlexSC: Flexible System Call Scheduling with Exception-Less System Calls», OSDI 2010 — академический предок io_uring.
- Moritz Lipp et al. «Meltdown: Reading Kernel Memory from User Space» — почему вход в ядро подорожал.
Документация и man-страницы:
man 2 syscall,man 2 intro,man 7 vdso,man 7 signal-safety,man 7 pipe,man 7 unix,man 2 futex,man 3 cmsg,man 2 memfd_create,man 7 shm_overview,man 7 sem_overview,man 2 pidfd_open.- Таблица системных вызовов Linux по архитектурам — первоисточник номеров.
- Documentation/userspace-api в дереве ядра.
- liburing и io_uring by example.
- LWN: «The rapid growth of io_uring», «A pidfd API for Linux», «Sealed files».
- OpenBSD
pinsyscalls(2)иmsyscall(2)— модель проверки источника вызова. - Apple: Mach IPC и XPC, FreeBSD
kqueue(2).
Что дальше
Мы разобрали, как процесс просит ядро о чём угодно и как два процесса на одной машине обмениваются данными. Осталось самое интересное продолжение той же истории: что происходит, когда собеседник находится на другом континенте. socket() выглядит как обычный дескриптор, за write() прячутся очереди, буферы, алгоритм Nagle, TCP-конечный автомат, netfilter и кольцо драйвера — и каждый из этих слоёв умеет добавить свою миллисекунду.
Следующая статья: Сетевой стек операционной системы: от сокета до драйвера.