Разделяемая память: подводные камни и практические рекомендации

· Обновлено: · · Разделяемая память, 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, который сокращает число копий и взамен возвращает ответственность за согласованность на сторону приложения».

  • Быстро
  • Гибко
  • Но протокол приходится писать самим
  • А если ломается, симптомы получаются эффектными

Примерно такой набор из четырёх пунктов.

Два лица разделяемой памятиРазделяемая память подходит под видом быстрого IPC, но на деле это IPC, который сокращает число копий и возвращает ответственность за согласованность на сторону приложения.Лицо «быстрого IPC»Как есть на самом делеМожно сократить копииОтветственность за согласованность — у приложенияПротокол свой · при сбое симптомы эффектные

Рис. 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 / mmap 63
  • Меньше всего ломается старт с SPSC-кольцевого буфера (single-producer single-consumer) или двойного буфера

Иначе говоря: разделяемая память быстрая, но при небрежном использовании легко подхватить «болезнь ощущения, будто всё и так синхронизируется». Избежать этого — первая задача.

Разница между «увидеть» и «безопасно прочитать»Разделяемая память показывает одну и ту же последовательность байтов нескольким процессам и сама по себе не является синхронизацией; увидеть значение и безопасно его прочитать — разные вопросы.Механизм, который показывает одну и ту же последовательность байтовЭто не сама синхронизацияУвидетьБезопасно прочитатьПроектировать как разные вопросы

Рис. 2: В разделяемой памяти «увидеть» и «безопасно прочитать» — разные вопросы.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Что разделяемая память разделяет, а что — нет

Грубо говоря, разделяемая память отображает одни и те же физические страницы в виртуальные адресные пространства нескольких процессов. В Windows используют объект file mapping и view, в POSIX объект разделяемой памяти отображают через mmap. 273

Здесь важны два пункта.

  1. Разделяется содержимое — последовательность байтов, а не сами виртуальные адреса
  2. Быть coherent и быть синхронизированным — разные вещи
Одни и те же физические страницы глазами разных viewРазделяемая память отображает одни и те же физические страницы в виртуальные адресные пространства нескольких процессов; разделяется содержимое — последовательность байтов, а не сами виртуальные адреса.view процесса AОдни и те же физические страницыview процесса BРазделяются байты, а не виртуальные адреса

Рис. 3: Разделяется последовательность байтов одних и тех же физических страниц, а не сами виртуальные адреса.

В документации Windows тоже сказано: view, созданные из одного и того же объекта file mapping, в один момент времени coherent. Но это не значит, что читатель всегда может прочитать согласованную, уже обновлённую запись. 8

Например, даже если writer собирается записать

  • length
  • затем payload
  • затем ready flag

именно в этом порядке, reader без какой-либо синхронизации может увидеть комбинацию нового length и старого payload. Разделяемая память это сама не чинит.

То есть разделяемая память разделяет байты. Не разделяет смысл, порядок, уведомление о завершении и политику восстановления. Это всё приходится проектировать самим.

Что разделяемая память разделяет и чего не разделяетРазделяемая память разделяет только байты; смысл, порядок, уведомление о завершении и политику восстановления она не разделяет — это проектирует сторона приложения.Разделяемая памятьРазделяет байтыНе разделяетСмысл и порядокУведомление о завершении и политику восстановленияЭто проектируем мы

Рис. 4: Байты разделяются; смысл, порядок, уведомление о завершении и политику восстановления проектируете сами.

3. Где разделяемая память уместна, а где нет

Ситуация Уместно / нет Почему
Передавать крупные кадры или буферы внутри одной машины Уместно Легко сократить число копий
Частые показания датчиков, изображения, звук, данные стакана и т. п. Уместно Проще целиться в низкую задержку и высокую пропускную способность
Обмениваться только мелкими командами и ответами Скорее нет Относительная цена синхронизации управления велика
Обмениваться с другой машиной Нет Разделяемая память в основе рассчитана на один хост
Долго сосуществуют разные языки и разные версии Сложно Нужны ABI и версионирование
Нужна ещё и персистентность Зависит от цели File-backed mapping — рабочий вариант, но ответственность за персистентность и за IPC легко смешать

На практике очень устойчиво разделение управление — через сообщения, тело данных — через разделяемую память. Например:

  • процесс UI сообщает процессу worker «используй следующий кадр» через event / pipe / socket
  • сам кадр лежит в разделяемой памяти

Такая конфигурация обычно спокойная.

