共有メモリの安全な使い方
まず結論
共有メモリは「コピーを減らせる代わりに、整合性の責任をアプリ側が負うIPC」。
- 速いのは同じマシン内で大きなデータをやり取りするとき
- 小さい制御メッセージだけならpipeやsocketの方が楽
- 見えることと安全に読めることは別問題
volatileは同期の魔法ではない- 生ポインタや
std::string、std::mutexをそのまま置くと後で泣く
共有メモリが向いている場面 / 向いていない場面
| 場面 | 向き・不向き | 理由 |
|---|---|---|
| 同一マシン内で大きなフレームやバッファを渡す | 向いている | コピー回数を減らせる |
| 高頻度のセンサ値、画像、音声 | 向いている | 低レイテンシを狙える |
| 小さいコマンドや応答だけ | あまり向かない | 同期コストが相対的に重い |
| 他マシンとのやり取り | 向かない | 同一ホスト前提 |
| 異なる言語・バージョンが長期共存 | 難しい | ABI設計が必要 |
最初に決めるべき4つのこと
- control planeとdata planeを分ける - データ本体は共有メモリ、通知はevent/pipe/socket
- 並行モデルを絞る - SPSC(1対1)が一番簡単
- 所有者と寿命を決める - 誰が作り、誰が消すか
- ABIとバージョンを決める - レイアウト、型サイズ、バージョン番号
よくある落とし穴
1. 同期しない
「同じメモリを見ているから書いたら読める」は間違い。正しいタイミングで、正しい単位で、正しい順序で読める保証はない。必ずmutex/semaphore/eventなど別の同期手段と組み合わせる。
2. volatileで何とかしようとする
volatileはatomicityもmutual exclusionも保証しない。volatile bool readyでbusy loopする設計はCPUを無駄に使い、途中状態を拾いやすい。
3. 途中状態を読ませる
複数フィールドからなるレコードを公開するとき、readerが「新しい長さと古いペイロード」を組み合わせて見ることがある。対策:
- mutexで守る
- ダブルバッファにして有効バッファ番号を切り替える
- リングバッファにしてslotごとにstate/sequenceを持つ
4. ポインタや複雑オブジェクトをそのまま置く
生ポインタ、HANDLE、std::string、std::vector、std::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参照
- 別チャネルで通知
- 異常系テストあり
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
子プロセスを安全に扱うためのチェックリスト
Windows アプリで子プロセスを安全に扱うための設計指針を、プロセス木の所有権・終了伝播・標準入出力・watchdog 配置の4観点で整理し、Job Object を基準にした典型構成と落とし穴をまとめます。
C#をNative AOTでDLL化しC/C++から呼び出す方法
C# のクラスライブラリを Native AOT でネイティブ DLL として発行し、UnmanagedCallersOnly で C/C++ から呼び出す構成を、設計指針・最小実装・はまりどころまでまとめて整理した実務向け解説記事です。
シリアル通信アプリ開発の落とし穴
シリアル通信アプリで詰まりやすいフレーミング、複数タイムアウト、RTS/CTS や DTR、USB 変換時の再接続、UI フリーズ回避、single writer 設計、生ログまで、実装前に押さえたい論点を実務目線で整理した解説記事です。
WinForms/WPF/WinUIの選定ガイド
Windows Forms、WPF、WinUI の三択を、新規開発か既存資産延命か、配布要件、UI 表現の必要度、チーム文化の観点で判断表に整理し、Windows App SDK との混同や全面リライトの落とし穴も避けつつ、摩擦の少ない選択を実務視点で見極めます。
プログラミング言語の速度比較を正しく行う方法
C#・C++・Java・Goの実行速度を公平に比較するための測定設計、warm-upや環境固定の具体策、p95やGC pauseを含む統計の見方、実運用に近いベンチ項目の選び方までを実践目線で整理する解説記事です。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。