IPC в C++: типичные ошибки и как их избежать
Разделяемая память без синхронизации, зомби-процессы, залипшие мьютексы после падения владельца. Разбираем ошибки межпроцессного взаимодействия в C++ и что делать, чтобы не ловить их в проде.

Два процесса пишут в одну область разделяемой памяти без синхронизации — и получают то мусор, то верные данные, в зависимости от планировщика. Owner-процесс держит POSIX-мьютекс, падает по SIGKILL, и все остальные навсегда виснут на pthread_mutex_lock. Named pipe остаётся в файловой системе после перезапуска, и следующий запуск падает на mkfifo с EEXIST. Это не экзотика — это три самых частых способа выстрелить себе в ногу при межпроцессном взаимодействии на C++.
Разбираем не как устроен каждый механизм IPC целиком, а конкретные ошибки: где они возникают, что вы увидите в логах и strace, чем платите за неправильный выбор. Примеры под Linux, компилятор GCC 13 / Clang 17, стандарт C++17 и выше. Windows-специфику (CreateFileMapping, named pipes через CreateNamedPipe) не трогаем — там своя модель времени жизни объектов.
Ошибка 1: разделяемая память без синхронизации
Самый распространённый случай. Вы создаёте сегмент через shm_open + mmap, кладёте туда структуру и читаете её из другого процесса. Работает на тестах, ломается под нагрузкой.
Проблема в двух вещах сразу: атомарность записи и видимость. Запись 8-байтного поля на x86-64 атомарна, но структура из нескольких полей — уже нет. Один процесс может увидеть половину обновления. А обычные volatile или просто чтение поля не гарантируют, что вы увидите свежее значение, а не закешированное в регистре.
std::atomic внутри разделяемой памяти работает между процессами, но только если тип lock-free. Проверяйте это явно:
#include <atomic>
#include <cstdint>
struct SharedState {
std::atomic<std::uint64_t> sequence; // счётчик версии
std::uint64_t payload[8];
};
// На этапе компиляции убеждаемся, что atomic не прячет внутри мьютекс.
// Мьютекс, размещённый в разделяемой памяти по указателю на
// внутренний lock, между процессами не работает.
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
"atomic<uint64_t> must be lock-free for IPC");Паттерн seqlock для читателя без блокировок: читатель проверяет sequence до и после чтения, писатель увеличивает счётчик до записи (делая его нечётным) и после (делая чётным). Если до и после совпало и число чётное — данные консистентны.
seqlock
Схема синхронизации «один писатель, много читателей». Читатель не блокируется вовсе, а перечитывает данные, если писатель влез в середину. Хорошо работает, когда читателей много, а конфликтов мало.
Ошибка 2: мьютекс без robust-флага
Вы кладёте pthread_mutex_t в разделяемую память, чтобы синхронизировать процессы. Забываете два флага при инициализации — и получаете два разных отложенных сбоя.
- Без
PTHREAD_PROCESS_SHAREDмьютекс работает только между потоками одного процесса. Между процессами его поведение не определено — может «работать» на вашей машине и молча ничего не защищать. - Без
PTHREAD_MUTEX_ROBUSTпроцесс, захвативший мьютекс и умерший, оставляет его залоченным навсегда. Все остальные виснут наpthread_mutex_lockбез шанса восстановиться.
#include <pthread.h>
void init_shared_mutex(pthread_mutex_t* mtx) {
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
// Мьютекс виден другим процессам через разделяемую память.
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
// Владелец умер с захваченным мьютексом — следующий lock
// вернёт EOWNERDEAD, а не зависнет навсегда.
pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);
pthread_mutex_init(mtx, &attr);
pthread_mutexattr_destroy(&attr);
}С robust-мьютексом после смерти владельца следующий lock вернёт EOWNERDEAD. Это не ошибка, которую можно проигнорировать: вы обязаны привести защищаемые данные в согласованное состояние и вызвать pthread_mutex_consistent, иначе мьютекс станет непригодным навсегда.
int rc = pthread_mutex_lock(mtx);
if (rc == EOWNERDEAD) {
// Предыдущий владелец умер посреди критической секции.
// Чиним инварианты вручную, потом объявляем мьютекс годным.
repair_shared_state();
pthread_mutex_consistent(mtx);
} else if (rc != 0) {
// ENOTRECOVERABLE и прочее — мьютекс уже не спасти
}Robust-мьютекс не чинит ваши данные. Он лишь даёт шанс их починить и говорит: «предыдущий владелец не отпустил меня по-человечески». Что делать дальше — только ваша ответственность.
Ошибка 3: утечка имён и дескрипторов
Объекты POSIX IPC — shm_open, sem_open, mkfifo — переживают процесс, который их создал. Named-семафор с именем /myapp_lock остаётся в системе после завершения программы. Разделяемый сегмент /dev/shm/myapp висит, пока его кто-то держит открытым или пока вы явно не вызвали shm_unlink.
Отсюда два симптома. Первый — при повторном запуске вы открываете старый сегмент со стухшими данными и не понимаете, откуда там мусор. Второй — /dev/shm потихоньку забивается брошенными сегментами.
Разделяйте создание и удаление имени. shm_unlink удаляет имя, но сам сегмент живёт, пока открыт хоть один дескриптор — как unlink для обычного файла:
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
const char* name = "/myapp_state";
// Создатель: O_CREAT | O_EXCL ловит чужой недоубранный сегмент.
int fd = shm_open(name, O_CREAT | O_EXCL | O_RDWR, 0600);
if (fd == -1 && errno == EEXIST) {
// Старый сегмент не убран. Не открываем молча — это сигнал.
// Осознанно: shm_unlink(name) и повторить, либо упасть с ошибкой.
}O_EXCL здесь — не паранойя. Без него вы тихо подцепите старый сегмент и не заметите проблему, пока не начнёте читать несовместимые данные от прошлой версии программы.
Ошибка 4: игнорирование частичных чтений и записей на пайпах
Для потоковых механизмов — pipe, FIFO, Unix-сокет типа SOCK_STREAM — write и read не обязаны обработать весь буфер за один вызов. write может записать меньше, чем вы просили, если буфер ядра заполнен. read может вернуть меньше, чем есть в сообщении. Код вида read(fd, buf, size) с проверкой только на -1 ломается ровно тогда, когда данных много.
Оборачивайте в цикл, который дочитывает и дозаписывает остаток:
#include <unistd.h>
#include <cerrno>
// Пишем ровно len байт или возвращаем ошибку.
bool write_all(int fd, const char* data, size_t len) {
size_t sent = 0;
while (sent < len) {
ssize_t n = ::write(fd, data + sent, len - sent);
if (n < 0) {
if (errno == EINTR) continue; // прерван сигналом — повтор
return false;
}
sent += static_cast<size_t>(n);
}
return true;
}Отдельно про SIGPIPE: если писать в pipe, у которого закрыт читающий конец, процесс по умолчанию получит SIGPIPE и молча умрёт. В сетевом и IPC-коде это почти всегда не то, что нужно. Игнорируйте сигнал через signal(SIGPIPE, SIG_IGN) и обрабатывайте EPIPE из write явно.
Как выбрать механизм под задачу
Половина ошибок начинается с выбора инструмента не под тот сценарий. Разделяемая память быстрая, но требует ручной синхронизации. Пайпы просты, но односторонни и без структуры сообщений. Ниже — что за что берут.
| Механизм | Сильная сторона | Чем платите | Когда брать |
|---|---|---|---|
| Разделяемая память (shm) | Минимальная задержка, нет копирования | Синхронизация целиком на вас | Большой объём данных, высокая частота обмена |
| Pipe / FIFO | Простота, поток байтов | Односторонний, нет границ сообщений | Конвейер, лог, простая передача |
| Unix-сокет | Двусторонний, есть SOCK_DGRAM с границами | Копирование через ядро | Клиент-сервер на одной машине |
| POSIX message queue | Границы сообщений, приоритеты | Лимиты на размер и число сообщений | Обмен небольшими командами |
SOCK_DGRAM vs SOCK_STREAM
У Unix-сокета типа SOCK_DGRAM границы сообщений сохраняются: один send — один recv. У SOCK_STREAM, как у TCP, границ нет, и делить поток на сообщения — ваша забота. Это влияет на протокол поверх сокета.
Что мерить и куда смотреть в проде
Замеры зависят от вашего железа и профиля нагрузки, поэтому конкретных цифр не привожу — снимайте их у себя. Что снимать:
- Задержку round-trip для сокетов и очередей под реальной параллельностью, а не в один поток.
- Заполнение
/dev/shm—df /dev/shmиls -la /dev/shm. Растущий список брошенных сегментов — это утечка из ошибки 3. - Зависшие процессы на
futex—strace -p PIDпокажет вечное ожидание наFUTEX_WAIT. Признак незакрытого мьютекса из ошибки 2.
Дальше: если синхронизация в shm стала узким местом, посмотрите в сторону lock-free кольцевых буферов и seqlock вместо мьютексов. Если процессы часто падают и восстановление критично — закладывайте robust-мьютексы с самого начала, дописывать их потом дороже. Инвалидация и версионирование формата данных в разделяемой памяти между версиями программы — отдельная большая тема, здесь мы её не разворачивали.
Источники: pthread_mutexattr_setrobust(3) — Linux manual page, shm_open(3) — Linux manual page
Частые вопросы
Работает ли std::atomic в разделяемой памяти между процессами?
А если у меня другая версия компилятора или ядра?
Что делать, если процесс завис навсегда на pthread_mutex_lock?
Почему после перезапуска в разделяемой памяти оказываются старые данные?
Грозит ли мне падение процесса при записи в закрытый pipe?
Нужна ли синхронизация, если запись в shm атомарна на x86?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.