Управление через сообщения, тело данных — через разделяемую памятьУведомление «используй следующий кадр» от процесса UI к процессу worker идёт через event, pipe или socket, а сам кадр передаётся через разделяемую память.уведомление (event / pipe / socket)пишет тело кадрачитает тело кадраПроцесс UIПроцесс workerРазделяемая память

Рис. 5: Уведомление идёт через сообщения, в разделяемой памяти лежит только тело кадра.

4. Четыре вещи, которые нужно решить в первую очередь

При проектировании разделяемой памяти сначала решают следующие четыре пункта.

Четыре вещи, которые решают сначалаПри проектировании разделяемой памяти сначала решают разделение control plane и data plane, модель конкурентности, владельца и время жизни, ABI и версию.Проектирование разделяемой памятиРазделение planeМодель конкурентностиВладелец и время жизни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 и порядок памяти, и потом из тестов вылезают плохо воспроизводимые сбои.

Сложность моделей конкурентностиСложность примерно растёт в порядке SPSC, MPSC, SPMC, MPMC; если сразу идти в MPMC, придётся одновременно закрывать взаимное исключение и порядок памяти.SPSC (1 запись / 1 чтение)MPSC (много записей / 1 чтение)SPMC (1 запись / много чтений)MPMC (много записей / много чтений)Одновременно закрывать исключение и порядок памяти

Рис. 7: Сложность моделей конкурентности примерно растёт от SPSC к MPMC.

4.3 Определить владельца и время жизни

  • Кто создаёт
  • Кто инициализирует
  • Кто удаляет
  • Кто восстанавливает, если участник упал на середине работы

Если это размыто, поведение плывёт при каждом порядке запуска и каждом перезапуске, и причину становится трудно отделить.

4.4 Определить ABI и версию

  • Раскладка
  • Размеры типов
  • alignment
  • Области reserved
  • version / feature flags
  • Есть ли совместимость

Shared memory — это разговор не про API, а про ABI (binary interface). Если здесь срезать углы, получается неприятный сбой: исходники совместимы, а ломается только во время выполнения.

Shared memory — это разговор про ABIShared memory — не API, а бинарное обещание ABI; если здесь срезать углы, исходники остаются совместимыми, а ломается только выполнение.shared memoryНе API, а обещание ABIЕсли срезать углыИсходники совместимы, выполнение ломается

Рис. 8: Shared memory — это обещание ABI; если срезать углы, ломается выполнение при совместимых исходниках.

5. Частые подводные камни

5.1 Нет синхронизации

Это самая частая проблема.

«Мы смотрим на одну и ту же память, значит записанное наверняка можно прочитать».

Прочитать иногда можно. Но это не значит, что прочитать можно в правильный момент, правильной единицей и в правильном порядке.

И в Windows, и в POSIX доступ к разделяемой памяти предполагается вместе с отдельным средством синхронизации. В документации Windows тоже написано: доступ к общим view нужно согласовывать через mutex / semaphore / event. 2 В материалах по POSIX тоже сказано, что доступ к shared memory требует синхронизации. 9

Яма «записал — значит прочитают»То, что память одна и ту же и значение иногда читается, ещё не гарантия правильного момента, единицы и порядка; доступ к разделяемой памяти предполагается вместе со средством синхронизации.«Записал — значит прочитают»Прочитать иногда можноПравильные момент, единица и порядок — отдельноПредполагается вместе со средством синхронизацииmutex / semaphore / event и т. д.

Рис. 9: Прочитать и прочитать правильной единицей в правильном порядке — разные вещи; средство синхронизации — предпосылка.

5.2 Попытаться закрыть всё через volatile

volatile — не волшебство, которое спасает проектирование разделяемой памяти. Как минимум atomicity и mutual exclusion — разные вопросы. 45

Например, схема с volatile bool ready; и busy loop:

  • впустую тратит CPU
  • размывает гарантию порядка между payload и ready
  • не переносима
  • легко ловит промежуточные состояния

В целом хорошего из этого почти не выходит.

Кроме того, WaitOnAddress в Windows рассчитан на потоки внутри одного процесса. Как межпроцессный механизм ожидания его лучше не рассматривать. 10

Проблемы схемы, которая сторожит через volatileСхема, в которой volatile bool крутят в busy loop, впустую тратит CPU, размывает гарантию порядка и легко ловит промежуточные состояния, поэтому её не стоит делать фундаментом проектирования.Сторожить volatile bool в busy loopВпустую тратит CPUГарантия порядка размытаЛегко ловит промежуточные состоянияНе делать фундаментом проектированияWaitOnAddress тоже для потоков внутри одного процесса

Рис. 10: Busy loop на volatile проигрывает сразу в трёх местах: CPU, порядок, промежуточные состояния.

