メモリバリアの種類とアーキテクチャ別の振る舞い
x86で動いたロックフリーコードがARMで壊れる理由を、TSOと弱順序の差から原理でつかめます。smp_mb/rmb/wmbとacquire/releaseの対応、コンパイラバリアとの違いまで具体例で整理します。
- 1.x86はTSO(許す並べ替えはStoreLoadのみ)と強く、ARM/POWERはLoadLoad・StoreStoreまで並べ替わる弱順序。だからx86で偶然動いたコードがARMで壊れる。
- 2.Linuxのsmp_mbは全方向フェンス、smp_rmbはLoadLoad、smp_wmbはStoreStoreに対応。acquire/releaseは片側だけ止める一方通行で、これらより軽い。
- 3.barrier()やvolatileはコンパイラの並べ替えだけを止め、CPUの並べ替えは止めない。CPUバリアは逆に単独ではコンパイラを縛らないため、両方を兼ねる命令が要る。
アーキテクチャごとに「許す並べ替え」が違う
メモリバリアの話が厄介なのは、何を止めるべきかがCPUによって変わる からです。各アーキテクチャは「どのメモリ操作の並べ替えを許すか」を取り決めており、これを メモリ整合性モデル と呼びます。並べ替えは4方向(LoadLoad / LoadStore / StoreStore / StoreLoad)で整理するのが定石です。バリアそのものの4分類は メモリオーダリングとメモリバリア を土台にしてください。本稿はその先、アーキテクチャ差 に踏み込みます。
| アーキテクチャ | モデル | 許される並べ替え |
|---|---|---|
| x86 / x86-64 | TSO(Total Store Order) | StoreLoad のみ |
| SPARC(TSOモード) | TSO | StoreLoad のみ |
| ARMv8 (AArch64) | 弱順序+アドレス依存保持 | ほぼ全方向(依存のあるLoadは保持) |
| POWER / PowerPC | 弱順序 | ほぼ全方向。最も緩いとされる |
| RISC-V (RVWMO) | 弱順序 | ほぼ全方向。fenceで明示制御 |
つまり x86は強く、ARM/POWER/RISC-Vは弱い。この差こそが「x86のラップトップでは何年も動いていたロックフリーコードを、ARMサーバーへ載せた途端に壊れた」という典型事故の正体です。
x86のTSO:唯一の緩みはStoreLoad
x86のTSOは「ほぼプログラム順だが、StoreだけがあとのLoadに追い越される」モデルです。緩みの源は各コアの ストアバッファ。書き込みをいったんバッファに溜めて先へ進むため、自分のStoreがキャッシュへ反映される前に、後続のLoadが他コアの値を読んでしまいます。
逆に言えば、TSOでは LoadLoad・LoadStore・StoreStore は決して並べ替わりません。だから生産者・消費者パターン(データを書いてからフラグを立て、フラグを見てからデータを読む)は、x86では バリアなしでも偶然動いてしまう のです。
x86でテストが通っても、それはTSOが強いおかげで必要なバリアの欠落が露見していないだけかもしれません。同じソースをARMでビルドすると、保持されていたLoadLoad/StoreStoreの順序が崩れて初めてバグが顕在化します。移植性のあるコードは、強いアーキテクチャの偶然に頼らず弱順序前提で書く のが鉄則です。
唯一残るStoreLoadの緩みを止めたいときだけ、x86では MFENCE(または LOCK 接頭辞付き命令)が要ります。seq_cst ストアがこれに相当し、デッカーのアルゴリズムのような相互排他を素朴に組むと、このフェンスがないと両者が同時に進入できてしまいます。
ARM/POWERの弱順序:依存だけが順序を保つ
ARMv8やPOWERでは、独立したメモリ操作はほぼ自由に並べ替わります。ただし完全な無法ではなく、アドレス依存 は守られます。あるLoadの結果をアドレスに使う次のLoad(p = load(&ptr); v = load(p) のような連鎖)は、ハードウェアが順序を保証します。これを利用するのが rcu_dereference の軽量さの背景です(詳しくは RCU(Read-Copy-Update)の原理)。
問題は 制御依存 と 無関係なペア です。if (flag) use(data) のように分岐を挟むだけでは、data のLoadが flag のLoadを追い越しうる。だから弱順序機ではフラグ経由のデータ受け渡しに 明示的なバリアが必須 になります。
// ARM/POWER で安全にするには両側にバリアが要る
生産者: data = 42; smp_wmb(); flag = 1; // StoreStore: data を先に見せる
消費者: if (flag) { smp_rmb(); use(data); } // LoadLoad: flag を読んでから data
ARMv8世代では、これらをまとめて表現する ロード獲得(LDAR)/ストア解放(STLR) 命令が追加され、acquire/release を1命令で表せるようになりました。汎用の DMB(Data Memory Barrier)より軽量で、acquire-releaseと相性が良いのが要点です。
smp_mb / rmb / wmb と acquire / release の対応
Linuxカーネルは、これらアーキテクチャ差を吸収するためにポータブルなバリアマクロを提供します。コンパイラは各アーキテクチャ向けに適切な実機命令へ展開します。
| Linuxマクロ | 禁止する並べ替え | 概念的な対応 |
|---|---|---|
| smp_mb() | 全方向(Load/Store の4種すべて) | seq_cst フェンス相当・最も重い |
| smp_rmb() | LoadLoad(読み同士) | 読み手側の順序固定 |
| smp_wmb() | StoreStore(書き同士) | 書き手側の順序固定 |
| smp_load_acquire() | この後の読み書きが前に出るのを禁止 | acquire(一方通行・片側の蓋) |
| smp_store_release() | この前の読み書きが後ろに回るのを禁止 | release(一方通行・片側の蓋) |
注目すべきは コストの階段 です。smp_mb は最も重い全方向フェンス(x86では MFENCE、ARMでは DMB ISH)。smp_rmb/smp_wmb はその半分だけを止め、acquire/release は 片側方向だけ を止める一方通行の蓋なので、さらに軽くなります。release-acquireの「対で挟むと happens-before が成立する」性質は メモリオーダリングとメモリバリア のとおりで、ここで強調したいのは 必要最小のバリアを選べば弱順序機でも速い という点です。
smp_mb の smp_ は、ユニプロセッサ(CONFIG_SMP=n)ビルドでは コンパイラバリアだけに退化 することを意味します。単一コアなら他コアとの可視順序を気にする必要がないからです。対して接頭辞なしの mb() は、デバイスのMMIOなどハードウェアとの順序が要る場面用で、SMP設定に関わらず実機フェンスを出します。用途を取り違えないことが大切です。
コンパイラバリアとCPUバリアは別物
最後に最も誤解の多い区別を押さえます。並べ替えの主体は コンパイラ と CPU の2つで、止める道具も別です。
- コンパイラバリア:
barrier()(Linux)やasm volatile("" ::: "memory")、C11のatomic_signal_fence。コンパイラに「この点をまたいでメモリアクセスを動かすな」と命じるだけ。実機命令は1つも生成しません。 - CPUバリア:
MFENCE/DMBなどの実命令。実行時のハードウェア並べ替えを止める。
ここに罠があります。CPUバリア命令は、それ単独ではコンパイラの並べ替えを縛りません。逆にコンパイラバリアはCPUを縛りません。だから本物のメモリバリアは 両方を兼ねる 必要があります。Linuxの smp_mb などは内部で asm volatile(... ::: "memory") を含み、コンパイラとCPUの両方に効くよう作られています。
volatile が保証するのは「その変数アクセスを最適化で消したり融合したりしない」ことだけです。他の変数との順序も、CPUの並べ替えも保証しません。Javaの volatile(acquire/release相当の順序を持つ)とC/C++の volatile(順序保証なし)は別物であることも、移植時の事故の温床です。マルチコアの同期に volatile を使うのは誤りで、必ずアトミック操作かバリアを使ってください。
// 不十分:CPUバリアだけ。コンパイラが flag を data より前に動かしうる
data = 42;
asm volatile("dmb ish"); // ← "memory" クローバ無しだとコンパイラには無力
flag = 1;
// 正しい:コンパイラ+CPU 両方を止める
data = 42;
smp_wmb(); // 内部で "memory" クローバ付き → 両方に効く
flag = 1;
- x86=TSO:許す並べ替えは StoreLoad のみ。ARM/POWER/RISC-V=弱順序:ほぼ全方向(依存のあるLoadだけ保持)。
- smp_mb=全方向、smp_rmb=LoadLoad、smp_wmb=StoreStore。acquire/release は片側だけ止める一方通行で、より軽い。
- コンパイラバリア(
barrier()/volatile)は実機命令を出さずコンパイラだけ縛る。CPUバリア(MFENCE/DMB)は逆。本物のバリアは両方を兼ねる。 - x86で動いてもARMで壊れうる。弱順序を前提に最小限のバリアを置く のが移植性と性能の両立点。
まとめ──強いx86に甘えず、弱順序前提で最小限を置く
メモリバリアで何を止めるべきかは アーキテクチャの整合性モデル で決まります。x86のTSO はStoreLoadしか並べ替えず強いため、必要なバリアが欠けていても偶然動いてしまう。一方 ARM/POWER/RISC-V の弱順序 はLoadLoad・StoreStoreまで並べ替わるので、フラグ経由のデータ受け渡しには明示的なバリアが必須です。Linuxは差を吸収するため smp_mb(全方向)/smp_rmb(LoadLoad)/smp_wmb(StoreStore) と、より軽い acquire/release を提供し、必要最小を選べば弱順序機でも速く保てます。そして コンパイラバリアとCPUバリアは別物 で、本物のバリアは両方を兼ねる——volatile は代用になりません。土台のキャッシュ動作は メモリ階層とキャッシュコヒーレンシ(MESIプロトコル)、これらバリアを内部で発行する道具は カーネルのロック機構(spinlock・mutex・seqlock) と カーネルのロックフリー同期とCAS が引き受けます。普段はロックやアトミックがバリアを隠してくれるので、自前のロックフリーを弱順序機へ載せるときだけ この章に立ち返れば十分です。
OS Article
メモリバリアの種類とアーキテクチャ別の振る舞いを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メモリバリア
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
Linuxのsmp_mbは全方向フェンス、smp_rmbはLoadLoad、smp_wmbはStoreStoreに対応。acquire/releaseは片側だけ止める一方通行で、これらより軽い。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「メモリバリア / メモリオーダリング」に近いか確認する。
- 強みである「x86はTSO(許す並べ替えはStoreLoadのみ)と強く、ARM/POWERはLoadLoad・StoreStoreまで並べ替わる弱順序。だからx86で偶然動いたコードがARMで壊れる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。