共有メモリの落とし穴とベストプラクティスの知識マップ(知識グラフ)
共有メモリはWindowsのCreateFileMapping/MapViewOfFileやPOSIXのshm_open/mmapを使い、同じ物理ページを複数プロセスから見せる仕組みだが、同期そのものではないため、同期なしで複数フィールドを読み書きするとwriterの書き込み途中を読んでしまう事故が起きうる。この事故はSPSCリングバッファやダブルバッファによるcommitプロトコルで防ぎ、生ポインタやHANDLEなどprocess-local資源はそのまま置けないためオフセット参照に置き換え、control planeとdata planeを分離して通知は別チャネルへ逃がすことが推奨される。加えて、ABIの固定、作成者だけが初期化する運用による初期化レースの回避、Windowsのabandoned mutexやPOSIXのrobust mutexを使ったクラッシュ復旧設計、名前空間・権限の構成、世代ごとのサイズ固定が、事故率を下げる実務上の要点になる。
共有メモリ
同じ物理ページを複数プロセスの仮想アドレス空間へマップし、同じバイト列を複数プロセスから見せる仕組み。同期そのものではない。
共有メモリIPC
CreateFileMapping/MapViewOfFile
Windowsで名前付き共有メモリを作成・オープンし、プロセスのアドレス空間にマップするAPIの組み合わせ。pagefile-backedに使うとディスク非連携の共有メモリになる。
shm_open/ftruncate/mmap
POSIXでshared memory objectを作成・サイズ設定し、プロセスのアドレス空間にマップするAPIの組み合わせ。
POSIX共有メモリAPI
ファイルマッピング(メモリマップトファイル)
ファイルの内容をプロセスやシステムのアドレス空間にマップする仕組み。ディスク上のファイルに裏打ちされる形と、CreateFileMappingにINVALID_HANDLE_VALUEを渡してページファイルに裏打ちされる形(名前付き共有メモリ)がある。Windowsのキャッシュマネージャー自身の実装基盤でもある。
メモリマップトファイル
途中状態の読み取り
writerがlength・payload・ready flagなど複数フィールドを書き終える前に、readerがその隙間へ入り、新しいフィールドと古いフィールドを組み合わせて読んでしまう現象。
SPSCリングバッファ
producerがwrite_seqの位置へ書き、consumerがread_seqの位置から読む、writerとreaderがそれぞれ1つだけの固定長スロット配列。
single-producer single-consumer ring buffer
ダブルバッファによるcommitプロトコル
非公開側バッファへ書き、長さやチェックサムを確定したうえでactive buffer indexを切り替え、readerは読み終えたあとにindexが変わっていないか再確認する公開手順。
ダブルバッファ
control planeとdata planeの分離
開始・停止・エラーなどの制御はメッセージ系、フレームやバッチなどのデータ本体は共有メモリに置く設計方針。
制御と本体の分離
共有メモリの固定ヘッダ
magic・abi_version・state・generation・heartbeatなどを先頭に置き、別物や未初期化・layout差異・初期化途中を検出できるようにする共有メモリのヘッダ設計。
先頭ヘッダ
オフセット参照
共有メモリ内の参照をポインタではなくセグメント先頭からの相対位置(offset)で持ち、各プロセスがbase + offsetで自分のアドレスに直す参照方式。
process-localな資源
生ポインタ、HANDLE、file descriptor、std::string、std::mutexなど、その値がプロセスの文脈にしか意味を持たない資源。
生ポインタ
共有メモリのABI設計
型のサイズ、alignment、padding、構造体の並び順など、共有メモリに置くデータが満たすべきバイナリレベルの約束。
共有メモリのバイナリ約束
初期化レース
作った側が初期化したはずという思い込みのまま、open直後に使う・初期化完了フラグがない・参加者が同時に初期化するなどで共有メモリが壊れる状態。
初期化競合
creatorだけが初期化する運用
共有メモリを作成した側だけが初期化を行い、あとから参加する側はREADY状態になるのを待つ運用ルール。
creator/joinerパターン
クラッシュ復旧設計
writerが共有データ更新中に落ちた場合に備えて、generation番号・最終commit済みsequence・heartbeat・dirty/cleanフラグ・破損時の全再初期化手順などを用意する設計。
共有メモリの復旧手順
abandonedになったmutex(Windows)
所有スレッドがreleaseせずに終了したためabandonedになったWindowsのmutex。wait側はWAIT_ABANDONEDを受け取り、共有資源が不定状態かもしれないと判断できる。
robust mutex(POSIX)
ownerが死んだときEOWNERDEADを返し、修復後にpthread_mutex_consistent()を呼ぶことで再利用できるPOSIXのmutex機構。
false sharingとキャッシュライン競合
producerが更新するwrite_indexとconsumerが更新するread_indexなど、更新頻度の高い複数のフィールドが同じキャッシュラインに乗り、CPU間でラインが行き来して遅くなる現象。
名前空間と権限(Global/Local)
Windowsの名前付き共有メモリが従う、Global\/Local\の名前空間、event・semaphore・mutex等と共有される命名空間、ACL、SeCreateGlobalPrivilegeなどの権限の仕組み。
世代ごとのサイズ固定運用
共有メモリのサイズをその世代では不変にし、拡張が必要なら新しいversion/name/generationのsegmentを作って参加者を切り替え、旧segmentを閉じる運用。
resize in placeを避ける
他マシンとの共有
ネットワーク越しの共有ファイルをマップするなどして、共有メモリの仕組みを別マシンとの間でも使おうとすること。
リモート越しの共有メモリ
通知を別チャネルへ逃がす設計
readyフラグの共有メモリ上での表現に頼らず、event・semaphore・named pipeなど待てるprimitiveへ完了通知を分離する設計。
待てるprimitiveへの分離
busy loopによるポーリング通知
共有メモリ上のreadyフラグをwhileループで見張り続けて通知を代替する実装パターン。
名前付きパイプ
Windowsのプロセス間通信手段のひとつ。リモートからの利用はアプリ自身のポートではなくSMB(TCP 445)を経由する。
Windowsでは共有メモリの骨格としてCreateFileMapping/OpenFileMapping/MapViewOfFileを利用する。
POSIXでは共有メモリの骨格としてshm_open/ftruncate/mmapを利用する。
INVALID_HANDLE_VALUEを指定したCreateFileMappingは、ディスク上のファイルではなくページファイルに裏打ちされたfile mapping objectを作り、ファイルマッピングの仕組みを利用する。
同じfile mapping objectから作ったviewは同時点でcoherentだが、それは読者が常に一貫した更新済みレコードを読めることを意味しないため、同期なしで複数フィールドを読むと途中状態を読んでしまうことがある。
データを書き終えてからindexを進めるという順序が、release/acquireのようなメモリバリアで実際に保証されている場合、SPSCリングバッファはreaderが途中状態を読むことを防ぐ。フラグを最後に書くだけではコンパイラやCPUの並べ替えに対して順序が保証されず、防げない。
非公開側で書き終えてからactive indexを切り替える更新がメモリバリアで順序付けられ、かつreaderが読み終えたあとに世代を再確認する場合、ダブルバッファのcommitプロトコルはreaderが途中状態を読むことを防ぐ。順序付けと再確認のどちらを欠いても防げない。
開始・停止・通知はメッセージ系、データ本体は共有メモリという分離が、共有メモリ設計に対する本記事の推奨である。
magic・abi_version・state・generation・heartbeatを持つ固定ヘッダを先頭に置くことが、共有メモリ設計に対する本記事の推奨である。
参照をポインタではなくbase+offsetで持つことが、共有メモリ設計に対する本記事の推奨である。
同じmappingを別プロセスでmapしても仮想アドレスは一致するとは限らないため、生ポインタやHANDLEなどprocess-localな資源をそのまま共有メモリに置いても他プロセスでは意味を持たない。
共有メモリはAPIではなくABI(バイナリインターフェース)の話であり、型サイズ・alignment・padding・構造体の並び順の一致を前提とする。
作った側が初期化したはずという思い込みのまま参加者が同時に初期化したりopen直後に使ったりすると、初期化レースが起こることがある。
作成側だけが初期化し、参加側はREADYを待つ運用は、初期化レースを防ぐ。
generation番号・最終commit済みsequence・heartbeatなどによる復旧手順を用意することが、共有メモリ設計に対する本記事の推奨である。
Windowsでは、所有スレッドがreleaseせずに終了するとmutexがabandonedになり、wait側が受け取るWAIT_ABANDONEDを共有資源が不定状態かもしれないという復旧の手がかりに利用できる。
POSIXでは、ownerが死んだときEOWNERDEADが返り、修復後にpthread_mutex_consistent()を呼ぶ流れを復旧に利用できる。
producerとconsumerが更新するカウンタなど、更新頻度の高いフィールドが同じキャッシュラインに乗っていると、false sharingが起きて性能が落ちることがある。
Windowsの名前付き共有メモリは、Global\/Local\の名前空間、ACL、SeCreateGlobalPrivilegeなどの権限で構成される。
サイズはその世代では不変にし、拡張が必要なら新しい世代のsegmentへ切り替えることが、共有メモリ設計に対する本記事の推奨である。
共有メモリは基本的に同一ホスト内の仕組みであり、他マシンとの共有には向かない。
remote fileに対してはcoherenceが保証されず、2台が同じページをwritableにmapしても互いの書き込みは見えずマージもされない。
readyフラグを共有メモリに置くだけでなく、通知はevent・semaphore・named pipeなど待てるprimitiveへ逃がすことが、共有メモリ設計に対する本記事の推奨である。
共有メモリ上のreadyフラグをbusy loopでポーリングする通知設計は、CPUの浪費や取りこぼしの原因になるため推奨されない。
共有メモリの通知を別チャネルへ逃がす選択肢の1つとして、Windowsのnamed pipeを利用できる。