5.3 Дать прочитать промежуточное состояние

Когда разделяемая память ломается, симптомы выглядят вполне обыденно.

  • Обновился только заголовок
  • Устарел только payload
  • Обновилась только длина
  • Пара из двух полей оказалась несогласованной

На схеме авария устроена просто: reader заходит в щель до того, как writer допишет length и payload.

процесс readerразделяемая памятьпроцесс writerпроцесс readerразделяемая памятьпроцесс writerlength уже новыйpayload ещё старый«обновился только заголовок»хватает промежуточное состояниепишет 1024 в lengthчитает length1024читает payload на 1024 байтасодержимое предыдущего поколенияпишет payloadподнимает 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::string
  • std::vector
  • std::unordered_map
  • std::mutex
  • CRITICAL_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.

Ссылаться offset, а не указателемВиртуальные адреса и process-local ресурсы имеют смысл только в контексте своего процесса, поэтому ссылку держат как offset от базового адреса, и каждый процесс разрешает её как base+offset.вместо этогоКласть сырой указатель или HANDLEВ другом процессе — бессмысленное значениеДержать как offsetКаждый процесс разрешает 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(...))
  • не класть нетривиальные объекты
Какие различия ломают ABI и как это закрыватьРазличия размеров типов, alignment, pack и padding, 32-bit и 64-bit ломают бинарный договор, поэтому их закрывают целыми фиксированной ширины, явной раскладкой и заголовком с версией.Разница размеров типовБинарный договор ломаетсяРазница pack и paddingРазница 32-bit / 64-bitЦелые фиксированной ширины + явная раскладкаПоложить 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:

  • INITIALIZING
  • READY
  • BROKEN

И пусть инициализирует только creator, а joiner ждёт READY. Одна эта дисциплина заметно успокаивает обстановку.

Как избегать гонки инициализацииВ заголовок кладут state INITIALIZING, READY и BROKEN; инициализирует только creator и поднимает READY, joiner начинает пользоваться только после ожидания READY — так избегают гонки инициализации.Creator создаётИнициализирует только creatorСтавит state в READYJoiner после open сразу не пользуетсяЖдёт READYНачинает пользоваться

Рис. 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
  • процедура полной повторной инициализации при повреждении
Когда writer падает во время обновленияЕсли владелец завершается без release, в Windows приходит WAIT_ABANDONED, у POSIX robust mutex — EOWNERDEAD; общий ресурс может быть в неопределённом состоянии, поэтому не продолжают как есть, а переходят к восстановлению.Writer падает во время обновленияWAIT_ABANDONED (Windows)EOWNERDEAD (POSIX robust)Общий ресурс может быть в неопределённом состоянииНе продолжать как есть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».

Типичный false sharing и что с ним делатьЕсли write_index, который обновляет producer, и read_index, который обновляет consumer, сидят в одной cache line, линия бегает между CPU и становится медленно; hot field разносят по разным cache line.Producer обновляет write_indexОдна и та же cache lineConsumer обновляет read_indexЛиния бегает между CPU и становится медленноРазнести 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

1582

Иначе говоря:

  • кажется, что если назвать объект "Global\\MyApp", его смогут делить служба и desktop-приложение
  • но срывается из-за прав
  • и вдобавок, если mutex с тем же именем уже создали раньше, получаем ERROR_INVALID_HANDLE

Вылезает очень характерная для Windows грязь.

Грязь пространства имён GlobalЕсли для общего доступа службы и desktop-приложения взять имя в Global, можно сорваться на правах без SeCreateGlobalPrivilege или столкнуться с одноимённым mutex, который уже занял общее пространство имён.Хотим делить через имя в GlobalСрыв из-за правСтолкновение с одноимённым mutexДля создания нужен SeCreateGlobalPrivilegeПолучаем 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

На практике безопаснее сделать размер неизменным в пределах одного поколения. Если расширение нужно:

  1. создать сегмент с новой version / name / generation
  2. переключить на него участников
  3. закрыть старый сегмент

Так частота сбоев снижается.

Расширение размера — через смену поколенияРазмер разделяемой памяти в пределах поколения фиксируют; если нужно расширить, создают сегмент нового поколения, переключают участников и закрывают старый сегмент — так частота сбоев ниже, чем у resize in place.Хотим потом расширитьСоздать сегмент нового поколенияПереключить участниковЗакрыть старый сегмент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
Уведомления выносят на примитив, которого можно ждатьУведомление через флаг в разделяемой памяти и Sleep тратит CPU, даёт дрожание задержки и пропуски, поэтому разделяемую память оставляют стороне данных, а уведомления выносят на event, semaphore и другие примитивы, которых можно ждать.Сторожить ready flag через SleepТрата CPU и дрожание задержкиПропуски трудно заметитьУведомление — на примитив, которого можно ждатьВ Windows — event / semaphore и т. п.В POSIX — semaphore / condvar и т. п.

