共有メモリの安全な使い方

· · Shared Memory, IPC, Concurrency, C++, C#, Windows開発

まず結論

共有メモリは「コピーを減らせる代わりに、整合性の責任をアプリ側が負うIPC」

  • 速いのは同じマシン内で大きなデータをやり取りするとき
  • 小さい制御メッセージだけならpipeやsocketの方が楽
  • 見えること安全に読めることは別問題
  • volatileは同期の魔法ではない
  • 生ポインタやstd::stringstd::mutexをそのまま置くと後で泣く

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

場面 向き・不向き 理由
同一マシン内で大きなフレームやバッファを渡す 向いている コピー回数を減らせる
高頻度のセンサ値、画像、音声 向いている 低レイテンシを狙える
小さいコマンドや応答だけ あまり向かない 同期コストが相対的に重い
他マシンとのやり取り 向かない 同一ホスト前提
異なる言語・バージョンが長期共存 難しい ABI設計が必要

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

  1. control planeとdata planeを分ける - データ本体は共有メモリ、通知はevent/pipe/socket
  2. 並行モデルを絞る - SPSC(1対1)が一番簡単
  3. 所有者と寿命を決める - 誰が作り、誰が消すか
  4. ABIとバージョンを決める - レイアウト、型サイズ、バージョン番号

よくある落とし穴

1. 同期しない

「同じメモリを見ているから書いたら読める」は間違い。正しいタイミングで、正しい単位で、正しい順序で読める保証はない。必ずmutex/semaphore/eventなど別の同期手段と組み合わせる。

2. volatileで何とかしようとする

volatileはatomicityもmutual exclusionも保証しない。volatile bool readyでbusy loopする設計はCPUを無駄に使い、途中状態を拾いやすい。

3. 途中状態を読ませる

複数フィールドからなるレコードを公開するとき、readerが「新しい長さと古いペイロード」を組み合わせて見ることがある。対策:

  • mutexで守る
  • ダブルバッファにして有効バッファ番号を切り替える
  • リングバッファにしてslotごとにstate/sequenceを持つ

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

生ポインタ、HANDLEstd::stringstd::vectorstd::mutexはプロセスをまたげない。参照はベースアドレスからのoffsetで持つ。

5. ABIが壊れる

共有メモリはソースコードではなくバイナリの約束。int/longのサイズ、boolの表現、32bit/64bit、alignment、paddingの違いが全部効く。固定幅整数(uint32_t等)を使い、static_assert(sizeof(...))で確認する。

6. 初期化レース

「作った側が初期化したはず」と思い込むと壊れる。先頭ヘッダにstate(INITIALIZING/READY/BROKEN)を置き、creatorだけが初期化し、joinerはREADYを待つ。

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

writerが更新中に落ちたらどうするか。最低限、generation番号、最終commit済みsequence、heartbeat、dirty/cleanフラグを持たせる。

8. 通知まで全部共有メモリに押し込む

while(!ready) Sleep(1);はCPUを無駄に使う。通知は待てるprimitive(event/semaphore)へ逃がす。

9. 名前・権限を軽く見る

WindowsではGlobal\Local\のnamespaceがある。session 0以外からGlobal\を作るにはSeCreateGlobalPrivilegeが必要。object名はevent/mutex/semaphoreとnamespaceを共有するので同名衝突に注意。

ベストプラクティス

先頭に固定ヘッダを置く

typedef struct SharedHeader {
    uint32_t magic;          // 別物や未初期化を弾く
    uint16_t abi_version;    // レイアウト差異を弾く
    uint16_t header_size;
    uint32_t state;          // 0=initializing, 1=ready, 2=broken
    uint64_t total_size;
    uint64_t generation;     // 再作成を検知
    uint64_t heartbeat_ns;   // 死活を見る
    uint64_t payload_offset;
    uint64_t payload_size;
    uint8_t  reserved[64];   // 将来拡張の逃げ道
} SharedHeader;

その他のベストプラクティス

  • 参照はoffsetで持つ - base + offsetで解決し、範囲チェックを入れる
  • 並行モデルを絞る - 最初はSPSCリングバッファかダブルバッファから始める
  • commitプロトコルを明示する - 「どの瞬間から読んでよいか」を決める
  • サイズは世代ごとに固定する - resize in placeより、新しい世代を作って切り替える
  • 観測可能性を入れる - 最終更新時刻、drop数、version mismatch数、heartbeat
  • 異常系テストを先に作る - writer kill, reader stall, version mismatch, 権限不足

WindowsとPOSIXで使うAPI

操作 Windows POSIX
作成/open CreateFileMapping / OpenFileMapping shm_open / ftruncate / mmap
同期 mutex / semaphore / event process-shared mutex / semaphore
使ってはいけない CRITICAL_SECTION, WaitOnAddress PTHREAD_PROCESS_PRIVATEのままのmutex
owner death WAIT_ABANDONED robust mutex + EOWNERDEAD

C#のMemoryMappedFileも本質はWindowsのfile mappingのラッパー。基本ルールは同じ。

まとめ

共有メモリの本体は「速さ」より責任の移動。コピーを減らす代わりに、同期・ABI・復旧・権限を自分で引き受ける。

最初の1本目はこう作る:

  • SPSCリングバッファかダブルバッファ
  • 先頭固定ヘッダ(magic, version, state, generation)
  • offset参照
  • 別チャネルで通知
  • 異常系テストあり

関連する記事

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

シリアル通信アプリ開発の落とし穴

シリアル通信アプリで詰まりやすいフレーミング、複数タイムアウト、RTS/CTS や DTR、USB 変換時の再接続、UI フリーズ回避、single writer 設計、生ログまで、実装前に押さえたい論点を実務目線で整理した解説記事です。

記事を読む

WinForms/WPF/WinUIの選定ガイド

Windows Forms、WPF、WinUI の三択を、新規開発か既存資産延命か、配布要件、UI 表現の必要度、チーム文化の観点で判断表に整理し、Windows App SDK との混同や全面リライトの落とし穴も避けつつ、摩擦の少ない選択を実務視点で見極めます。

記事を読む

関連トピック

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

このテーマがつながるサービス

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

ブログ一覧に戻る