Разделяемая память: подводные камни и практические рекомендации
· Обновлено: · Го Комура · Разделяемая память, IPC, Конкурентность, C++, C#, Разработка Windows
История изменений (1 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619740)
Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.
Го Комура (2026). Разделяемая память: подводные камни и практические рекомендации. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/
- DOI (зарегистрированный архив)
- 10.5281/zenodo.21619740
- DOI (последняя зарегистрированная версия)
- 10.5281/zenodo.21619741
Кадры изображений, результаты проверок, временные ряды логов, данные биржевого стакана, огромные буферы. Когда внутри одной машины нужно с низкой задержкой обмениваться большими данными, разделяемая память выглядит очень привлекательно.
Опасность здесь небольшая, но важная: разделяемая память подходит к вам под видом «быстрого IPC». На деле это «IPC, который сокращает число копий и взамен возвращает ответственность за согласованность на сторону приложения».
- Быстро
- Гибко
- Но протокол приходится писать самим
- А если ломается, симптомы получаются эффектными
Примерно такой набор из четырёх пунктов.
flowchart TB
accTitle: Два лица разделяемой памяти
accDescr: Разделяемая память подходит под видом быстрого IPC, но на деле это IPC, который сокращает число копий и возвращает ответственность за согласованность на сторону приложения.
f1["Лицо «быстрого IPC»"] --> f2["Как есть на самом деле"]
f2 --> f3["Можно сократить копии"]
f2 --> f4["Ответственность за согласованность — у приложения"]
f4 -.-> f5["Протокол свой · при сбое симптомы эффектные"]
Рис. 1: Разделяемая память подходит под видом «быстрого IPC» и возвращает ответственность за согласованность на сторону приложения.
Дальше, держа в уме file mapping в Windows и POSIX shm_open / mmap, разберём где на практике застревают с разделяемой памятью и какое проектирование снижает частоту сбоев.
Пишете ли вы на C/C++ или заходите через MemoryMappedFile в C# — суть почти та же. 1
Для кого это и какие предпосылки
Статья для разработчиков, которым сейчас нужно решить, как передавать большие данные между процессами на одной машине. Основной адресат — тот, кто в C / C++ напрямую трогает file mapping Windows или POSIX shm_open, но те же ямы ждут и того, кто входит через MemoryMappedFile в C#. Главы про ямы и про принципы проектирования (5 и 6) от языка не зависят.
Рабочий пример лежит в 6.9, там и C (Windows / MSVC), и C#. Со стороны POSIX в главе 7 показаны только соответствия имён API.
Термины, которые лучше зафиксировать заранее
В тексте несколько терминов остаются по-английски. Чтобы не споткнуться на первом появлении, сводим их здесь.
| Термин | Смысл |
|---|---|
| IPC (Inter-Process Communication) | Межпроцессное взаимодействие. Общий механизм обмена данными или сигналами с другим процессом. Сюда входят pipe, socket, named pipe, разделяемая память и т. д. |
| coherent | Несколько view, указывающих на одну и ту же сущность, в один момент времени показывают одно и то же содержимое. Это не значит «читатель всегда может прочитать согласованную, уже обновлённую запись» |
| ABI (Application Binary Interface) | Не договор на уровне исходников, а бинарное обещание между исполняемыми файлами. Сюда входят размеры типов, alignment, padding, порядок полей в структуре |
| SPSC / MPSC / SPMC / MPMC | Сокращение числа producer и consumer. S = single, M = multi, P = producer, C = consumer. В SPSC один writer и один reader. Развёрнуто в 4.2 |
| lock-free | Схема, которая идёт вперёд только атомарными операциями, без захвата блокировки. Это гарантия продвижения — «какой-то один поток всегда может продвинуться», — а не синоним «быстро» |
| sentinel | Зарезервированное особое значение со смыслом «недействительно» или «конец». Для offset, например, можно решить, что UINT64_MAX значит «недействительно» |
| NUMA (Non-Uniform Memory Access) | Конфигурация, в которой «удалённость» памяти с точки зрения CPU неоднородна. Обращение к памяти дальнего узла заметно замедляет тот же самый код |
1. Сначала вывод (одной фразой)
Грубо, но полезно для практики, получается так.
- Разделяемая память — механизм, который показывает одну и ту же последовательность байтов нескольким процессам, а не саму синхронизацию 23
- Быстрой она бывает при обмене большими данными внутри одной машины. Для мелких управляющих сообщений часто удобнее pipe / socket / named pipe / очередь
- В разделяемой памяти увидеть и безопасно прочитать — разные вопросы
volatileлучше не делать фундаментом проектирования. Атомарность, порядок и ожидание продумывают отдельно 45- Если положить как есть сырой указатель,
HANDLE, file descriptor,std::string,std::vector,std::mutex, потом почти наверняка будет больно - Данные в разделяемой памяти безопаснее сводить к целым фиксированной ширины + явной раскладке + заголовку с версией
- Уже одно размещение в заголовке magic / version / size / state / generation / heartbeat сильно меняет, насколько легко потом разбирать инцидент
- Сложность разделяемой памяти не в скорости, а в инициализации, времени жизни, восстановлении, правах и ABI
- В Windows каркас —
CreateFileMapping/OpenFileMapping/MapViewOfFile, в POSIX —shm_open/ftruncate/mmap63 - Меньше всего ломается старт с SPSC-кольцевого буфера (single-producer single-consumer) или двойного буфера
Иначе говоря: разделяемая память быстрая, но при небрежном использовании легко подхватить «болезнь ощущения, будто всё и так синхронизируется». Избежать этого — первая задача.
flowchart TB
accTitle: Разница между «увидеть» и «безопасно прочитать»
accDescr: Разделяемая память показывает одну и ту же последовательность байтов нескольким процессам и сама по себе не является синхронизацией; увидеть значение и безопасно его прочитать — разные вопросы.
k1["Механизм, который показывает одну и ту же последовательность байтов"] --> k2["Это не сама синхронизация"]
k2 --> k3["Увидеть"]
k2 --> k4["Безопасно прочитать"]
k3 --> k5["Проектировать как разные вопросы"]
k4 --> k5
Рис. 2: В разделяемой памяти «увидеть» и «безопасно прочитать» — разные вопросы.
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Что разделяемая память разделяет, а что — нет
Грубо говоря, разделяемая память отображает одни и те же физические страницы в виртуальные адресные пространства нескольких процессов.
В Windows используют объект file mapping и view, в POSIX объект разделяемой памяти отображают через mmap. 273
Здесь важны два пункта.
- Разделяется содержимое — последовательность байтов, а не сами виртуальные адреса
- Быть coherent и быть синхронизированным — разные вещи
flowchart TB
accTitle: Одни и те же физические страницы глазами разных view
accDescr: Разделяемая память отображает одни и те же физические страницы в виртуальные адресные пространства нескольких процессов; разделяется содержимое — последовательность байтов, а не сами виртуальные адреса.
pa["view процесса A"] --> pp["Одни и те же физические страницы"]
pb["view процесса B"] --> pp
pp -.-> pn["Разделяются байты, а не виртуальные адреса"]
Рис. 3: Разделяется последовательность байтов одних и тех же физических страниц, а не сами виртуальные адреса.
В документации Windows тоже сказано: view, созданные из одного и того же объекта file mapping, в один момент времени coherent. Но это не значит, что читатель всегда может прочитать согласованную, уже обновлённую запись. 8
Например, даже если writer собирается записать
length- затем
payload - затем
ready flag
именно в этом порядке, reader без какой-либо синхронизации может увидеть комбинацию нового length и старого payload.
Разделяемая память это сама не чинит.
То есть разделяемая память разделяет байты. Не разделяет смысл, порядок, уведомление о завершении и политику восстановления. Это всё приходится проектировать самим.
flowchart TB
accTitle: Что разделяемая память разделяет и чего не разделяет
accDescr: Разделяемая память разделяет только байты; смысл, порядок, уведомление о завершении и политику восстановления она не разделяет — это проектирует сторона приложения.
s1["Разделяемая память"] --> s2["Разделяет байты"]
s1 --> s3["Не разделяет"]
s3 --> s4["Смысл и порядок"]
s3 --> s5["Уведомление о завершении и политику восстановления"]
s4 --> s6["Это проектируем мы"]
s5 --> s6
Рис. 4: Байты разделяются; смысл, порядок, уведомление о завершении и политику восстановления проектируете сами.
3. Где разделяемая память уместна, а где нет
| Ситуация | Уместно / нет | Почему |
|---|---|---|
| Передавать крупные кадры или буферы внутри одной машины | Уместно | Легко сократить число копий |
| Частые показания датчиков, изображения, звук, данные стакана и т. п. | Уместно | Проще целиться в низкую задержку и высокую пропускную способность |
| Обмениваться только мелкими командами и ответами | Скорее нет | Относительная цена синхронизации управления велика |
| Обмениваться с другой машиной | Нет | Разделяемая память в основе рассчитана на один хост |
| Долго сосуществуют разные языки и разные версии | Сложно | Нужны ABI и версионирование |
| Нужна ещё и персистентность | Зависит от цели | File-backed mapping — рабочий вариант, но ответственность за персистентность и за IPC легко смешать |
На практике очень устойчиво разделение управление — через сообщения, тело данных — через разделяемую память. Например:
- процесс UI сообщает процессу worker «используй следующий кадр» через event / pipe / socket
- сам кадр лежит в разделяемой памяти
Такая конфигурация обычно спокойная.
flowchart TB
accTitle: Управление через сообщения, тело данных — через разделяемую память
accDescr: Уведомление «используй следующий кадр» от процесса UI к процессу worker идёт через event, pipe или socket, а сам кадр передаётся через разделяемую память.
ui["Процесс UI"] -->|"уведомление (event / pipe / socket)"| wk["Процесс worker"]
ui -.->|"пишет тело кадра"| shm["Разделяемая память"]
wk -.->|"читает тело кадра"| shm
Рис. 5: Уведомление идёт через сообщения, в разделяемой памяти лежит только тело кадра.
4. Четыре вещи, которые нужно решить в первую очередь
При проектировании разделяемой памяти сначала решают следующие четыре пункта.
flowchart TB
accTitle: Четыре вещи, которые решают сначала
accDescr: При проектировании разделяемой памяти сначала решают разделение control plane и data plane, модель конкурентности, владельца и время жизни, ABI и версию.
d0["Проектирование разделяемой памяти"] --> d1["Разделение plane"]
d0 --> d2["Модель конкурентности"]
d0 --> d3["Владелец и время жизни"]
d0 --> d4["ABI и версия"]
Рис. 6: Сначала фиксируют разделение, модель конкурентности, владельца со временем жизни и ABI.
4.1 Разделить control plane и data plane
Сначала решают, что именно кладут в shared memory.
- data plane: изображения, звук, последовательности записей, объёмные данные
- control plane: запуск, остановка, ошибки, переподключение, повторная инициализация, уведомления
Одно это разделение заметно упрощает проектирование стороны shared memory.
4.2 Сузить модель конкурентности
- SPSC: 1 producer / 1 consumer
- MPSC: несколько writer / 1 consumer
- SPMC: 1 writer / несколько reader
- MPMC: несколько writer / несколько reader
Сложность примерно в этом порядке растёт. Сразу идти в MPMC обычно не стоит. Придётся одновременно закрывать взаимное исключение между writer и порядок памяти, и потом из тестов вылезают плохо воспроизводимые сбои.
flowchart TB
accTitle: Сложность моделей конкурентности
accDescr: Сложность примерно растёт в порядке SPSC, MPSC, SPMC, MPMC; если сразу идти в MPMC, придётся одновременно закрывать взаимное исключение и порядок памяти.
m1["SPSC (1 запись / 1 чтение)"] --> m2["MPSC (много записей / 1 чтение)"]
m2 --> m3["SPMC (1 запись / много чтений)"]
m3 --> m4["MPMC (много записей / много чтений)"]
m4 -.-> m5["Одновременно закрывать исключение и порядок памяти"]
Рис. 7: Сложность моделей конкурентности примерно растёт от SPSC к MPMC.
4.3 Определить владельца и время жизни
- Кто создаёт
- Кто инициализирует
- Кто удаляет
- Кто восстанавливает, если участник упал на середине работы
Если это размыто, поведение плывёт при каждом порядке запуска и каждом перезапуске, и причину становится трудно отделить.
4.4 Определить ABI и версию
- Раскладка
- Размеры типов
- alignment
- Области reserved
- version / feature flags
- Есть ли совместимость
Shared memory — это разговор не про API, а про ABI (binary interface). Если здесь срезать углы, получается неприятный сбой: исходники совместимы, а ломается только во время выполнения.
flowchart TB
accTitle: Shared memory — это разговор про ABI
accDescr: Shared memory — не API, а бинарное обещание ABI; если здесь срезать углы, исходники остаются совместимыми, а ломается только выполнение.
a1["shared memory"] --> a2["Не API, а обещание ABI"]
a2 --> a3["Если срезать углы"]
a3 --> a4["Исходники совместимы, выполнение ломается"]
Рис. 8: Shared memory — это обещание ABI; если срезать углы, ломается выполнение при совместимых исходниках.
5. Частые подводные камни
5.1 Нет синхронизации
Это самая частая проблема.
«Мы смотрим на одну и ту же память, значит записанное наверняка можно прочитать».
Прочитать иногда можно. Но это не значит, что прочитать можно в правильный момент, правильной единицей и в правильном порядке.
И в Windows, и в POSIX доступ к разделяемой памяти предполагается вместе с отдельным средством синхронизации. В документации Windows тоже написано: доступ к общим view нужно согласовывать через mutex / semaphore / event. 2 В материалах по POSIX тоже сказано, что доступ к shared memory требует синхронизации. 9
flowchart TB
accTitle: Яма «записал — значит прочитают»
accDescr: То, что память одна и ту же и значение иногда читается, ещё не гарантия правильного момента, единицы и порядка; доступ к разделяемой памяти предполагается вместе со средством синхронизации.
g1["«Записал — значит прочитают»"] --> g2["Прочитать иногда можно"]
g2 --> g3["Правильные момент, единица и порядок — отдельно"]
g3 --> g4["Предполагается вместе со средством синхронизации"]
g4 -.-> g5["mutex / semaphore / event и т. д."]
Рис. 9: Прочитать и прочитать правильной единицей в правильном порядке — разные вещи; средство синхронизации — предпосылка.
5.2 Попытаться закрыть всё через volatile
volatile — не волшебство, которое спасает проектирование разделяемой памяти.
Как минимум atomicity и mutual exclusion — разные вопросы. 45
Например, схема с volatile bool ready; и busy loop:
- впустую тратит CPU
- размывает гарантию порядка между payload и ready
- не переносима
- легко ловит промежуточные состояния
В целом хорошего из этого почти не выходит.
Кроме того, WaitOnAddress в Windows рассчитан на потоки внутри одного процесса.
Как межпроцессный механизм ожидания его лучше не рассматривать. 10
flowchart TB
accTitle: Проблемы схемы, которая сторожит через volatile
accDescr: Схема, в которой volatile bool крутят в busy loop, впустую тратит CPU, размывает гарантию порядка и легко ловит промежуточные состояния, поэтому её не стоит делать фундаментом проектирования.
v1["Сторожить volatile bool в busy loop"] --> v2["Впустую тратит CPU"]
v1 --> v3["Гарантия порядка размыта"]
v1 --> v4["Легко ловит промежуточные состояния"]
v2 --> v5["Не делать фундаментом проектирования"]
v3 --> v5
v4 --> v5
v5 -.-> v6["WaitOnAddress тоже для потоков внутри одного процесса"]
Рис. 10: Busy loop на volatile проигрывает сразу в трёх местах: CPU, порядок, промежуточные состояния.
5.3 Дать прочитать промежуточное состояние
Когда разделяемая память ломается, симптомы выглядят вполне обыденно.
- Обновился только заголовок
- Устарел только payload
- Обновилась только длина
- Пара из двух полей оказалась несогласованной
На схеме авария устроена просто: reader заходит в щель до того, как writer допишет length и payload.
sequenceDiagram
participant W as процесс writer
participant M as разделяемая память
participant R as процесс reader
W->>M: пишет 1024 в length
Note over M: length уже новый<br/>payload ещё старый
R->>M: читает length
M-->>R: 1024
R->>M: читает payload на 1024 байта
M-->>R: содержимое предыдущего поколения
Note over R: «обновился только заголовок»<br/>хватает промежуточное состояние
W->>M: пишет payload
W->>M: поднимает ready flag
Рис. 11: Reader заходит в щель до того, как writer допишет payload, и хватает промежуточное состояние.
Эта щель есть всегда, пока запись length и payload — не одна неделимая операция. Если атомарно обновляется один scalar, всё относительно просто. Если публикуется запись из нескольких полей, нужна процедура commit.
Обычно это одно из следующего:
- закрыть всё целиком через mutex
- сделать двойной буфер и в конце переключить «номер сейчас действующего буфера»
- сделать кольцевой буфер и держать state / sequence у каждого слота
- при 1 writer / нескольких reader снимать snapshot через sequence counter
Даже «поднять ready flag последним» ещё сырое решение, пока не определено, в каком порядке памяти этот флаг пишут и читают. В разделяемой памяти сам момент публикации и есть протокол.
5.4 Класть указатели или сложные объекты «как есть»
Ещё один частый шаблон.
- Сырой указатель
HANDLE- file descriptor
std::stringstd::vectorstd::unordered_mapstd::mutexCRITICAL_SECTION
Их кладут в shared memory как есть и пытаются использовать из другого процесса. В процессе-читателе это почти наверняка access violation или бессмысленное значение.
Причина простая: виртуальные адреса и process-local ресурсы имеют смысл только в контексте своего процесса. И view в Windows: даже если тот же mapping отобразить в другом процессе, виртуальные адреса не обязаны совпасть. 711
Поэтому, если нужна ссылка, базовый подход — хранить её как offset от базового адреса.
typedef struct ShmRef {
uint64_t offset; // относительная позиция от начала сегмента
uint32_t length;
uint32_t kind;
} ShmRef;
Тогда каждый процесс приводит это к своему адресу как base + offset.
flowchart TB
accTitle: Ссылаться offset, а не указателем
accDescr: Виртуальные адреса и process-local ресурсы имеют смысл только в контексте своего процесса, поэтому ссылку держат как offset от базового адреса, и каждый процесс разрешает её как base+offset.
p1["Класть сырой указатель или HANDLE"] --> p2["В другом процессе — бессмысленное значение"]
p2 -.->|"вместо этого"| p3["Держать как offset"]
p3 --> p4["Каждый процесс разрешает base+offset"]
Рис. 12: Ссылку держат не сырым указателем, а как offset от базового адреса.
5.5 Ломается ABI
Shared memory — это не исходный код, а бинарный договор. То есть работают все следующие различия.
- Размер
int/long - Представление
bool - Underlying type у
enum - Размер
wchar_t - Разница 32-bit / 64-bit
#pragma pack- Разница компилятора / языка
- alignment / padding
- little-endian / big-endian
Внутри одного хоста endianness обычно совпадает, но достаточно поддержки ARM64 или смешанного toolchain, и расхождение становится вполне обыденным.
Поэтому для структур в shared memory настоятельно рекомендуют:
- целые фиксированной ширины вроде
uint32_t/uint64_t - явные поля padding / reserved
- в header —
version,header_size,record_size,total_size - при необходимости
static_assert(sizeof(...)) - не класть нетривиальные объекты
flowchart TB
accTitle: Какие различия ломают ABI и как это закрывать
accDescr: Различия размеров типов, alignment, pack и padding, 32-bit и 64-bit ломают бинарный договор, поэтому их закрывают целыми фиксированной ширины, явной раскладкой и заголовком с версией.
b1["Разница размеров типов"] --> b4["Бинарный договор ломается"]
b2["Разница pack и padding"] --> b4
b3["Разница 32-bit / 64-bit"] --> b4
b4 --> b5["Целые фиксированной ширины + явная раскладка"]
b5 --> b6["Положить version и size в header"]
Рис. 13: Различия размеров типов и padding ломают ABI, поэтому сводят к целым фиксированной ширины и явной раскладке.
5.6 Гонка инициализации
Shared memory легко ломается на допущении «раз кто-то создал, он же и инициализировал».
В Windows, если CreateFileMapping натыкается на уже существующее имя, он возвращает существующий объект, а GetLastError() даёт ERROR_ALREADY_EXISTS.
Начальные страницы pagefile-backed mapping заполнены нулями. 8
В POSIX новый объект shared memory сначала имеет длину 0, размер задают через ftruncate. Вновь выделенные байты инициализируются нулями. Создание через O_CREAT | O_EXCL атомарно. 3
Если, не зная этой разницы,
- сразу пользоваться после open
- не иметь флага завершения инициализации
- позволить участникам инициализировать одновременно
- не проверять несовпадение версий,
результат зависит от порядка запуска и может сломаться.
Как минимум в заголовок стоит положить такие state:
INITIALIZINGREADYBROKEN
И пусть инициализирует только creator, а joiner ждёт READY.
Одна эта дисциплина заметно успокаивает обстановку.
flowchart TB
accTitle: Как избегать гонки инициализации
accDescr: В заголовок кладут state INITIALIZING, READY и BROKEN; инициализирует только creator и поднимает READY, joiner начинает пользоваться только после ожидания READY — так избегают гонки инициализации.
c1["Creator создаёт"] --> c2["Инициализирует только creator"]
c2 --> c3["Ставит state в READY"]
j1["Joiner после open сразу не пользуется"] --> j2["Ждёт READY"]
c3 -.-> j2
j2 --> j3["Начинает пользоваться"]
Рис. 14: Инициализирует только creator; joiner начинает пользоваться после ожидания READY в заголовке.
5.7 Нет плана восстановления после аварийного завершения
Что делать, если writer падает во время обновления общих данных? Если оставить это неопределённым и выпустить в продакшн, картина при инциденте резко становится серьёзной.
Mutex в Windows становится abandoned, если владеющий поток завершается без release, и ожидающая сторона получает WAIT_ABANDONED. Это значит, что общий ресурс может быть в неопределённом состоянии. 12
У robust mutex в POSIX тоже: когда владелец умирает, возвращается EOWNERDEAD, а после восстановления состояния вызывают pthread_mutex_consistent(). 1314
Важно здесь — не «продолжать как ни в чём не бывало». Для восстановления нужен как минимум один из следующих механизмов:
- номер generation
- последняя зафиксированная sequence
- heartbeat
- флаг dirty / clean
- журнальный двухфазный commit
- процедура полной повторной инициализации при повреждении
flowchart TB
accTitle: Когда writer падает во время обновления
accDescr: Если владелец завершается без release, в Windows приходит WAIT_ABANDONED, у POSIX robust mutex — EOWNERDEAD; общий ресурс может быть в неопределённом состоянии, поэтому не продолжают как есть, а переходят к восстановлению.
w1["Writer падает во время обновления"] --> w2["WAIT_ABANDONED (Windows)"]
w1 --> w3["EOWNERDEAD (POSIX robust)"]
w2 --> w4["Общий ресурс может быть в неопределённом состоянии"]
w3 --> w4
w4 --> w5["Не продолжать как есть"]
w5 -.-> w6["generation / heartbeat / двухфазный commit / повторная инициализация"]
Рис. 15: Если владелец упал, предполагают неопределённое состояние и идут в процедуру восстановления, а не продолжают.
5.8 False sharing и конкуренция за cache line
Про разделяемую память часто говорят, что она быстрая. Но если «горячие» счётчики сидят в одной cache line, линия бегает между процессорами, и тормоза уже нельзя игнорировать.
Классический пример:
- producer обновляет
write_index - consumer обновляет
read_index - оба поля лежат в одной cache line
В этом случае помогают:
- разнести hot field по разным cache line
- отделить часто обновляемые field от редких
- держать в уме «1 writer — 1 cache line»
Этого уже достаточно, чтобы картина заметно изменилась. Про выравнивание на 64 байта говорят часто, но относитесь к этому как к «64 байта — распространённое, но не абсолютное значение для многих CPU».
flowchart TB
accTitle: Типичный false sharing и что с ним делать
accDescr: Если write_index, который обновляет producer, и read_index, который обновляет consumer, сидят в одной cache line, линия бегает между CPU и становится медленно; hot field разносят по разным cache line.
fp["Producer обновляет write_index"] --> fl["Одна и та же cache line"]
fc["Consumer обновляет read_index"] --> fl
fl --> fs["Линия бегает между CPU и становится медленно"]
fs --> fx["Разнести hot field по разным cache line"]
Рис. 16: Если горячие счётчики сидят в одной cache line, становится медленно — их разносят по разным line.
5.9 Недооценка имён, прав и безопасности
Именованная разделяемая память удобна, но небрежность с именами и правами приводит к сбоям.
В Windows есть такие особенности:
- есть пространства имён
Global\иLocal\ - чтобы создать file mapping в
Global\не из session 0, нуженSeCreateGlobalPrivilege - имена объектов делят пространство имён с event / semaphore / mutex / waitable timer / job
Иначе говоря:
- кажется, что если назвать объект
"Global\\MyApp", его смогут делить служба и desktop-приложение - но срывается из-за прав
- и вдобавок, если mutex с тем же именем уже создали раньше, получаем
ERROR_INVALID_HANDLE
Вылезает очень характерная для Windows грязь.
flowchart TB
accTitle: Грязь пространства имён Global
accDescr: Если для общего доступа службы и desktop-приложения взять имя в Global, можно сорваться на правах без SeCreateGlobalPrivilege или столкнуться с одноимённым mutex, который уже занял общее пространство имён.
n1["Хотим делить через имя в Global"] --> n2["Срыв из-за прав"]
n1 --> n3["Столкновение с одноимённым mutex"]
n2 -.-> n4["Для создания нужен SeCreateGlobalPrivilege"]
n3 -.-> n5["Получаем ERROR_INVALID_HANDLE"]
Рис. 17: Пространство имён Global легко наступает на две лужи: права и столкновение имён.
На стороне POSIX небрежность с mode или umask у shm_open тоже приводит либо к излишней открытости, либо, наоборот, к тому, что объект нельзя открыть. 3
Shared memory — это не «просто память, значит безопасно». Из любого процесса с правом на чтение она видна вполне прямо. Если кладёте конфиденциальные данные, нужно думать в тех же категориях, что и для обычной памяти: paging / swap / dump / права.
5.10 Небрежно менять размер и обновлять
Желание «потом чуть расширить» разделяемую память — довольно опасный запрос.
- У объекта mapping в Windows размер фиксируется при создании 8
- В POSIX тоже: если не следить за согласованностью
ftruncateиmmap, длина отображения у участников перестаёт совпадать 316
На практике безопаснее сделать размер неизменным в пределах одного поколения. Если расширение нужно:
- создать сегмент с новой version / name / generation
- переключить на него участников
- закрыть старый сегмент
Так частота сбоев снижается.
flowchart TB
accTitle: Расширение размера — через смену поколения
accDescr: Размер разделяемой памяти в пределах поколения фиксируют; если нужно расширить, создают сегмент нового поколения, переключают участников и закрывают старый сегмент — так частота сбоев ниже, чем у resize in place.
z0["Хотим потом расширить"] --> z1["Создать сегмент нового поколения"]
z1 --> z2["Переключить участников"]
z2 --> z3["Закрыть старый сегмент"]
z0 -.-> z4["Resize in place опасен"]
Рис. 18: Размер фиксируют внутри поколения; расширение делают переключением на сегмент нового поколения.
5.11 Попытаться засунуть в разделяемую память даже уведомления
Частый шаблон:
- записать в разделяемую память
ready = 1 - на другой стороне крутить
while (!ready) Sleep(1);
Поначалу это работает. Но позже возвращается в виде:
- бесполезного расхода CPU
- дрожания задержки из-за
Sleep(1) - пропусков, которые трудно заметить
- сложности аккуратно написать тайм-аут и уведомление о завершении
Разделяемую память лучше держать на стороне данных, а уведомления выносить на примитивы, которых можно ждать.
- Windows: event / semaphore / mutex / named pipe и т. п. 217
- POSIX: semaphore / process-shared mutex + condvar и т. п. 1819
flowchart TB
accTitle: Уведомления выносят на примитив, которого можно ждать
accDescr: Уведомление через флаг в разделяемой памяти и Sleep тратит CPU, даёт дрожание задержки и пропуски, поэтому разделяемую память оставляют стороне данных, а уведомления выносят на event, semaphore и другие примитивы, которых можно ждать.
t1["Сторожить ready flag через Sleep"] --> t2["Трата CPU и дрожание задержки"]
t1 -.-> t6["Пропуски трудно заметить"]
t2 --> t3["Уведомление — на примитив, которого можно ждать"]
t3 --> t4["В Windows — event / semaphore и т. п."]
t3 --> t5["В POSIX — semaphore / condvar и т. п."]
Рис. 19: Не сторожить флаг, а принимать уведомление через примитив, которого можно ждать.
5.12 Мысль «так можно разделять данные и с другими машинами»
Бывает момент, когда хочется подумать: если взять file-backed mapping и отобразить сетевой общий файл, не получится ли обмен наподобие разделяемой памяти и с другими машинами.
Это опасно.
В документации по CreateFileMapping в Windows тоже сказано, что для удалённых файлов coherence не гарантируется.
Если две машины отображают одну и ту же страницу как writable, каждая видит только свою запись, и при обновлении диска слияния (merge) не происходит. 8
Разделяемая память в основе своей — механизм внутри одного хоста. Если нужно выходить за пределы машины, честнее сразу выбрать socket / RPC / брокер сообщений — так проще сохранить рассудок.
flowchart TB
accTitle: С другими машинами так не делят
accDescr: Даже если отобразить общий файл по сети, для remote file coherence не гарантируется; разделяемая память — механизм внутри одного хоста, поэтому при выходе за машину выбирают socket, RPC или брокер сообщений.
r1["Хотим делить, отобразив remote file"] --> r2["Coherence не гарантируется"]
r2 --> r3["Разделяемая память — механизм внутри одного хоста"]
r3 --> r4["К socket / RPC / брокеру сообщений"]
Рис. 20: Разделяемая память — механизм внутри одного хоста; если нужно через машины — выбирают сообщения.
6. Практические рекомендации
6.1 Разделить control plane и data plane
Если разделение из 4.1 довести до назначения на уровне реализации, получается так (неудачную форму см. в 5.11).
- shared memory: frame, sample, batch, snapshot
- event / semaphore / pipe / socket: ready, consumed, stop, error, reconnect
Это разделение улучшает прозрачность проектирования ещё до того, как повлияет на производительность.
6.2 Положить фиксированный заголовок в начало
Как минимум настоятельно рекомендуем такой заголовок в начале.
typedef struct SharedHeader {
uint32_t magic;
uint16_t abi_version;
uint16_t header_size;
uint32_t state; // 0=initializing, 1=ready, 2=broken
uint32_t flags;
uint64_t total_size;
uint64_t generation;
uint64_t heartbeat_ns;
uint64_t payload_offset;
uint64_t payload_size;
uint64_t write_seq;
uint64_t read_seq;
uint8_t reserved[64];
} SharedHeader;
Суть в том, что
magicотсекает чужое или неинициализированноеabi_versionиheader_sizeотсекают различия раскладкиstateотсекает незавершённую инициализациюgenerationпозволяет обнаружить повторное созданиеheartbeatпозволяет следить за жизнеспособностьюreservedоставляет запас для будущего расширения
Сложность разделяемой памяти в том, что «трудно увидеть, что происходит». Именно поэтому с самого начала закладывают метаданные для наблюдаемости.
flowchart TB
accTitle: Роль полей фиксированного заголовка
accDescr: Magic в заголовке отсекает чужое и неинициализированное, abi_version и header_size — различия раскладки, state — незавершённую инициализацию, generation позволяет обнаружить повторное создание, heartbeat — следить за жизнеспособностью.
h0["Фиксированный заголовок в начале"] --> h1["magic отсекает чужое"]
h0 --> h2["version отсекает различия"]
h0 --> h3["state отсекает незавершённое"]
h1 --> h4["Становятся метаданными для наблюдения"]
h2 --> h4
h3 --> h4
h0 -.-> h5["generation обнаруживает повторное создание"]
h5 -.-> h6["heartbeat следит за жизнеспособностью"]
Рис. 21: Поля фиксированного заголовка по отдельности отсекают чужое, различия раскладки и незавершённую инициализацию.
6.3 Ссылаться через offset
Ссылки хранят не как pointer, а как offset.
- Разрешать через
base + offset - Добавить проверку диапазона
offset + length - Определить sentinel для некорректных данных
Уже это заметно снижает число инцидентов из-за несовпадения адресов.
6.4 Сузить модель конкурентности
Из четырёх моделей в 4.2 сначала стоит выбрать один из двух вариантов.
- SPSC ring buffer
- snapshot по схеме 1 writer / несколько reader
SPSC-кольцевой буфер — это массив слотов фиксированной длины, в который producer пишет в позицию write_seq, а consumer читает из позиции read_seq. Writer один и reader один, поэтому движение одностороннее.
flowchart LR
subgraph ring["Кольцевой буфер, 8 слотов"]
direction LR
s0["slot 0<br/>чтение закончено"]
s1["slot 1<br/>чтение закончено"]
s2["slot 2<br/>ещё не прочитан"]
s3["slot 3<br/>ещё не прочитан"]
s4["slot 4<br/>идёт запись"]
s5["slot 5<br/>свободен"]
s6["slot 6<br/>свободен"]
s7["slot 7<br/>свободен"]
end
C["consumer<br/>read_seq = 2<br/>сдвигает после чтения"] --> s2
P["producer<br/>write_seq = 4<br/>сдвигает после записи"] --> s4
s7 -.->|"дойдя до конца, возвращается к slot 0"| s0
Рис. 22: Устройство SPSC-кольцевого буфера. Producer и consumer односторонне двигают разные index.
Суть в том, чтобы не ломать порядок «сначала дописать, потом сдвинуть index» и «сначала дочитать, потом сдвинуть index». А write_seq и read_seq, как в 5.8, кладут на разные cache line.
Если нужны несколько writer, обычно лучше работает подход, который сокращает число точек ответственности за согласованность, например:
- lock-free / atomic остаётся только у самой операции enqueue
- фактическое обновление данных стягивается к одному consumer
6.5 Явно задать протокол commit
Проектирование, в котором нельзя словами объяснить, «с какого момента можно читать», опасно.
Например, для двойного буфера определяют ритуал публикации:
- писать в ещё непубликуемый буфер
- зафиксировать контрольную сумму и длину
- переключить индекс активного буфера с семантикой release
- reader читает активный индекс с семантикой acquire
- после чтения проверить, что индекс не изменился
На схеме сразу видно: момент переключения только один.
sequenceDiagram
participant W as writer
participant BA as буфер A
participant IX as active index
participant BB as буфер B
participant R as reader
Note over IX: active index — A
R->>IX: читает с acquire
IX-->>R: A
R->>BA: читает буфер A
W->>BB: пишет в ещё непубликуемый B
W->>BB: фиксирует длину и контрольную сумму
W->>IX: переключает на B с release
Note over IX: active index — B
R->>IX: после чтения перепроверяет index
IX-->>R: сменился на B
Note over R: прочитанное отбрасывает<br/>и читает заново из B
Рис. 23: Ритуал публикации двойного буфера. Reader после чтения ещё раз проверяет active index.
Если опустить шаг «после чтения перепроверить index», writer может начать повторно использовать тот же буфер, пока reader ещё читает, и получится то же промежуточное состояние, что в 5.3. Если сторон только две, во время повторного чтения переключение может случиться ещё раз, поэтому при быстрых обновлениях увеличивают число сторон или смещаются к способу sequence counter из 5.3.
6.6 Фиксировать размер по поколениям
Вместо resize in place в сопровождении удобнее резать поколения, например:
name = MyShm.v3abi_version = 3generation = 42
Разделяемая память не делает «проверку типов при вызове», как API. Именно поэтому важно не ломать однажды зафиксированный ABI.
6.7 Заложить наблюдаемость
Как минимум помогают следующие показатели.
- Время последнего обновления
- Последняя успешная sequence
- Число drop / overwrite
- Число несовпадений версий
- Число attach / detach
- Последний код ошибки
- Heartbeat
Когда разделяемая память ломается, логов обычно мало. Собственные счётчики заметно облегчают реакцию на инцидент.
6.8 Сначала писать тесты аварийных сценариев
Одного happy path недостаточно. Как минимум стоит проверить следующее.
- Принудительное завершение writer во время обновления
- Переполнение кольца из-за задержки reader
- Подключение при несовпадении версий
- Смешение 32-bit / 64-bit
- Open между разными сессиями
- Недостаточные права
- Перезапуск процесса-предшественника со старым поколением
- Промахи кэша / влияние NUMA при непрерывной передаче огромных данных
Для разделяемой памяти тесты на «поломку» ценнее, чем тесты happy path.
6.9 Минимальный образец обмена туда-обратно
Доведём приёмы выше до минимальной рабочей схемы. На pagefile-backed file mapping Windows кладут один блок фиксированной раскладки, а уведомления гоняют двумя auto-reset event.
Общие правила такие, четыре штуки.
- В блоке только целые фиксированной ширины и массивы фиксированной длины. Ни указателей, ни
HANDLE - В начале кладут
magic/abi_version/block_size/state - Сначала дописывают тело, потом фиксируют длину, и только после этого поднимают event
- Имена event другие, чем у file mapping (в Windows event / semaphore / mutex / waitable timer / job / file mapping делят пространство имён) 815
sequenceDiagram
accTitle: Обмен туда-обратно в минимальном образце
accDescr: Отправитель заканчивает инициализацию, пишет тело, фиксирует длину и поднимает event; получатель ждёт event, проверяет ABI и state, читает длину с проверкой диапазона и в том же порядке отвечает.
participant W as отправитель
participant M as общий блок
participant R as получатель
W->>M: инициализирует и ставит state в READY
W->>M: дописывает тело, затем фиксирует длину
W->>R: поднимает event запроса
R->>M: проверяет magic / version / state
R->>M: проверяет диапазон длины и читает
R->>M: ответ тоже: сначала тело, потом длина
R->>W: поднимает event ответа
Рис. 24: Обмен туда-обратно в минимальном образце. Длину фиксируют после тела, уведомление — после этого.
Сначала отправитель.
/* shm_writer.c : отправитель. Его запускают первым.
* cl /W4 /nologo shm_writer.c (kernel32.lib линкуется по умолчанию) */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#define SHM_NAME L"Local\\KsShmDemo.v1.Block"
#define EVT_REQ L"Local\\KsShmDemo.v1.Request"
#define EVT_REP L"Local\\KsShmDemo.v1.Reply"
#define SHM_MAGIC 0x314D4853u /* 'S','H','M','1' в little-endian */
#define SHM_ABI 1u
#define STATE_INITIALIZING 0u
#define STATE_READY 1u
#pragma pack(push, 8)
typedef struct DemoBlock {
uint32_t magic;
uint32_t abi_version;
uint32_t block_size;
uint32_t state;
uint32_t request_len;
uint32_t reply_len;
char request[256];
char reply[256];
} DemoBlock; /* 24 + 256 + 256 = 536 байт */
#pragma pack(pop)
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
DWORD waited = 0;
int rc = 1;
/* 1. Создаём pagefile-backed mapping. Начальные страницы — нули.
* Если имя уже занято, CreateFileMappingW «успешно» возвращает существующий
* объект, поэтому одной проверки на NULL второго отправителя не отсечь.
* Если пойти дальше в memset ниже, сотрёте общий блок у уже работающего
* партнёра. GetLastError() выставляется и при успехе — читайте сразу. */
hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE,
0, (DWORD)sizeof(DemoBlock), SHM_NAME);
if (hMap == NULL) {
printf("CreateFileMapping failed: %lu\n", GetLastError());
goto cleanup;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
printf("%ls уже используется. Отправитель может быть только один\n", SHM_NAME);
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
/* 2. Event для уведомлений. Имя — другое, чем у mapping */
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ); /* auto-reset / несигнальное */
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 3. Инициализирует только создатель. state поднимают последним */
memset(blk, 0, sizeof(*blk));
blk->magic = SHM_MAGIC;
blk->abi_version = SHM_ABI;
blk->block_size = (uint32_t)sizeof(DemoBlock);
blk->state = STATE_INITIALIZING;
MemoryBarrier();
blk->state = STATE_READY;
/* 4. Порядок «тело → барьер → длина → уведомление» не ломать */
strcpy_s(blk->request, sizeof(blk->request), "ping from writer");
MemoryBarrier();
blk->request_len = (uint32_t)strlen(blk->request);
if (!SetEvent(hReq)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 5. Ждём ответ. Бесконечно не ждём */
waited = WaitForSingleObject(hRep, 5000);
if (waited == WAIT_TIMEOUT) {
printf("Нет ответа от reader\n");
goto cleanup;
}
if (waited != WAIT_OBJECT_0) {
printf("WaitForSingleObject failed: %lu\n", GetLastError());
goto cleanup;
}
/* 6. Перед чтением проверяем, что длина в диапазоне */
len = blk->reply_len;
if (len > sizeof(blk->reply)) {
printf("reply_len вне диапазона: %u\n", len);
goto cleanup;
}
printf("reply: %.*s\n", (int)len, blk->reply);
rc = 0;
cleanup:
/* Когда закрыты все view и handle, имя исчезает. Не падать раньше reader */
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
Получатель берёт определения от SHM_NAME до DemoBlock в точности как у отправителя и подменяет только main. На практике эту общую часть выносят в заголовочный файл.
/* shm_reader.c : получатель. Константы и DemoBlock те же, что в shm_writer.c.
* cl /W4 /nologo shm_reader.c */
int main(void)
{
HANDLE hMap = NULL, hReq = NULL, hRep = NULL;
DemoBlock *blk = NULL;
uint32_t len = 0;
int rc = 1;
hMap = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, SHM_NAME);
if (hMap == NULL) {
printf("OpenFileMapping failed: %lu / writer уже запущен?\n", GetLastError());
goto cleanup;
}
blk = (DemoBlock *)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(DemoBlock));
if (blk == NULL) {
printf("MapViewOfFile failed: %lu\n", GetLastError());
goto cleanup;
}
hReq = CreateEventW(NULL, FALSE, FALSE, EVT_REQ);
hRep = CreateEventW(NULL, FALSE, FALSE, EVT_REP);
if (hReq == NULL || hRep == NULL) {
printf("CreateEvent failed: %lu\n", GetLastError());
goto cleanup;
}
/* 1. Сначала ждём уведомление. Writer поднимает его только после инициализации */
if (WaitForSingleObject(hReq, 5000) != WAIT_OBJECT_0) {
printf("Запрос так и не пришёл\n");
goto cleanup;
}
/* 2. До обращения проверяем ABI и state */
if (blk->magic != SHM_MAGIC || blk->abi_version != SHM_ABI ||
blk->block_size != (uint32_t)sizeof(DemoBlock)) {
printf("ABI не совпадает: magic=%08X abi=%u size=%u\n",
blk->magic, blk->abi_version, blk->block_size);
goto cleanup;
}
if (blk->state != STATE_READY) {
printf("Инициализация ещё не закончена: state=%u\n", blk->state);
goto cleanup;
}
/* 3. Сначала проверка диапазона длины, потом чтение */
len = blk->request_len;
if (len > sizeof(blk->request)) {
printf("request_len вне диапазона: %u\n", len);
goto cleanup;
}
printf("request: %.*s\n", (int)len, blk->request);
/* 4. Тело → барьер → длина → уведомление — тот же порядок, что у writer */
strcpy_s(blk->reply, sizeof(blk->reply), "pong from reader");
MemoryBarrier();
blk->reply_len = (uint32_t)strlen(blk->reply);
if (!SetEvent(hRep)) {
printf("SetEvent failed: %lu\n", GetLastError());
goto cleanup;
}
rc = 0;
cleanup:
if (hRep != NULL) CloseHandle(hRep);
if (hReq != NULL) CloseHandle(hReq);
if (blk != NULL) UnmapViewOfFile(blk);
if (hMap != NULL) CloseHandle(hMap);
return rc;
}
MemoryBarrier стоит затем, чтобы длина или флаг не стали видны раньше, чем дописано тело. 5
Если расхождение раскладки хотите ловить на сборке, в MSVC добавьте /std:c11 и зафиксируйте sizeof(DemoBlock) через static_assert из <assert.h>.
Тот же блок со стороны C# выглядит так. Смещения заданы константами — это главное, чтобы ни на байт не разъехаться со структурой на стороне C. Именованные MemoryMappedFile и EventWaitHandle — только для Windows. 1
// .NET 8 / Windows. Отправитель: dotnet run -- write, получатель: dotnet run -- read
using System.IO.MemoryMappedFiles;
using System.Text;
const string MapName = "Local\\KsShmDemo.v1.Block";
const string ReqName = "Local\\KsShmDemo.v1.Request";
const string RepName = "Local\\KsShmDemo.v1.Reply";
const uint Magic = 0x314D4853; // 'S','H','M','1'
const uint Abi = 1;
const int BlockSize = 536;
const int MaxBody = 256;
// Та же раскладка, что у DemoBlock на стороне C, зафиксирована константами смещений
const int OffMagic = 0, OffAbi = 4, OffBlockSize = 8, OffState = 12;
const int OffRequestLen = 16, OffReplyLen = 20, OffRequest = 24, OffReply = 280;
bool isWriter = args.Length > 0 && args[0] == "write";
// Отправитель использует CreateNew. CreateOrOpen открыл бы уже работающий блок
// вторым отправителем, и инициализация ниже сломала бы чужой обмен.
// CreateNew при занятом имени бросает IOException — это и есть сигнал
// (тот же смысл, что проверка ERROR_ALREADY_EXISTS на стороне C)
using var mmf = isWriter
? MemoryMappedFile.CreateNew(MapName, BlockSize)
: MemoryMappedFile.OpenExisting(MapName);
using var view = mmf.CreateViewAccessor(0, BlockSize);
using var reqEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ReqName);
using var repEvent = new EventWaitHandle(false, EventResetMode.AutoReset, RepName);
if (isWriter)
{
view.Write(OffMagic, Magic);
view.Write(OffAbi, Abi);
view.Write(OffBlockSize, (uint)BlockSize);
Thread.MemoryBarrier();
view.Write(OffState, 1u); // READY
byte[] request = Encoding.UTF8.GetBytes("ping from C#");
view.WriteArray(OffRequest, request, 0, request.Length);
Thread.MemoryBarrier();
view.Write(OffRequestLen, (uint)request.Length);
reqEvent.Set();
if (!repEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("Нет ответа от reader");
return 1;
}
return PrintBody(OffReplyLen, OffReply, "reply");
}
if (!reqEvent.WaitOne(TimeSpan.FromSeconds(5)))
{
Console.WriteLine("Запрос так и не пришёл");
return 1;
}
if (view.ReadUInt32(OffMagic) != Magic || view.ReadUInt32(OffAbi) != Abi
|| view.ReadUInt32(OffBlockSize) != BlockSize || view.ReadUInt32(OffState) != 1u)
{
Console.WriteLine("ABI не совпадает или инициализация ещё не закончена");
return 1;
}
if (PrintBody(OffRequestLen, OffRequest, "request") != 0)
{
return 1;
}
byte[] reply = Encoding.UTF8.GetBytes("pong from C#");
view.WriteArray(OffReply, reply, 0, reply.Length);
Thread.MemoryBarrier();
view.Write(OffReplyLen, (uint)reply.Length);
repEvent.Set();
return 0;
int PrintBody(int lenOffset, int bodyOffset, string label)
{
uint length = view.ReadUInt32(lenOffset);
if (length > MaxBody)
{
Console.WriteLine($"{label} имеет длину вне диапазона: {length}");
return 1;
}
byte[] body = new byte[length];
view.ReadArray(bodyOffset, body, 0, body.Length);
Console.WriteLine($"{label}: {Encoding.UTF8.GetString(body)}");
return 0;
}
Версии на C и на C# используют те же имена и ту же раскладку, поэтому одну сторону можно сделать отправителем, другую — получателем, и обмен всё равно сойдётся. Вот что значит зафиксировать ABI.
Этот образец намеренно только на один обмен туда-обратно. Если нужна непрерывная передача — к кольцевому буферу из 6.4; если нужно переживать аварийное завершение writer — к generation и heartbeat из 5.7.
7. На что смотреть в Windows и в POSIX
| Аспект | Windows | POSIX |
|---|---|---|
| Создание / open | CreateFileMapping / OpenFileMapping / MapViewOfFile 6 |
shm_open / ftruncate / mmap 3 |
| Обмен без привязки к диску | pagefile-backed mapping с INVALID_HANDLE_VALUE 68 |
Объект разделяемой памяти POSIX + mmap 3 |
| Начальные значения | Страницы pagefile-backed mapping инициализируются нулями 8 | Новый объект начинается с длины 0. Вновь выделенные байты инициализируются нулями 3 |
| Синхронизация | mutex / semaphore / event / interlocked и т. п. 25 | process-shared mutex / condvar / semaphore 2018 |
| Что нельзя использовать межпроцессно | CRITICAL_SECTION, WaitOnAddress 2110 |
mutex / condvar, оставленные как PTHREAD_PROCESS_PRIVATE 2019 |
| Смерть владельца | WAIT_ABANDONED 12 |
robust mutex + EOWNERDEAD / pthread_mutex_consistent() 1314 |
| Удаление имени | Исчезает при освобождении последнего handle / view 28 | shm_unlink удаляет имя. Объект сохраняется, пока остаются ссылки 2223 |
| Пространство имён / права | Global\ / Local\, ACL, SeCreateGlobalPrivilege 1524 |
mode, umask, пространство имён, O_CREAT|O_EXCL 3 |
MemoryMappedFile в C# по сути тоже обёртка над file mapping в Windows.
Поэтому основные правила не меняются:
- open по тому же имени
- отдельно использовать mutex / event
- читать view с явной раскладкой
- не класть ссылки на объекты как есть
flowchart TB
accTitle: У MemoryMappedFile те же основы
accDescr: MemoryMappedFile в C# по сути обёртка над file mapping Windows, поэтому основы те же: open по тому же имени, синхронизация отдельно через mutex или event, чтение с явной раскладкой, ссылки на объекты как есть не кладут.
cs["MemoryMappedFile в C#"] --> fm["Обёртка над file mapping"]
fm --> q1["Open по тому же имени"]
fm --> q2["Синхронизация отдельно: mutex / event"]
fm --> q3["Читать с явной раскладкой"]
q3 -.-> q4["Ссылки на объекты как есть не класть"]
Рис. 25: У MemoryMappedFile в C# те же основы, что у file mapping.
8. Чек-лист для первой проверки
- Действительно ли нужна разделяемая память? Речь о больших данных на одном хосте?
- Разделены ли control plane и data plane?
- Можно ли свести модель конкурентности к SPSC / 1 writer, несколько reader?
- Есть ли в заголовке magic / version / size / state / generation / heartbeat?
- Не кладёте ли pointer /
HANDLE/ fd / объект STL /std::mutex? - Есть ли протокол commit, из-за которого reader никогда не увидит промежуточное состояние?
- Определён ли ровно один инициализатор?
- Есть ли процедура восстановления при аварийном завершении?
- Явно ли заданы имена и права?
- Действительно ли необходим
Global\? - Не предполагается ли resize in place?
- Проверены ли kill writer / зависание reader / несовпадение версий / нехватка прав?
9. Итог
При грамотном использовании разделяемая память действительно сильная. Особенно для больших данных внутри одной машины, таких как:
- изображения
- звук
- потоки с датчиков
- крупные пакеты
- частые snapshot
она действительно окупается.
Но суть разделяемой памяти — не столько «скорость», сколько перенос ответственности. В обмен на меньшее число копий и меньше сообщений через ядро вы берёте на себя:
- синхронизацию
- видимость
- инициализацию
- ABI
- восстановление
- права
- наблюдаемость
flowchart TB
accTitle: Суть разделяемой памяти — перенос ответственности
accDescr: В обмен на меньшее число копий и меньше сообщений через ядро сторона приложения берёт на себя синхронизацию, видимость, инициализацию, ABI, восстановление, права и наблюдаемость.
e1["Меньше копий и сообщений"] --> e2["Взамен берёте на себя"]
e2 --> e3["Синхронизацию, видимость, инициализацию"]
e2 --> e4["ABI, восстановление, права"]
e2 --> e5["Наблюдаемость"]
Рис. 26: Суть разделяемой памяти — не «скорость», а перенос ответственности за согласованность на вашу сторону.
Поэтому для первой реализации безопасна такая форма:
- SPSC-кольцевой буфер или двойной буфер
- фиксированный заголовок в начале
- ссылки через offset
- уведомление через отдельный канал
- наличие version / generation / heartbeat
- наличие тестов аварийных сценариев
Начав именно с такой формы, разделяемая память становится вполне послушным инструментом. Если же с первого дня относиться к ней как к «быстрой общей памяти, куда можно класть что угодно», со временем это превращается уже не в приложение, а в археологию.
10. Справочные материалы
- Windows: основы file mapping и именованной разделяемой памяти 682
- Windows: пространство имён / безопасность / синхронизация 1524512
- POSIX:
shm_open,shm_unlink,mmap, process-shared / robust-синхронизация 322162013 - .NET: обзор
MemoryMappedFile1
-
Microsoft Learn, “Memory-Mapped Files” / Microsoft Learn, “MemoryMappedFile Class” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)” ↩ ↩2
-
Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function” ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, “Creating Named Shared Memory” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Scope of Allocated Memory” ↩ ↩2
-
Microsoft Learn, “CreateFileMappingA function” ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
man7.org, “POSIX Shared Memory” training slides ↩
-
Microsoft Learn, “WaitOnAddress function” ↩ ↩2
-
Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” ↩
-
Microsoft Learn, “Mutex Objects” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)” ↩ ↩2 ↩3
-
man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)” ↩ ↩2
-
Microsoft Learn, “Kernel object namespaces” ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, “Using Mutex Objects” ↩
-
man7.org, “sem_init(3)” / man7.org, “sem_init(3p)” ↩ ↩2
-
Microsoft Learn, “Critical Section Objects” ↩
-
man7.org, “shm_unlink(3p)” ↩ ↩2
-
man7.org, “shm_open(3)” (семантика shm_unlink) ↩
-
Microsoft Learn, “File Mapping Security and Access Rights” ↩ ↩2
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Чек-лист безопасной работы с дочерними процессами в Windows-приложении
Чтобы безопасно работать с дочерними процессами в Windows-приложении, важнее не API запуска, а владение деревом процессов и процедура зав...
Почему ломаются аргументы ── правила аргументов командной строки Windows
В Windows массива аргументов нет: в CreateProcess уходит одна строка, делит её принимающая сторона. Правила деления CommandLineToArgvW, C...
Что остаётся после падения родителя ── дерево дочерних процессов в Job Object
Почему после принудительного завершения UI остаётся помощник SDK и держит камеру или COM-порт. Как Job Object делает дерево процессов одн...
Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
wait на условной переменной может вернуться и без уведомления (ложное пробуждение). Почему спецификация это допускает и как правильно жда...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Обмен большими объёмами данных и проектирование изоляции процессов через разделяемую память, file mapping и MemoryMappedFile напрямую связаны с разработкой Windows-приложений.
Технические консультации и ревью дизайна
Проработка решений, которые снижают частоту сбоев — способ синхронизации, ABI, стратегия восстановления, разделение control plane и data plane — хорошо ложится на техническую консультацию и ревью архитектуры.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Можно ли сразу и корректно прочитать из другого процесса значение, записанное в разделяемую память?
- Увидеть значение и безопасно его прочитать — разные вопросы. Разделяемая память показывает одну и ту же последовательность байтов нескольким процессам; сама по себе она не является синхронизацией. Даже если writer собирается записать length, payload и ready flag именно в этом порядке, reader без какой-либо синхронизации может увидеть новый length вместе со старым payload. И в Windows, и в POSIX доступ к разделяемой памяти предполагается вместе со средствами синхронизации — mutex, semaphore, event.
- Можно ли размещать в разделяемой памяти указатели, std::string или HANDLE?
- Лучше не размещать. Виртуальные адреса и process-local ресурсы имеют смысл только в контексте своего процесса: даже если тот же mapping отобразить в другом процессе, виртуальные адреса не обязаны совпасть. То же относится к std::vector, std::mutex и CRITICAL_SECTION. Если нужна ссылка, её хранят как offset от базового адреса, а данные в разделяемой памяти сводят к целым фиксированной ширины, явной раскладке и заголовку с версией.
- Избавляет ли volatile от необходимости синхронизации в разделяемой памяти?
- Нет. volatile — не волшебство, которое спасает проектирование разделяемой памяти: как минимум atomicity и mutual exclusion — разные вопросы. Схема, в которой volatile bool крутят в busy loop, впустую тратит CPU, оставляет размытой гарантию порядка между payload и ready flag и легко ловит промежуточные состояния. Кроме того, WaitOnAddress в Windows рассчитан на потоки внутри одного процесса; как межпроцессный механизм ожидания его лучше не рассматривать. Уведомления выносят на примитивы, которых можно ждать, — event или semaphore.
- Что нужно решить в первую очередь при проектировании разделяемой памяти?
- Четыре вещи. Разделение control plane и data plane: управление (запуск, остановка, уведомления) идёт через сообщения, тело данных — через разделяемую память. Сужение модели конкурентности (сначала меньше ломаются SPSC-кольцевой буфер или двойной буфер). Владелец и время жизни: кто создаёт, инициализирует, удаляет и восстанавливает при аварийном завершении участника. И проектирование ABI, включая раскладку и версию. Уже одно наличие в заголовке magic, version, size, state, generation и heartbeat сильно меняет, насколько легко потом разбирать сбой.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.