Рис. 19: Не сторожить флаг, а принимать уведомление через примитив, которого можно ждать.

5.12 Мысль «так можно разделять данные и с другими машинами»

Бывает момент, когда хочется подумать: если взять file-backed mapping и отобразить сетевой общий файл, не получится ли обмен наподобие разделяемой памяти и с другими машинами.

Это опасно.

В документации по CreateFileMapping в Windows тоже сказано, что для удалённых файлов coherence не гарантируется. Если две машины отображают одну и ту же страницу как writable, каждая видит только свою запись, и при обновлении диска слияния (merge) не происходит. 8

Разделяемая память в основе своей — механизм внутри одного хоста. Если нужно выходить за пределы машины, честнее сразу выбрать socket / RPC / брокер сообщений — так проще сохранить рассудок.

С другими машинами так не делятДаже если отобразить общий файл по сети, для remote file coherence не гарантируется; разделяемая память — механизм внутри одного хоста, поэтому при выходе за машину выбирают socket, RPC или брокер сообщений.Хотим делить, отобразив remote fileCoherence не гарантируетсяРазделяемая память — механизм внутри одного хостаК 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 оставляет запас для будущего расширения

Сложность разделяемой памяти в том, что «трудно увидеть, что происходит». Именно поэтому с самого начала закладывают метаданные для наблюдаемости.

Роль полей фиксированного заголовкаMagic в заголовке отсекает чужое и неинициализированное, abi_version и header_size — различия раскладки, state — незавершённую инициализацию, generation позволяет обнаружить повторное создание, heartbeat — следить за жизнеспособностью.Фиксированный заголовок в началеmagic отсекает чужоеversion отсекает различияstate отсекает незавершённоеСтановятся метаданными для наблюденияgeneration обнаруживает повторное создание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 один, поэтому движение одностороннее.

Кольцевой буфер, 8 слотовдойдя до конца, возвращается к slot 0slot 0чтение законченоslot 1чтение законченоslot 2ещё не прочитанslot 3ещё не прочитанslot 4идёт записьslot 5свободенslot 6свободенslot 7свободенconsumerread_seq = 2сдвигает после чтенияproducerwrite_seq = 4сдвигает после записи

Рис. 22: Устройство SPSC-кольцевого буфера. Producer и consumer односторонне двигают разные index.

Суть в том, чтобы не ломать порядок «сначала дописать, потом сдвинуть index» и «сначала дочитать, потом сдвинуть index». А write_seq и read_seq, как в 5.8, кладут на разные cache line.

Если нужны несколько writer, обычно лучше работает подход, который сокращает число точек ответственности за согласованность, например:

  • lock-free / atomic остаётся только у самой операции enqueue
  • фактическое обновление данных стягивается к одному consumer

6.5 Явно задать протокол commit

Проектирование, в котором нельзя словами объяснить, «с какого момента можно читать», опасно.

Например, для двойного буфера определяют ритуал публикации:

  1. писать в ещё непубликуемый буфер
  2. зафиксировать контрольную сумму и длину
  3. переключить индекс активного буфера с семантикой release
  4. reader читает активный индекс с семантикой acquire
  5. после чтения проверить, что индекс не изменился

На схеме сразу видно: момент переключения только один.

readerбуфер Bactive indexбуфер Awriterreaderбуфер Bactive indexбуфер Awriteractive index — Aactive index — Bпрочитанное отбрасываети читает заново из Bчитает с acquireAчитает буфер Aпишет в ещё непубликуемый Bфиксирует длину и контрольную суммупереключает на B с releaseпосле чтения перепроверяет indexсменился на B

Рис. 23: Ритуал публикации двойного буфера. Reader после чтения ещё раз проверяет active index.

Если опустить шаг «после чтения перепроверить index», writer может начать повторно использовать тот же буфер, пока reader ещё читает, и получится то же промежуточное состояние, что в 5.3. Если сторон только две, во время повторного чтения переключение может случиться ещё раз, поэтому при быстрых обновлениях увеличивают число сторон или смещаются к способу sequence counter из 5.3.

6.6 Фиксировать размер по поколениям

Вместо resize in place в сопровождении удобнее резать поколения, например:

  • name = MyShm.v3
  • abi_version = 3
  • generation = 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
