メモリモデルとhappens-before関係
並行バグの9割は可視性と並び替えの誤解から来る。何が保証され何が壊れるかを規定するメモリモデルとhappens-before関係を原理から押さえれば、ロックも不要な書き方も自信を持って選べる。
- 1.メモリモデルは「あるスレッドの書き込みが、いつ・どの順で他スレッドに見えるか」を規定する契約。コンパイラとCPUによる命令の並び替えを前提に、許される実行結果の範囲を定める。
- 2.happens-before関係は半順序で、AがBにhappens-beforeなら『Aの書き込みはBから必ず見える』。これで順序づけられないアクセスの組がデータ競合となり、結果は未定義になる。
- 3.ロック解放/取得、volatile/atomicのrelease/acquire書き込みが同期点を作り、その背後でメモリバリアが並び替えと可視性を制御する。
なぜ「モデル」が必要なのか
単一スレッドのプログラムは、ソースに書いた順番どおりに実行されたかのように振る舞います。しかし実際には、コンパイラは依存のない命令を入れ替え、CPUはストアバッファや投機実行で書き込みを遅延・先行させます。単一スレッドでは結果が変わらない限りこれらは「見えない」のですが、別スレッドがそのメモリを同時に観測する瞬間、並び替えと可視性の遅延が一気に表面化します。
メモリモデルとは、こうした最適化を許しつつ「あるスレッドの書き込みが、いつ、どの順序で、他のスレッドから観測されうるか」を厳密に定める契約です。プログラマは「この同期操作を入れればこの可視性が保証される」と推論でき、処理系は「この保証を壊さない範囲なら自由に並び替えてよい」と最適化できます。Java の JMM(Java Memory Model, JSR-133)と C++11 のメモリモデルは、いずれもこの契約を happens-before 関係という抽象で記述します。
直感的に欲しいのは逐次一貫性(sequential consistency, SC)——全スレッドの全アクセスが何らかの単一の全順序に並び、各スレッド内ではプログラム順を保つ、という世界です。しかしSCをハードウェアで常に保証するとストアバッファを毎回フラッシュする羽目になり、現代CPUでは致命的に遅くなります。そこで両モデルは「データ競合のないプログラムに限り、SCのように振る舞う」(DRF-SC保証)という妥協を採りました。競合を自分で消す代わりに、直感的な順序が戻ってくる契約です。
happens-before 関係の定義
happens-before(→ と書きます)は、プログラム中のアクション間に定義される半順序です。半順序なので、任意の2アクションが必ず順序づけられるわけではありません。順序づかないペアこそが問題の核心になります。
この関係は2つの源から組み立てられます。
- プログラム順序(program order):同一スレッド内で先に書いたアクションは、後のアクションに happens-before する。
- 同期順序(synchronizes-with):あるスレッドの解放操作(ロック解除、release ストア)が、別スレッドの取得操作(ロック取得、acquire ロード)に「観測される」とき、解放は取得に synchronizes-with する。
この2つを推移的に閉じたものが happens-before です。つまり A → B かつ B → C なら A → C。同期はスレッドをまたぐ「橋」を架け、プログラム順序が橋の両岸を伸ばす、という構図です。
スレッド1 スレッド2
data = 42; (A)
flag = true; (B, release ストア)
while(!flag); (C, acquire ロード)
use(data); (D)
A → B (プログラム順序)
B → C (synchronizes-with: CがBの書き込みを観測)
C → D (プログラム順序)
∴ A → D ⇒ Dからdataはちゃんと42に見える
ここで重要なのは、A → D が成立するからこそ data = 42 が D から確実に見える点です。同期がなければ B と C の間に橋が架からず、data の書き込みは D から見えるとは限りません(古い値や未初期化を読みうる)。
名前に反して、happens-beforeは「Aの方が時間的に先に起きた」という主張ではありません。「Aの効果がBから必ず見える」「AとBの間で並び替えが起きない」という可視性と順序の保証です。実時間で先でも、同期で結ばれていなければ可視性は保証されません。逆に happens-before で結ばれていれば、ハードウェアが内部でどう並び替えようと、観測結果は保証された通りになります。
データ競合の正体
両モデルは**データ競合(data race)**を厳密に定義します。すなわち——
2つのアクセスが、(1) 同じメモリ位置に触れ、(2) 少なくとも一方が書き込みで、(3) どちらも同期操作(atomic/volatile)でなく、(4) happens-before で順序づけられていない——とき、それらはデータ競合をなす。
ここが上級者の落とし穴です。「読み書きが同時に起きた」のが競合なのではなく、happens-before で順序づけられていない普通のアクセスの組が競合なのです。同時に走っていても、間に同期があって順序づけられていれば競合ではありません。
C++ ではデータ競合は 未定義動作(UB) です。「古い値が見える」程度では済まず、最適化の前提が崩れてプログラム全体の意味が壊れます。Java はメモリ安全性のため UB にはせず、代わりに「out-of-thin-air(どこからともなく現れる値)を除き、何らかの並び替え結果が観測されうる」という弱い保証に留めます。いずれにせよ、競合のあるコードの結果を推論してはいけないという結論は同じです。可変状態の共有を減らす設計が根本対策になる理由はここにあり、イミュータビリティ(不変性)が並行処理で強い所以でもあります。
同期なしの共有フラグを while(!flag); で待つコードは、競合であると同時にコンパイラが flag をレジスタにキャッシュしてループから追い出し、無限ループ化しうる正当な最適化対象です(コンパイラ最適化(SSA)の領域)。volatile(Java)/atomic(C++) にして初めて、毎回メモリから読み、かつ並び替え禁止の保証が付きます。「動いているように見えた」は単に運が良かっただけ、という典型例です。
メモリオーダーとバリアの原理
C++11 は同期の「強さ」を memory_order で段階的に選べます。これが happens-before を実装レベルで実現する**メモリバリア(フェンス)**に対応します。
| オーダー | 意味 | 禁止する並び替え | コスト |
|---|---|---|---|
| relaxed | 原子性のみ。順序保証なし | なし(単一変数の原子性だけ) | 最小 |
| acquire | このロード以降の読み書きを上へ動かさない | 後続アクセスの巻き上げ | 中 |
| release | このストア以前の読み書きを下へ動かさない | 先行アクセスの押し下げ | 中 |
| acq_rel | acquireとreleaseの両方(RMW向け) | 両方向 | 中 |
| seq_cst | 全seq_cst操作に単一全順序 | ほぼ全方向+全順序 | 最大 |
肝は release/acquire のペアリングです。スレッド1の release ストアを、スレッド2の acquire ロードが観測したとき、synchronizes-with が成立し、release より前の全書き込みが、acquire より後の読み出しから見えるようになります。release は「片方向の壁」——壁の上(前)の操作が壁を越えて下へ漏れるのを禁じ、acquire は逆に下の操作が上へ漏れるのを禁じます。この非対称な一方向バリアが、最小限のコストで「橋」を架ける仕組みです。
// 生産者(スレッド1)
data = compute(); // 通常書き込み
ready.store(true, std::memory_order_release); // releaseの壁:上の書き込みを下に漏らさない
// 消費者(スレッド2)
while (!ready.load(std::memory_order_acquire)) {} // acquireの壁:下の読みを上に漏らさない
use(data); // dataは確実にcompute()の結果
ハードウェアでは、これらは具体的なバリア命令やキャッシュコヒーレンス制御に落ちます。x86 は比較的強いメモリモデル(TSO, Total Store Order)を持ち、StoreLoad 以外の並び替えをそもそも行わないため acquire/release はほぼ無コストですが、ARM や POWER は弱順序で、dmb などの明示的バリアが挿入されます。同じソースでも CPU によって生成されるバリアの量が違うのは、ハードウェアのメモリモデルの強弱を吸収しているためです。
relaxed は読み書きが分割されない(torn read が起きない)原子性だけを保証し、他の変数との順序は一切保証しません。参照カウンタのインクリメントのように「合計が合えばよく、可視性タイミングは問わない」用途には最適ですが、relaxed で書いたフラグを根拠に別の通常変数を読むと、その通常変数は古い値のままでありえます。「atomicにしたから安全」という思い込みが最も危険で、何との順序が必要かを見極めてオーダーを選ぶ必要があります。
seq_cst と Java volatile
C++ のデフォルト seq_cst と Java の volatile(JSR-133 以降)は、acquire/release に加えて全 seq_cst 操作にまたがる単一の全順序を保証します。これにより、複数の独立した atomic 変数の間でも一貫した順序が観測でき、有名な「ストアバッファ問題」(2スレッドが互いの変数を書いてから相手を読むと両方が古い値を見る)を防げます。
Java の volatile 書き込みは release、読み出しは acquire として働き、加えて全 volatile アクセスに全順序が付くため、実質 seq_cst 相当です。Java の synchronized は、あるモニタの unlock が、同じモニタの後続 lock に synchronizes-with するルールで happens-before を作ります。final フィールドには特別規定があり、コンストラクタ完了後はデータ競合があっても初期値が見える保証が与えられます——これは安全な公開(safe publication)の土台です。
| 概念 | C++11 | Java (JMM) |
|---|---|---|
| 最強の順序 | memory_order_seq_cst | volatile / synchronized |
| 解放操作 | release ストア / unlock相当 | monitor unlock, volatile書き込み |
| 取得操作 | acquire ロード / lock相当 | monitor lock, volatile読み出し |
| 競合の扱い | 未定義動作(UB) | 弱保証(out-of-thin-air禁止) |
| 緩い原子性 | memory_order_relaxed | 直接対応なし(VarHandle で近似) |
まとめと実務指針
メモリモデルの核心は3点に集約できます。第一に、並び替えと可視性遅延は最適化の正当な帰結であり、それを前提に「許される結果の範囲」を定めるのがモデルの役割。第二に、happens-before という半順序が「この書き込みはあの読み出しから見える/見えない」を決め、順序づかない普通アクセスの組がデータ競合として未定義(C++)または弱保証(Java)の領域に落ちる。第三に、同期は release/acquire の一方向バリアとして実装され、seq_cst/volatile がさらに全順序を足す。
実務では、まずそもそも共有可変状態を避ける(不変データ、メッセージパッシング)のが最善で、これは並行性モデル(CSP・アクター・STM)が示す方向です。共有が避けられないなら、ロックか seq_cst の atomic で安全側に倒し、relaxed や acquire/release はプロファイルで本当に効くと確認できた箇所に限定します。並行処理の入口にあたる待ち合わせの考え方は同期処理と非同期処理も参照してください。
「happens-beforeは可視性と順序の保証であって実時間順ではない」——これを即答できるかが分水嶺です。データ競合の定義(同一位置・一方が書き込み・非atomic・happens-beforeで順序づかない)を4条件で言えること。release/acquireが一方向バリアでペアで初めて synchronizes-with を作ること。relaxedは原子性のみで順序保証なし、seq_cst/volatileは全順序まで足すこと。C++はデータ競合がUB、Javaは弱保証という違い。この5点を押さえれば、メモリモデル絡みの問いはほぼ対応できます。
プログラミング Article
メモリモデルとhappens-before関係を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メモリモデル
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
happens-before関係は半順序で、AがBにhappens-beforeなら『Aの書き込みはBから必ず見える』。これで順序づけられないアクセスの組がデータ競合となり、結果は未定義になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「メモリモデル / happens-before」に近いか確認する。
- 強みである「メモリモデルは「あるスレッドの書き込みが、いつ・どの順で他スレッドに見えるか」を規定する契約。コンパイラとCPUによる命令の並び替えを前提に、許される実行結果の範囲を定める。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。