共有メモリの落とし穴と実務ベストプラクティス

· 更新日: · · Shared Memory, IPC, Concurrency, C++, C#, Windows開発

更新履歴(5件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
C#のサンプルが`CreateOrOpen`を使っていたのを直しました。2本目の送り手が、動いているブロックをそのまま開いて初期化し、相手のやり取りを壊してしまいます。送り手は`CreateNew`、受け手は`OpenExisting`に分けました(C側の`ERROR_ALREADY_EXISTS`の判定と同じ趣旨です)。
Cのサンプルで、`CreateFileMappingW`が同名の既存マッピングを「成功して」返す場合を弾いていなかったのを直しました。NULLの判定だけでは2本目の送り手を止められず、そのまま`memset`に進んで動いている相手の共有ブロックを消してしまいます。`ERROR_ALREADY_EXISTS`の判定を追加しました。
途中状態を読んでしまう流れ、SPSCリングバッファの構造、ダブルバッファ切り替えの3つの図を追加しました。最小の往復サンプルをC(`CreateFileMapping`とイベント2本)とC#(`MemoryMappedFile`と`EventWaitHandle`)で新設し、用語表と前提を冒頭に置きました。5章と6章の重複は参照に圧縮し、比喩で書いていた4か所を具体的な記述に直しました。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589690)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「共有メモリの落とし穴と実務ベストプラクティス」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589690 https://staging.comcomponent.com/blog/2026/03/18/000-shared-memory-pitfalls-best-practices/

DOI(最新版)
10.5281/zenodo.21589690
DOI(この版)
10.5281/zenodo.21732713

画像フレーム、検査結果、時系列ログ、板情報、巨大バッファ。 同一マシン内で大きなデータを低レイテンシでやり取りしたいとき、共有メモリはかなり魅力的です。

ただし、ここで少し危ないのは、共有メモリが 「速い IPC」 という顔で近づいてくることです。 実際には、共有メモリは 「コピーを減らせる代わりに、整合性の責任をアプリ側へ押し返してくる IPC」 です。

  • 速い
  • 柔軟
  • でも protocol は自前
  • 事故ると症状が派手

だいたいこの 4 点セットです。

この記事では、Windows の file mapping と POSIX shm_open / mmap を念頭に、共有メモリを実務で使うときの詰まりどころと、事故率を下げる設計 を整理します。 C/C++ でも C# の MemoryMappedFile でも、本質はほぼ同じです。1

対象読者と前提

同一マシン内のプロセス間で大きなデータを渡す設計を、これから決める開発者 に向けて書いています。C / C++ で Windows の file mapping か POSIX の shm_open を直接触る方を主な想定にしていますが、C# の MemoryMappedFile から入る方も同じ落とし穴を踏みます。落とし穴と設計指針の章(5 章・6 章)は言語に依存しない内容です。

動くサンプルは 6.9 に置いてあり、C(Windows / MSVC)と C# の両方 を載せています。POSIX 側は API 名の対応だけを 7 章の表で示します。

先に押さえておく用語

本文には英語のまま出てくる用語がいくつかあります。初出でつまずかないよう、先にまとめておきます。

用語 意味
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 1・reader 1 です。4.2 で展開します
lock-free ロックを取らず、atomic 操作だけで進める作りです。「どれか 1 つのスレッドは必ず前進できる」という進行保証を指す言葉で、「速い」とは別の性質です
sentinel 「無効」「終端」を表すために予約しておく特別な値です。offset なら「UINT64_MAX は無効」と決めておく、といった使い方をします
NUMA (Non-Uniform Memory Access) CPU から見たメモリの遠さが一様でない構成です。遠いノードのメモリを触ると、同じコードでも目に見えて遅くなります

1. まず結論(ひとことで)

先にかなり雑に、でも実務で役に立つ言い方をすると、こうです。

  • 共有メモリは 同じバイト列 を複数プロセスから見せる仕組みであって、同期そのもの ではありません23
  • 速いのは大きなデータ を同一マシン内でやり取りするときです。小さい制御メッセージだけなら、pipe / socket / named pipe / queue のほうが楽なことがかなり多いです
  • 共有メモリでは、見えること安全に読めること が別問題です
  • volatile は設計の土台にしないほうがよいです。原子性、順序、待機 は別に考えます45
  • 生ポインタ、HANDLE、file descriptor、std::stringstd::vectorstd::mutex をそのまま置くと、だいたい後で泣きます
  • 共有メモリに置くデータは、固定幅整数 + 明示的レイアウト + バージョン付きヘッダ に寄せたほうが安全です
  • 先頭ヘッダに magic / version / size / state / generation / heartbeat を置くだけで、事故調査のしやすさがかなり変わります
  • 共有メモリの難所は速度ではなく、初期化、寿命、復旧、権限、ABI です
  • Windows なら CreateFileMapping / OpenFileMapping / MapViewOfFile、POSIX なら shm_open / ftruncate / mmap が骨格です63
  • 一番事故りにくいのは、SPSC(single-producer single-consumer)のリングバッファ か、ダブルバッファ から始めることです

要するに、共有メモリは速いけれど、雑に使うと「勝手に同期されている気がする病」にかかる。ここを避けるのが最初の勝負です。

この記事の知識マップ

共有メモリはWindowsのCreateFileMapping/MapViewOfFileやPOSIXのshm_open/mmapを使い、同じ物理ページを複数プロセスから見せる仕組みだが、同期そのものではないため、同期なしで複数フィールドを読み書きするとwriterの書き込み途中を読んでしまう事故が起きうる。この事故はSPSCリングバッファやダブルバッファによるcommitプロトコルで防ぎ、生ポインタやHANDLEなどprocess-local資源はそのまま置けないためオフセット参照に置き換え、control planeとdata planeを分離して通知は別チャネルへ逃がすことが推奨される。加えて、ABIの固定、作成者だけが初期化する運用による初期化レースの回避、Windowsのabandoned mutexやPOSIXのrobust mutexを使ったクラッシュ復旧設計、名前空間・権限の構成、世代ごとのサイズ固定が、事故率を下げる実務上の要点になる。

共有メモリの落とし穴とベストプラクティスの知識マップ共有メモリがバイト列を共有するだけで同期そのものではないこと、途中状態の読み取りや初期化レース・クラッシュ復旧・ABI不一致・false sharingといった落とし穴、control planeとdata planeの分離やSPSCリングバッファ・ダブルバッファ・オフセット参照といった対策の関係を示す図利用する利用する利用する原因になり得る防止する防止する推奨される対応推奨される対応推奨される対応両立しない前提とする原因になり得る防止する推奨される対応利用する利用する原因になり得るで構成できる推奨される対応両立しない両立しない推奨される対応用いるのは非推奨利用する共有メモリCreateFileMapping/MapViewOfFileshm_open/ftruncate/mmapファイルマッピング(メモリマップトファイル)途中状態の読み取りSPSCリングバッファダブルバッファによるcommitプロトコルcontrol planeとdata planeの分離共有メモリの固定ヘッダオフセット参照process-localな資源共有メモリのABI設計初期化レースcreatorだけが初期化する運用クラッシュ復旧設計abandonedになったmutex(Windows)robust mutex(POSIX)false sharingとキャッシュライン競合名前空間と権限(Global/Local)世代ごとのサイズ固定運用他マシンとの共有通知を別チャネルへ逃がす設計busy loopによるポーリング通知名前付きパイプ

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全24件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

2. 共有メモリは何を共有して、何を共有しないのか

共有メモリは、ざっくり言うと 同じ物理ページ を複数プロセスの仮想アドレス空間へマップする仕組みです。 Windows では file mapping object と view を使い、POSIX では shared memory object を mmap します。273

ここで大事なのは 2 点です。

  1. 共有されるのは中身のバイト列であって、仮想アドレスそのものではない
  2. coherent であること同期されていること は別

Windows のドキュメントでも、同じ file mapping object から作った view は同時点で coherent だとされています。 ただし、それは 読者が常に一貫した更新済みレコードを読める という意味ではありません。8

たとえば、

  • writer が length
  • つづいて payload
  • つづいて ready flag

の順に書くつもりでも、reader 側が何の同期もなく読むと、新しい length と古い payload を組み合わせて見ることがあります。 共有メモリはここを自動では直してくれません。

つまり、共有メモリが共有するのは バイト。 共有しないのは 意味、順序、完了通知、復旧方針 です。 このへんは全部、こちらで設計する必要があります。

3. 共有メモリが向いている場面 / 向いていない場面

場面 向き・不向き 理由
同一マシン内で大きなフレームやバッファを渡す 向いている コピー回数を減らしやすい
高頻度のセンサ値、画像、音声、板情報など 向いている 低レイテンシ・高スループットを狙いやすい
小さいコマンドや応答だけをやり取りする あまり向かない 制御のための同期コストが相対的に重い
他マシンとやり取りする 向かない 共有メモリは基本的に同一ホスト前提
異なる言語・異なるバージョンが長期共存する 難しい ABI とバージョニング設計が必要
永続化も必要 目的次第 file-backed mapping は有力だが、永続化と IPC の責務が混ざりやすい

実務では、制御はメッセージ系、データ本体は共有メモリ という分離がかなり強いです。 たとえば、

  • UI プロセス → worker プロセスへ「次のフレームを使え」と通知するのは event / pipe / socket
  • 実際のフレーム本体は共有メモリ

という構成です。 これがわりと平和です。

4. 最初に決めるべき 4 つのこと

共有メモリを設計するとき、最初に決めるべきなのは次の 4 つです。

4.1 control plane と data plane を分ける

何を shared memory に置くのかを先に決めます。

  • data plane: 画像、音声、レコード列、バルクデータ
  • control plane: 開始、停止、エラー、再接続、再初期化、通知

この 2 つを分けるだけで、shared memory 側の設計がかなり単純になります。

4.2 並行モデルを絞る

  • SPSC: 1 producer / 1 consumer
  • MPSC: 多 writer / 1 consumer
  • SPMC: 1 writer / 多 reader
  • MPMC: 多 writer / 多 reader

難易度は、だいたいこの順で上がります。 最初から MPMC に行くのは、あまり勧められません。writer どうしの排他とメモリ順序を同時に相手にすることになり、テストで再現しにくい不具合が後から出ます。

4.3 所有者と寿命を決める

  • 誰が作るか
  • 誰が初期化するか
  • 誰が消すか
  • 参加者が途中で落ちたとき、誰が回復させるか

ここが曖昧だと、起動順や再起動のたびに挙動が変わり、原因の切り分けが難しくなります。

4.4 ABI とバージョンを決める

  • レイアウト
  • 型サイズ
  • alignment
  • reserved 領域
  • version / feature flags
  • 互換性の有無

shared memory は API ではなく ABI(binary interface) の話です。 ここを雑にすると、ソース互換はあるのに実行時だけ壊れる、という嫌な事故になります。

5. よくある落とし穴

5.1 同期しない

いちばん多いのはこれです。

「同じメモリを見ているのだから、書いたら読めるだろう」

読めることはあります。 でも、それは 正しいタイミングで、正しい単位で、正しい順序で 読めることを意味しません。

Windows でも POSIX でも、共有メモリへのアクセスは 別の同期手段と組み合わせる前提 です。 Windows の説明でも、共有 view へのアクセスは mutex / semaphore / event などで協調するよう書かれています。2 POSIX の説明でも、shared memory へのアクセスは同期が必要です。9

5.2 volatile で何とかしようとする

volatile は、共有メモリ設計を救ってくれる魔法ではありません。 少なくとも atomicitymutual exclusion は別問題です。45

たとえば volatile bool ready; を置いて busy loop する設計は、

  • CPU を無駄に使う
  • payload と ready の順序保証が曖昧になる
  • portable ではない
  • 途中状態を拾いやすい

と、だいたい良いことがありません。

さらに Windows の WaitOnAddress同じプロセス内の thread 向け です。 cross-process の待機機構としては考えないほうが安全です。10

5.3 途中状態を読ませる

共有メモリで事故るときの見た目は、かなり普通です。

  • ヘッダだけ新しい
  • ペイロードだけ古い
  • 長さだけ更新済み
  • 2 つのフィールドの組が壊れている

図にすると、事故の起き方は単純です。writer が lengthpayload を書き終える前に、reader がその隙間へ入ってしまうだけです。

reader プロセス共有メモリwriter プロセスreader プロセス共有メモリwriter プロセスlength は新しいpayload はまだ古い「ヘッダだけ新しい」中途半端な状態を掴むlength に 1024 を書くlength を読む1024payload を 1024 バイト読む前の世代の中身payload を書くready flag を立てる

この隙間は、lengthpayload の書き込みが「1 つの不可分な操作ではない」以上、必ず存在します。単一の scalar を atomic に更新するだけなら話は比較的単純ですが、複数フィールドからなるレコード を公開するなら、commit の手順が必要です。

典型的には次のどれかです。

  • mutex で丸ごと守る
  • ダブルバッファ にして最後に「今の有効バッファ番号」を切り替える
  • リングバッファ にして slot ごとに state / sequence を持つ
  • 1 writer / 多 reader なら sequence counter で snapshot を取る

「最後に ready flag を立てる」だけでも、その flag をどういうメモリ順序で書くか / 読むか を決めないと、設計としてはまだ甘いです。 共有メモリでは、公開タイミングそのものがプロトコル です。

5.4 ポインタや複雑オブジェクトをそのまま置く

これも頻出パターンです。

  • 生ポインタ
  • HANDLE
  • file descriptor
  • std::string
  • std::vector
  • std::unordered_map
  • std::mutex
  • CRITICAL_SECTION

このへんを shared memory にそのまま置いて、別プロセスから使おうとするパターンです。読み手のプロセスでは、まず間違いなくアクセス違反か無意味な値になります。

理由は単純で、仮想アドレスや process-local な資源は、その process の文脈にしか意味がない からです。 Windows の view も、同じ mapping を別 process で map しても、仮想アドレスは一致するとは限りません711

なので、参照が必要なら ベースアドレスからの offset で持つのが基本です。

typedef struct ShmRef {
    uint64_t offset;   // セグメント先頭からの相対位置
    uint32_t length;
    uint32_t kind;
} ShmRef;

これなら、各 process が base + offset で自分のアドレスに直せます。

5.5 ABI が壊れる

shared memory は、ソースコードではなく バイナリの約束 です。 つまり、次の違いが全部効きます。

  • int / long のサイズ
  • bool の表現
  • enum の underlying type
  • wchar_t のサイズ
  • 32bit / 64bit の差
  • #pragma pack
  • compiler / language の違い
  • alignment / padding
  • little-endian / big-endian

同一ホスト内なら endianness は揃っていることが多いですが、ARM64 対応や mixed toolchain が入るだけでも、かなり普通にずれます。

なので、shared memory に置く構造は次を強く勧めます。

  • uint32_t / uint64_t などの 固定幅整数
  • 明示的な padding / reserved
  • header に version, header_size, record_size, total_size
  • 必要なら static_assert(sizeof(...))
  • 非 trivial object を置かない

5.6 初期化レース

shared memory は「作った側が初期化したはず」という思い込みで壊れやすいです。

Windows では、CreateFileMapping が既存名に当たると 既存オブジェクトを返しGetLastError()ERROR_ALREADY_EXISTS が分かります。 pagefile-backed な mapping の初期ページは 0 で始まります。8 POSIX では、新しい shared memory object は 最初は長さ 0 で、ftruncate でサイズを付けます。新しく確保されたバイトは 0 初期化です。O_CREAT | O_EXCL による create は原子的です。3

この差を知らないまま、

  • open したら即使う
  • 初期化完了フラグがない
  • 参加者が同時に初期化する
  • version mismatch を見ない

とやると、起動順次第で壊れます。

最低限、先頭ヘッダに次の state を置いたほうがよいです。

  • INITIALIZING
  • READY
  • BROKEN

そして creator だけが初期化 し、joiner は READY を待つ。 この作法だけで、かなり世界が静かになります。

5.7 クラッシュ復旧を考えない

writer が共有データ更新中に落ちたらどうするか。 ここを未定義のまま本番へ出すと、障害時の顔つきが急に深刻になります。

Windows の mutex は、所有 thread が release せずに終了すると abandoned になり、wait 側は WAIT_ABANDONED を受け取れます。これは 共有資源が不定状態かもしれない という意味です。12 POSIX の robust mutex でも、owner が死んだとき EOWNERDEAD が返り、修復後に pthread_mutex_consistent() を呼ぶ流れがあります。1314

大事なのは、ここで「とりあえず続行」しないことです。 復旧には少なくとも次のどれかが要ります。

  • generation 番号
  • 最終 commit 済み sequence
  • heartbeat
  • dirty / clean flag
  • journal 的な 2 段 commit
  • 破損時の全再初期化手順

5.8 false sharing とキャッシュライン競合

shared memory は速い、と言われがちです。 でも hot なカウンタが同じ cache line に詰まっていると、CPU 間で line が行ったり来たりして、無視できないほど遅くなります。

典型例は、

  • producer が write_index を更新
  • consumer が read_index を更新
  • 両方が同じ cache line に乗っている

というやつです。

この場合は、

  • hot field を別 cache line に分ける
  • 更新頻度の高い field と低い field を分ける
  • 1 writer 1 cache line を意識する

だけでかなり変わります。 64 bytes に揃える話がよく出ますが、64 bytes は多くの CPU でありがちな値であって絶対法則ではない、くらいの気持ちで見てください。

5.9 名前・権限・セキュリティを軽く見る

named shared memory は便利ですが、名前と権限を雑にすると事故ります。

Windows では、

  • Global\Local\ の namespace がある
  • session 0 以外から Global\ の file mapping を 新規作成 するには SeCreateGlobalPrivilege が要る
  • object name は event / semaphore / mutex / waitable timer / job と namespace を共有 する

という癖があります。1582

つまり、

  • "Global\\MyApp" にしたら service と desktop app で共有できる気がする
  • でも権限で失敗する
  • しかも同名の mutex を先に作っていて ERROR_INVALID_HANDLE になる

みたいな、たいへん Windows らしい泥が出ます。

POSIX 側でも、shm_openmodeumask を軽く見ると、不要に広く見えたり、逆に開けなかったりします。3

shared memory は ただメモリだから安全 ではありません。 読める権限がある process からは、かなり素直に見えます。 機密情報を置くなら、通常のメモリと同じく paging / swap / dump / 権限の文脈で考える必要があります。

5.10 サイズ変更とアップグレードを雑にやる

共有メモリを「あとからちょっと広げたい」は、わりと危ない要求です。

  • Windows の mapping object には作成時のサイズがある8
  • POSIX でも ftruncatemmap の整合を考えないと、参加者側の map 長と合わなくなります316

実務では、サイズはその世代では不変 にしたほうが安全です。 拡張が必要なら、

  1. 新しい version / name / generation の segment を作る
  2. 参加者を切り替える
  3. 旧 segment を閉じる

のほうが事故率は下がります。

5.11 通知まで全部 shared memory に押し込む

よくあるのが、

  • 共有メモリに ready = 1
  • 相手は while (!ready) Sleep(1);

です。

これ、最初は動きます。 でも後で、

  • CPU を無駄に使う
  • Sleep(1) でレイテンシが揺れる
  • 取りこぼしに気づきにくい
  • タイムアウトや終了通知がきれいに書きづらい

という形で返ってきます。

共有メモリは データ面 に寄せ、通知は 待てる primitive へ逃がしたほうがよいです。

  • Windows: event / semaphore / mutex / named pipe など217
  • POSIX: semaphore / process-shared mutex + condvar など1819

5.12 「これで他マシンとも共有できる」と思う

file-backed mapping を使ってネットワーク越しの共有ファイルを map すれば、他マシンとも shared memory 的にいけるのでは、と思いたくなる瞬間があります。

ここは危ないです。

Windows の CreateFileMapping の説明でも、remote file に対しては coherence が保証されない とされています。 同じページを 2 台が writable に map した場合、それぞれ自分の書き込みしか見えず、ディスク更新時に merge もされません。8

共有メモリは、基本的に 同一ホスト内 の仕組みです。 マシンをまたぐなら、素直に socket / RPC / message broker を選んだほうが正気を保ちやすいです。

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_versionheader_size で layout 差異を弾く
  • state で初期化途中を弾く
  • generation で再作成を検知する
  • heartbeat で死活を見る
  • reserved で将来拡張の逃げ道を作る

です。

shared memory で辛いのは、「何が起きているか見えにくい」ことです。 だからこそ、観測用の metadata を最初から持たせます。

6.3 オフセット参照にする

参照は pointer ではなく offset で持ちます。

  • base + offset で解決する
  • offset + length の範囲チェックを入れる
  • invalid value 用の sentinel を決める

これだけで、address mismatch 系の事故がかなり減ります。

6.4 並行モデルを絞る

4.2 の 4 モデルのうち、最初に選ぶべきなのはこのどちらかです。

  • SPSC ring buffer
  • 1 writer / 多 reader の snapshot

SPSC リングバッファは、固定長スロットの配列に対して、producer が write_seq の位置へ書き、consumer が read_seq の位置から読むだけの構造です。writer も reader も 1 つなので、進む向きが一方通行になります。

リングバッファ 8 スロット末尾まで来たら slot 0 へ戻るslot 0読み終わりslot 1読み終わりslot 2未読slot 3未読slot 4書き込み中slot 5空きslot 6空きslot 7空きconsumerread_seq = 2読み終えてから進めるproducerwrite_seq = 4書き終えてから進める

ポイントは、「書き終えてから index を進める」「読み終えてから index を進める」 の順序を崩さないことです。そして write_seqread_seq は 5.8 のとおり、別の cache line へ置きます。

多 writer が必要なら、

  • enqueue だけは lock-free / atomic
  • 実データ更新は consumer 1 つへ集約

のように、整合性の責任点を減らす ほうがだいたいうまくいきます。

6.5 commit protocol を明示する

「どの瞬間から読んでよいか」を文章で説明できない設計は危ないです。

たとえばダブルバッファなら、

  1. 非公開側バッファへ書く
  2. チェックサムや長さを確定する
  3. release 付きで active buffer index を切り替える
  4. reader は acquire 付きで active index を読む
  5. 読み終えたら index が変わっていないか確認する

のように、公開の儀式 を決めます。図にすると、切り替えの瞬間が 1 か所しかないことがはっきりします。

readerバッファ Bactive indexバッファ Awriterreaderバッファ Bactive indexバッファ Awriteractive index は Aactive index は B読んだ内容は捨ててB から読み直すacquire で読むAバッファ A を読む非公開側の B へ書く長さとチェックサムを確定するrelease で B へ切り替える読み終えて index を再確認するB に変わっていた

この「読み終えたあとに index を再確認する」手順を省くと、reader が読んでいる最中に writer が同じバッファを次の書き込みへ使い回して、5.3 と同じ中途半端な状態になります。なお、バッファが 2 面しかないと読み直しの最中にまた切り替わることがあるので、更新が速い場合は面数を増やすか、5.3 に挙げた sequence counter 方式へ寄せます。

6.6 サイズは世代ごとに固定する

resize in place より、

  • name = MyShm.v3
  • abi_version = 3
  • generation = 42

のように世代を切ったほうが保守しやすいです。

共有メモリは API のように「呼び出し時に型チェック」してくれません。 だから 一度決めた ABI をこわさない ことが重要です。

6.7 観測可能性を入れる

最低限、このあたりがあると助かります。

  • 最終更新時刻
  • 最終成功 sequence
  • drop 数 / overwrite 数
  • version mismatch 数
  • attach / detach 数
  • last error code
  • heartbeat

shared memory が壊れるときは、だいたいログが薄いです。 自前で counters を置くと、障害対応がかなり楽になります。

6.8 異常系テストを先に作る

正常系だけでは足りません。少なくとも次は見たほうがいいです。

  • writer 更新中に強制終了
  • reader 遅延で ring があふれる
  • version mismatch で接続
  • 32bit / 64bit 混在
  • session をまたいだ open
  • 権限不足
  • 先行プロセスが古い世代を持ったまま再起動
  • huge data 連続転送時の cache miss / NUMA 影響

shared memory は正常系より 壊し方テスト のほうが価値が大きいです。

6.9 最小の往復サンプル

ここまでの作法を、動く最小構成に落とします。Windows の pagefile-backed な file mapping に固定レイアウトのブロックを 1 つ置き、通知は auto-reset event 2 本でやり取りする、というだけの構成です。

共通の約束はこの 4 つです。

  • ブロックは 固定幅整数と固定長配列だけ。ポインタも HANDLE も置かない
  • 先頭に magic / abi_version / block_size / state を置く
  • 本体を書き終えてから長さを確定 し、そのあとで event を立てる
  • event の名前は file mapping と 別名 にする(Windows では event / semaphore / mutex / waitable timer / job / file mapping が名前空間を共有するため)815

まず送り手です。

/* 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' をリトルエンディアンで並べた値 */
#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 を作る。初期ページは 0 で始まる。
     *    同じ名前が既にあると CreateFileMappingW は「成功して既存を返す」ので、
     *    NULL チェックだけでは 2 本目の送り手を弾けない。そのまま下の 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 は既に使われています。送り手は同時に1つだけです\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("request が来ませんでした\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 を付けて <assert.h>static_assertsizeof(DemoBlock) を固定します。

同じブロックを C# 側から扱うと、こうなります。オフセットを定数で明示 しているのが要点で、C 側の構造体と 1 バイトもずれないように書いています。名前付きの MemoryMappedFileEventWaitHandle は 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;

// C 側の DemoBlock と同じレイアウトを、オフセット定数で固定します
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 だと、2本目の送り手が動いている
// ブロックをそのまま開いてしまい、下の初期化で相手のやり取りを壊す。
// CreateNew は同名が既にあれば IOException になるので、そこで気づける
// (C 側の ERROR_ALREADY_EXISTS の判定と同じ趣旨です)
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("request が来ませんでした");
    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 を固定するとはこういうことです。

このサンプルは意図的に 1 往復だけ です。連続転送にするなら 6.4 のリングバッファへ、writer の異常終了に耐えさせるなら 5.7 の generation と heartbeat へ、それぞれ進めてください。

7. Windows と POSIX で見るポイント

観点 Windows POSIX
作成 / open CreateFileMapping / OpenFileMapping / MapViewOfFile6 shm_open / ftruncate / mmap3
ディスク非連携の共有 INVALID_HANDLE_VALUE を指定した pagefile-backed mapping68 POSIX shared memory object + mmap3
初期値 pagefile-backed pages は 0 初期化8 新規 object は長さ 0。新規確保バイトは 0 初期化3
同期 mutex / semaphore / event / interlocked など25 process-shared mutex / condvar / semaphore2018
cross-process で使ってはいけないもの CRITICAL_SECTION, WaitOnAddress2110 PTHREAD_PROCESS_PRIVATE のままの mutex / condvar2019
owner death WAIT_ABANDONED12 robust mutex + EOWNERDEAD / pthread_mutex_consistent()1314
name の削除 最終 handle / view 解放で消える28 shm_unlink で名前削除。参照が残っていれば実体は最後まで残る2223
namespace / 権限 Global\ / Local\、ACL、SeCreateGlobalPrivilege1524 mode, umask, 名前空間、O_CREAT|O_EXCL3

C# の MemoryMappedFile も、本質的には Windows の file mapping のラッパです。 だから、

  • 同じ名前で open する
  • 別途 mutex / event を使う
  • ビューに対して明示レイアウトで読む
  • オブジェクト参照をそのまま置かない

という基本は変わりません。1

8. まず見るチェックリスト

  • 本当に共有メモリが要るか。同一ホストで大きなデータ
  • control plane と data plane を分けたか
  • 並行モデルは SPSC / 1 writer 多 reader まで落とせないか
  • 先頭ヘッダに magic / version / size / state / generation / heartbeat があるか
  • pointer / HANDLE / fd / STL object / std::mutex を置いていないか
  • reader が途中状態を見ない commit protocol があるか
  • 初期化者が 1 人に定まっているか
  • 異常終了時の 復旧手順 があるか
  • 名前と権限を明示しているか
  • Global\ が本当に必要か
  • resize in place を前提にしていないか
  • writer kill / reader stall / version mismatch / 権限不足を試したか

9. まとめ

共有メモリは、うまく使えばかなり強いです。 特に、

  • 画像
  • 音声
  • センサ列
  • 大きなバッチ
  • 高頻度 snapshot

のような 同一マシン内の大きなデータ では、本当に効きます。

ただし、共有メモリの本体は「速さ」より 責任の移動 です。 コピーやカーネル越しのメッセージングを減らす代わりに、

  • 同期
  • 可視性
  • 初期化
  • ABI
  • 復旧
  • 権限
  • 観測可能性

をこちらで引き受けることになります。

なので、最初の 1 本目はこうするのが安全です。

  • SPSC ring buffer かダブルバッファ
  • 先頭固定ヘッダ
  • offset 参照
  • 別チャネルで通知
  • version / generation / heartbeat あり
  • 異常系テストあり

この形から始めると、shared memory はかなり素直な道具になります。 逆に、いきなり「何でも置ける速い共通メモリ」として扱うと、だんだんアプリではなく考古学になります。

10. 参考資料

  • Windows: file mapping と named shared memory の基本682
  • Windows: namespace / security / synchronization1524512
  • POSIX: shm_open, shm_unlink, mmap, process-shared / robust synchronization322162013
  • .NET: MemoryMappedFile の概要1
  1. Microsoft Learn, “メモリ マップト ファイル” / Microsoft Learn, “MemoryMappedFile クラス”  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” / Microsoft Learn, “名前付き共有メモリの作成”  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, “ファイル マッピングのセキュリティとアクセス権” / Microsoft Learn, “File Mapping Security and Access Rights”  2

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

共有メモリに書いた値は他のプロセスからすぐ正しく読めますか?
見えることと安全に読めることは別問題です。共有メモリは同じバイト列を複数プロセスから見せる仕組みであって、同期そのものではありません。writerがlength、payload、ready flagの順に書くつもりでも、reader側が何の同期もなく読むと、新しいlengthと古いpayloadを組み合わせて見ることがあります。WindowsでもPOSIXでも、共有メモリへのアクセスはmutex・semaphore・eventなどの同期手段と組み合わせる前提です。
共有メモリにポインタやstd::string、HANDLEを置いてもよいですか?
置かないほうがよいです。仮想アドレスやprocess-localな資源はそのプロセスの文脈にしか意味がなく、同じmappingを別プロセスでmapしても仮想アドレスが一致するとは限りません。std::vectorやstd::mutex、CRITICAL_SECTIONなども同様です。参照が必要ならベースアドレスからのoffsetで持ち、共有メモリに置くデータは固定幅整数+明示的レイアウト+バージョン付きヘッダに寄せるのが安全です。
volatileを使えば共有メモリの同期は不要になりますか?
なりません。volatileは共有メモリ設計を救う魔法ではなく、少なくともatomicityとmutual exclusionは別問題です。volatile boolをbusy loopで見張る設計は、CPUを無駄に使い、payloadとready flagの順序保証が曖昧になり、途中状態を拾いやすいという問題があります。またWindowsのWaitOnAddressは同一プロセス内のthread向けで、cross-processの待機機構としては考えないほうが安全です。通知はeventやsemaphoreなど待てるprimitiveへ逃がします。
共有メモリの設計で最初に決めるべきことは何ですか?
4つあります。制御(開始・停止・通知)はメッセージ系、データ本体は共有メモリというcontrol planeとdata planeの分離、並行モデルを絞ること(最初はSPSCリングバッファかダブルバッファが事故りにくい)、誰が作り・初期化し・消し・回復させるかという所有者と寿命、そしてレイアウトやバージョンを含むABIの設計です。先頭ヘッダにmagic・version・size・state・generation・heartbeatを置くだけで、事故調査のしやすさがかなり変わります。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る