Обмен туда-обратно в минимальном образцеОтправитель заканчивает инициализацию, пишет тело, фиксирует длину и поднимает event; получатель ждёт event, проверяет ABI и state, читает длину с проверкой диапазона и в том же порядке отвечает.получательобщий блокотправительполучательобщий блокотправительинициализирует и ставит state в READYдописывает тело, затем фиксирует длинуподнимает event запросапроверяет magic / version / stateпроверяет диапазон длины и читаетответ тоже: сначала тело, потом длинаподнимает 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 с явной раскладкой
  • не класть ссылки на объекты как есть

1

У MemoryMappedFile те же основыMemoryMappedFile в C# по сути обёртка над file mapping Windows, поэтому основы те же: open по тому же имени, синхронизация отдельно через mutex или event, чтение с явной раскладкой, ссылки на объекты как есть не кладут.MemoryMappedFile в C#Обёртка над file mappingOpen по тому же имениСинхронизация отдельно: mutex / eventЧитать с явной раскладкойСсылки на объекты как есть не класть

Рис. 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
  • восстановление
  • права
  • наблюдаемость
Суть разделяемой памяти — перенос ответственностиВ обмен на меньшее число копий и меньше сообщений через ядро сторона приложения берёт на себя синхронизацию, видимость, инициализацию, ABI, восстановление, права и наблюдаемость.Меньше копий и сообщенийВзамен берёте на себяСинхронизацию, видимость, инициализациюABI, восстановление, праваНаблюдаемость

Рис. 26: Суть разделяемой памяти — не «скорость», а перенос ответственности за согласованность на вашу сторону.

Поэтому для первой реализации безопасна такая форма:

  • SPSC-кольцевой буфер или двойной буфер
  • фиксированный заголовок в начале
  • ссылки через offset
  • уведомление через отдельный канал
  • наличие version / generation / heartbeat
  • наличие тестов аварийных сценариев

Начав именно с такой формы, разделяемая память становится вполне послушным инструментом. Если же с первого дня относиться к ней как к «быстрой общей памяти, куда можно класть что угодно», со временем это превращается уже не в приложение, а в археологию.

10. Справочные материалы

  • Windows: основы file mapping и именованной разделяемой памяти 682
  • Windows: пространство имён / безопасность / синхронизация 1524512
  • POSIX: shm_open, shm_unlink, mmap, process-shared / robust-синхронизация 322162013
  • .NET: обзор MemoryMappedFile 1
  1. Microsoft Learn, “Memory-Mapped Files” / Microsoft Learn, “MemoryMappedFile Class”  2 3 4

  2. Microsoft Learn, “Sharing Files and Memory”  2 3 4 5 6 7 8

  3. man7.org, “shm_open(3)”  2 3 4 5 6 7 8 9 10 11

  4. Microsoft Learn, “/volatile (volatile Keyword Interpretation)” / Microsoft Learn, “volatile (C++)”  2

  5. Microsoft Learn, “Interlocked Variable Access” / Microsoft Learn, “MemoryBarrier function”  2 3 4 5

  6. Microsoft Learn, “Creating Named Shared Memory”  2 3 4

  7. Microsoft Learn, “Scope of Allocated Memory”  2

  8. Microsoft Learn, “CreateFileMappingA function”  2 3 4 5 6 7 8 9 10

  9. man7.org, “POSIX Shared Memory” training slides 

  10. Microsoft Learn, “WaitOnAddress function”  2

  11. Microsoft Learn, “MapViewOfFileEx function” / Microsoft Learn, “MapViewOfFile function” 

  12. Microsoft Learn, “Mutex Objects”  2 3

  13. man7.org, “pthread_mutex_lock(3p)” / man7.org, “pthread_mutexattr_setrobust(3)”  2 3

  14. man7.org, “pthread_mutex_consistent(3)” / man7.org, “pthread_mutex_consistent(3p)”  2

  15. Microsoft Learn, “Kernel object namespaces”  2 3 4

  16. man7.org, “mmap(2)”  2

  17. Microsoft Learn, “Using Mutex Objects” 

  18. man7.org, “sem_init(3)” / man7.org, “sem_init(3p)”  2

  19. man7.org, “pthread_condattr_setpshared(3p)” / man7.org, “pthread_condattr_getpshared(3p)”  2

  20. man7.org, “pthread_mutexattr_getpshared(3)” / man7.org, “pthread_mutexattr_getpshared(3p)”  2 3

  21. Microsoft Learn, “Critical Section Objects” 

  22. Microsoft Learn, “File Mapping Security and Access Rights”  2

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Технические консультации и ревью дизайна

Проработка решений, которые снижают частоту сбоев — способ синхронизации, 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог