Когда двум процессам нужно обмениваться данными, большинство механизмов работает через посредника. Канал, сокет, очередь сообщений: во всех случаях данные сначала копируются из памяти одного процесса в буфер ядра, а потом из буфера ядра в память другого. Два копирования на каждую порцию. Для мелких сообщений это незаметно, но когда речь идёт о потоках в сотни мегабайт в секунду, копирование туда-обратно превращается в главный тормоз. Разделяемая память убирает посредника совсем.
Идея проста и радикальна. Вместо того чтобы гонять данные через ядро, несколько процессов отображают один и тот же участок физической памяти в своё адресное пространство. После этого они читают и пишут прямо в эту память, как в обычные переменные, без единого системного вызова на саму передачу. Это самый быстрый способ межпроцессного обмена под Linux, дающий задержки в доли микросекунды и нулевое копирование. Но у скорости есть оборотная сторона: раз ядро больше не вмешивается в каждый доступ, оно и не следит за порядком, и синхронизацию приходится строить вручную.
Чем связка из именованного объекта и отображения отличается от обычного файла
Современный POSIX-интерфейс разделяемой памяти строится на двух главных вызовах плюс паре вспомогательных. Объект создаётся и открывается вызовом, который принимает имя в особом формате, начинающееся со слеша, набор флагов и права доступа. Возвращается обычный файловый дескриптор. Дальше этот дескриптор не используется для чтения и записи напрямую, его задача иная: задать размер объекта и отобразить его в память.
Размер свежесозданного объекта нулевой, поэтому его задают отдельным вызовом усечения, который выставляет нужное число байт. Только после этого объект отображается в адресное пространство, возвращая указатель, через который и происходит вся работа. Когда объект больше не нужен, его удаляют отдельным вызовом отвязки имени. Полный жизненный цикл со стороны процесса, который создаёт сегмент и пишет в него структуру, выглядит так:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
struct shared_data { int counter; char buf[256]; };
int fd = shm_open("/myregion", O_CREAT | O_RDWR, 0600); /* создаём объект */
ftruncate(fd, sizeof(struct shared_data)); /* задаём размер */
struct shared_data *p = mmap(NULL, sizeof(struct shared_data),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd); /* дескриптор после отображения уже не нужен */
p->counter = 42; /* пишем прямо в память, без системных вызовов */
Новичков часто смущает, зачем нужны сразу два механизма, открытие объекта и его отображение, ведь похожую задачу можно решить и обычным файлом через одно лишь отображение. Преимущество комбинации в том, что объект разделяемой памяти подчиняется стандартным соглашениям именования: процессы ссылаются на него по понятному имени, а не по пути к файлу в произвольном месте. Вдобавок удаление через отвязку имени работает безопаснее: оно не уничтожает объект немедленно, если кто-то ещё держит его открытым, что снижает риск выдернуть память из-под работающего процесса. На Linux объекты разделяемой памяти физически живут в особой файловой системе в оперативной памяти, смонтированной в каталоге, где их при желании можно увидеть как обычные файлы.
Есть и второй способ получить разделяемую память, не прибегая к именованным объектам вовсе. Если родительский процесс отобразит безымянную область с особым флагом совместного доступа ещё до порождения потомка, то после ветвления и родитель, и потомок будут видеть одну и ту же область. Это удобно для тесно связанных процессов, рождённых от общего предка, потому что не требует ни имени, ни согласования, ни последующей уборки именованного объекта: область исчезнет сама, когда отомрут все её владельцы. Зато такой путь закрыт для процессов, не состоящих в родстве и стартовавших независимо, им именованный объект необходим. Выбор между двумя подходами сводится к тому, связаны ли стороны общим предком или знакомятся впервые уже работающими.
Почему без семафоров разделяемая память превращается в источник порчи данных
Скорость разделяемой памяти оплачивается полной ответственностью за синхронизацию. Раз несколько процессов пишут в одну и ту же область одновременно, без координации они неизбежно затрут записи друг друга. Один процесс начал обновлять структуру, второй в этот момент прочитал её наполовину обновлённой и получил мусор. Это классическая гонка данных, и в разделяемой памяти она проявляется особенно коварно, потому что ничто на уровне ядра её не предотвращает.
Инструмент координации это семафоры. Семафор это особый счётчик в ядре, к которому процессы обращаются через атомарные операции захвата и освобождения. Когда процесс собирается тронуть разделяемую структуру, он сначала захватывает семафор. Если семафор уже занят другим процессом, захватывающий блокируется и ждёт освобождения, а не крутится в холостом цикле, пожирая процессор. Закончив работу с памятью, процесс освобождает семафор, и следующий ожидающий просыпается. Так доступ к общей области превращается из хаоса в строгую очередь.
Именованные POSIX-семафоры создаются и открываются по тому же принципу, что и сегменты памяти, по имени со слешем. Защита критической секции выглядит как обрамление работы с памятью парой операций ожидания и сигнала:
#include <semaphore.h>
sem_t *sem = sem_open("/mysem", O_CREAT, 0600, 1); /* начальное значение 1 */
sem_wait(sem); /* захватываем: блокируемся, если занято */
p->counter++; /* критическая секция: только мы трогаем память */
sem_post(sem); /* освобождаем: будим следующего ожидающего */
Начальное значение единица превращает семафор в так называемый мьютекс, замок на одного владельца: в каждый момент критическую секцию проходит ровно один процесс. Семафоры с бо́льшим начальным значением применяют там, где ресурс допускает несколько одновременных пользователей, например для подсчёта свободных мест в кольцевом буфере между производителем и потребителем.
Как выстроить производителя и потребителя на разделяемом кольце
Самый частый практический сценарий разделяемой памяти это связка производитель-потребитель. Один процесс кладёт данные в общий буфер, другой их забирает. Если оформить буфер как кольцо, можно непрерывно передавать поток данных, не выделяя память под каждую порцию заново. Здесь одного замка мало, нужна более тонкая схема из нескольких семафоров.
Обычно берут три семафора. Первый, мьютекс с начальным значением единица, защищает сам буфер от одновременного доступа. Второй считает свободные ячейки и инициализируется числом мест в кольце: производитель ждёт на нём перед записью и блокируется, когда кольцо заполнено. Третий считает занятые ячейки и стартует с нуля: потребитель ждёт на нём перед чтением и блокируется, когда забирать нечего. Запись и чтение становятся зеркальными процедурами: производитель дожидается свободного места, захватывает мьютекс, кладёт элемент, отпускает мьютекс и сигналит о появлении занятой ячейки, а потребитель действует наоборот.
Эта схема избавляет от двух бед сразу. Нет порчи данных, потому что мьютекс сериализует доступ к самому буферу. И нет холостого ожидания: вместо опроса в цикле, съедающего процессор, оба процесса спят на семафорах и просыпаются ровно тогда, когда появляется работа или место. Производитель, упёршийся в полное кольцо, спокойно блокируется, пока потребитель не освободит ячейку, и наоборот.
Какие ловушки подстерегают при выходе и очистке ресурсов
Разделяемая память и семафоры обладают так называемой устойчивостью на уровне ядра: они переживают завершение создавшего их процесса и продолжают существовать, пока их явно не удалят или пока система не перезагрузится. Это удобно, когда процессы запускаются и останавливаются независимо, но оборачивается утечкой, если про очистку забыть. Брошенные объекты накапливаются в памяти, занимая место, пока кто-нибудь их не приберёт вручную или не уйдёт в перезагрузку.
Правильная схема очистки требует аккуратности. Отображение снимается отдельным вызовом, имя объекта отвязывается своим вызовом, семафоры закрываются и тоже отвязываются по имени. Тонкость в том, что отвязка имени лишь помечает объект на удаление, а физически он исчезает только тогда, когда последний процесс снимет своё отображение. Это сделано намеренно, чтобы не выдернуть память из-под того, кто ещё с ней работает. Грамотный демон вешает удаление на обработчик сигнала завершения, чтобы при штатной остановке прибрать за собой и не оставить мусор.
Особенно остро вопрос стоит в контейнерах. Объекты разделяемой памяти, переживающие процесс, в контейнерном окружении легко превращаются в потерянные: контейнер перезапустили, а старые объекты остались висеть. Поэтому в таких средах закладывают очистку, понимающую контейнерную специфику, чтобы при старте свежего экземпляра подобрать оставшийся от предыдущего хвост.
Отдельного внимания требует судьба семафора при аварийном падении владельца. Если процесс захватил мьютекс и рухнул, не успев его освободить, замок останется заперт навсегда, и все ожидающие зависнут насмерть. Это явление осиротевшего замка опаснее обычной утечки, потому что парализует работу живых процессов. Частичное лекарство дают так называемые робастные мьютексы, умеющие пометить замок как осиротевший, если державший его поток умер, и передать следующему ожидающему сигнал о том, что состояние под замком могло остаться несогласованным. Тогда новый владелец хотя бы узнаёт о проблеме и может попытаться восстановить целостность данных, вместо того чтобы зависнуть в неведении. Проектируя долгоживущую систему на разделяемой памяти, об устойчивости к падению участников думают заранее, а не после первого зависшего демона.
Чем атомарные операции дополняют семафоры на больших ядрах
Семафоры решают задачу синхронизации универсально, но у них есть цена: каждая операция захвата и освобождения это обращение к ядру, а оно не бесплатно. Для очень частых и очень коротких операций, вроде увеличения единственного счётчика, заход в ядро на каждый чих становится накладным. Здесь на сцену выходят атомарные операции, встроенные в современный язык C.
Атомарные типы позволяют выполнять чтение, запись и инкремент общей переменной так, что эти действия неделимы на уровне процессора, без всякой блокировки и без обращения к ядру. Если разделяемая структура содержит, скажем, счётчик готовности, объявление его атомарным с явным порядком работы с памятью даёт корректную синхронизацию без семафора и без расходов на системные вызовы. Практика обмена данными между процессами рекомендует начинать с простых семафоров ради ясности, а по мере роста нагрузки переходить на атомарные операции там, где это оправдано профилем производительности.
Есть и подводный камень, всплывающий именно на многоядерных системах. Если две независимые переменные, к которым обращаются разные процессы, случайно лежат в одной линии кеша процессора, аппаратура вынуждена постоянно синхронизировать эту линию между ядрами, хотя логически переменные не связаны. Это явление ложного разделения способно незаметно обрушить производительность. Лекарство в том, чтобы выравнивать критичные к скорости поля структуры по границе линии кеша, разнося независимые данные по разным линиям. Тонкость в том, что схема, прекрасно работающая на двух процессах, может повести себя совсем иначе на тридцати двух ядрах, поэтому тестировать разделяемую память нужно при реалистичном уровне параллелизма, а не на игрушечном примере из двух участников.
Сведём всё воедино. Разделяемая память через именованный объект и его отображение даёт непревзойдённую скорость обмена ценой ручной синхронизации. Семафоры превращают хаотичный доступ в упорядоченную очередь и снимают холостое ожидание, схема из трёх семафоров строит надёжное кольцо производитель-потребитель, а атомарные операции выжимают остатки производительности на горячих счётчиках. Не забывать про устойчивость объектов и очистку на выходе, выравнивать данные против ложного разделения и проверять всё на настоящей нагрузке. Тогда самый быстрый механизм межпроцессного обмена работает именно как ускоритель, а не как генератор тихо испорченных данных.