メモリオーダリングとメモリバリア
ロックなしの並行処理で「なぜか壊れる」の正体は、CPUとコンパイラによる命令の並べ替え。acquire/release/seq_cstとメモリバリアの仕組みを、ハードウェアの動作からつかめます。
- 1.CPUとコンパイラは単一スレッドで結果が変わらない範囲でメモリ操作を並べ替える。ストアバッファのせいで、自分の書き込みが他コアへ遅れて見える。
- 2.他スレッドから見える順序を保証するのがメモリ順序。acquireは後続の読み書きが前に出るのを、releaseは先行の読み書きが後ろに回るのを防ぎ、seq_cstは全体で一貫した1つの順序を作る。
- 3.並べ替えを禁じる境界を引くのがメモリバリア(フェンス)。ロックやアトミック変数は内部でこれを発行しているので、自前のロックフリーでだけ意識すればよい。
なぜ書いた順に見えないのか──並べ替えの正体
ソースコードの行の順番と、CPUが実際にメモリへ反映する順番は一致しません。コンパイラ と CPU が、それぞれ独立に命令を並べ替えるからです。
両者が守るのは1つだけ。「単一スレッドから見て結果が変わらない範囲」 でなら、自由に順序を入れ替えてよい、というルールです。
プログラム順(書いた順) 実際の実行順(一例)
data = 42 ready = 1 ← 先に出てしまう
ready = 1 data = 42
data と ready は別アドレスで依存がないため、この入れ替えは単一スレッドでは何の問題も起こしません。ところが 別スレッドが ready を見て data を読む と、ready==1 なのに data がまだ 0、という事故が起きます。
並べ替えの主体は2つあります。コンパイラ(最適化で命令を動かす)と CPU(実行時に動かす)。volatile はコンパイラの並べ替えやキャッシュを抑えるだけで、CPUの並べ替えは止められません。マルチコアでの同期には役立たない点に注意。
ストアバッファ:自分の書き込みが他コアに遅れて届く
CPUの並べ替えの主犯が ストアバッファ です。コアがメモリへ書き込むとき、キャッシュへの反映完了を待つと遅いので、書き込みを いったん手元のバッファに溜めて 次の命令へ進みます。バッファの中身は後からまとめてキャッシュへ流れます。
ここで2つの非対称が生まれます。
- 自分の書き込みは自分からはすぐ読める(バッファを覗くため)。
- 他コアからは、バッファが掃き出されるまで見えない(書いたのに古い値が見える窓ができる)。
この結果、自分のストア(書き込み)が、後続のロード(読み込み)を追い越して他コアに見える という現象が起きます。x86 でさえ許される「StoreLoad の並べ替え」がこれで、有名なデッカーのアルゴリズムが素朴な実装では壊れる原因になります。
コアA: flagA = 1; x = flagB // 書く前のflagBを読みうる
コアB: flagB = 1; y = flagA // 書く前のflagAを読みうる
→ ストアがバッファに留まる間に両者がロードすると、
x==0 かつ y==0 が同時に起こりうる(直感に反する)
アーキテクチャごとに「どの並べ替えを許すか」が決まっており、これを メモリモデル と呼びます。x86/x64(TSO) は比較的強く、許すのは StoreLoad のみ。ARM / RISC-V / POWER は弱く、LoadLoad・LoadStore・StoreStore も並べ替わります。x86 で動いたコードが ARM で壊れるのは、この強さの差が原因です。
メモリ順序:acquire / release / seq_cst
並べ替えを「禁止したい所だけ禁止する」ために、アトミック操作には メモリ順序(memory order) を指定します。代表的な3つを、何の並べ替えを止めるか で捉えるのが理解の近道です。
| メモリ順序 | 止める並べ替え | 典型的な使い所 |
|---|---|---|
| relaxed | 順序保証なし(アトミック性のみ保証) | 単純なカウンタ。順序を気にしない統計値 |
| acquire(読み込み側) | この後の読み書きが、この読み込みより前に出るのを禁止 | ロック取得・フラグを読んでデータを使う側 |
| release(書き込み側) | この前の読み書きが、この書き込みより後に回るのを禁止 | ロック解放・データを書いてフラグを立てる側 |
| seq_cst | 全スレッドで一貫した唯一の全順序を保証 | 迷ったときの安全な既定。最も強く最も重い |
肝は acquire と release が対になって働く ことです。スレッドBの release ストアを、スレッドAの acquire ロードが「観測した」なら、release より前のBの全書き込みは、acquire より後のAから必ず見える。これを release-acquire の同期(happens-before の確立) と呼びます。
スレッドB(生産者) スレッドA(消費者)
data = 42 while (ready.load(acquire) == 0) {}
ready.store(1, release) ─────► use(data) // data は必ず 42
release が「前の書き込みを後ろへ漏らさない蓋」、acquire が「後の読み込みを前へ漏らさない蓋」。両者で挟むことで、data=42 が ready=1 を追い越して見えることも、use(data) が ready 確認より前に走ることも防げます。
acquire は「後ろを前に出さない」、release は「前を後ろに回さない」。向きが逆の一方通行です。だからロック取得は acquire、ロック解放は release で十分で、クリティカルセクションの中身は外へ漏れないのに、入る前/出た後のコードは自由に最適化できます。これが排他制御の実装が速い理由でもあります。詳しくは 排他制御とデッドロック のアトミック操作の節と合わせて。
メモリバリア(フェンス):並べ替えを止める境界線
メモリ順序をアトミック変数に紐づけず、コード中に独立した「並べ替え禁止の境界」 を置くのが メモリバリア(メモリフェンス) です。バリアは「種類ごとに、またぐ並べ替えを禁止する」命令で、4方向で考えます。
| バリア種別 | 禁止する並べ替え | 意味 |
|---|---|---|
| LoadLoad | バリア前の読み込みと後の読み込み | 前の読みを終えてから後の読みを始める |
| StoreStore | バリア前の書き込みと後の書き込み | 前の書きを反映してから後の書きを出す |
| LoadStore | バリア前の読み込みと後の書き込み | 読んでから書く順を保つ |
| StoreLoad | バリア前の書き込みと後の読み込み | 最も重い。ストアバッファを掃き出させる |
acquire は内部的に LoadLoad + LoadStore バリアに、release は LoadStore + StoreStore バリアに相当します。seq_cst は加えて StoreLoad を含むため最も重く、x86 では MFENCE(または LOCK 付き命令)として現れます。前述のデッカー問題を素朴な実装で直すには、この StoreLoad バリアが要るわけです。
# x86 で seq_cst ストアに相当する典型コード
mov [flag], 1 ; ストア
mfence ; StoreLoad バリア(バッファを掃き出す)
mov eax, [other] ; このロードがストアを追い越せなくなる
StoreLoad バリアはストアバッファを実質フラッシュさせるため、数十サイクル規模で重いことがあります。不要な seq_cst の濫用 はマルチコアのスケールを殺します。逆に 必要なバリアの欠落 は、テストでは再現せず本番の特定CPU・特定負荷でだけ壊れる最悪のバグになります。「正しさを最優先し、計測してから relaxed 化」が鉄則です。
まとめ──普段は意識しなくていい、自前で組むときだけ要る
CPUとコンパイラは 単一スレッドで結果が変わらない範囲で命令を並べ替え、ストアバッファ のせいで自分の書き込みが他コアへ遅れて見える。これが「ロックなしでなぜか壊れる」の正体です。メモリ順序 はこの並べ替えを必要な箇所だけ禁じる指定で、release-acquire の対 が happens-before を作り、seq_cst が全体で一貫した唯一の順序を保証します。それを実装する道具が メモリバリア(フェンス)。重要なのは、ミューテックスやアトミック変数は内部で適切なバリアを発行している こと。だから普通にロックを使う限りメモリオーダリングを意識する必要はなく、自前のロックフリー構造を組むときだけ 立ち向かえばよい領域です。土台となるスレッドの基礎は プロセスとスレッド、コアを切り替える仕組みは コンテキストスイッチ も合わせてどうぞ。
OS Article
メモリオーダリングとメモリバリアを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メモリオーダリング
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 5
導入後に効く点
他スレッドから見える順序を保証するのがメモリ順序。acquireは後続の読み書きが前に出るのを、releaseは先行の読み書きが後ろに回るのを防ぎ、seq_cstは全体で一貫した1つの順序を作る。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 5
判断チェックリスト
- 自社の用途が「メモリオーダリング / メモリバリア」に近いか確認する。
- 強みである「CPUとコンパイラは単一スレッドで結果が変わらない範囲でメモリ操作を並べ替える。ストアバッファのせいで、自分の書き込みが他コアへ遅れて見える。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。