Dev Study
OSと低レイヤのしくみ コース

17. メモリバリア — 書いた順に見えるとは限らない

ソースコードに書いた順番と、他のCPUから見える順番がズレる仕組みと、それを止めるメモリバリアを図で追っていきましょう。

① 書いた順に見えると思っている世界

プログラマが期待する素朴なモデル
CPU 0 のソースコードdata = 42; の次に flag = 1;
この順でメモリに反映される「はず」
メモリ: data = 42先に書き込まれる
メモリ: flag = 1後に書き込まれる
CPU 1 が観測
flag == 1 を見たならば data も 42 のはず
  • ・flag は「data の準備ができた」という合図のつもりで書いている
  • ・全CPUの操作が1つの順序に並ぶこのモデルを逐次一貫性(sequential consistency)と呼ぶ
  • ・実際のハードウェアはこのモデルを保証していない

data を書いてから flag を立てれば、flag を見た側は data も見えるはず。多くの人がまずこう考えます。この前提が崩れるところから始めます。

② 実際には順序が入れ替わる

コンパイラとCPUの二段階の並べ替え
ソースコードdata = 42; flag = 1;
コンパイラ最適化(命令スケジューリング)
機械語では順序が逆かもしれないstore flag が先に置かれうる
CPUのアウトオブオーダ実行・ストアバッファ
メモリに届く順もさらに変わるバリアが無ければ順序に制約は無い
結果
CPU 1: flag == 1かつ data == 0(古い値)
  • ・並べ替えの主体はコンパイラとCPUの2つあり、どちらも独立に起こる
  • ・memory-barriers.txt は「CPUはプログラムの因果関係が保たれるように見える限り、任意の順序でメモリ操作を行ってよい」と述べている
  • ・単一CPU内から自分の操作を見る限りは辻褄が合うため、バグは他CPUから観測したときだけ現れる

並べ替えは「サボり」ではなく最適化です。単一スレッドから見た結果は変わらないため、コンパイラもCPUも自由に順序を変えられます。

③ なぜ並べ替えるのか — ストアバッファ

ストアが遅いのでCPUは待たずに先へ進む
CPU コアstore data=42 を発行
キャッシュラインの所有権がまだ無いと待たされる
ストアバッファstore を溜めてコアを止めない
コアはここに置いた時点で次の命令へ
所有権が取れた順にドレイン(排出)
キャッシュ / メモリ到達順はバッファ内の事情で決まる
他コアが見えるのはこの時点
CPU 1 から見える順序発行順とは一致しない
  • ・ストアバッファが無いと、キャッシュミスのたびにコアが長時間止まる
  • ・data のキャッシュラインだけがミスして flag がヒットすると、flag が先に見える
  • ・つまり順序逆転は性能のための設計上の代償であり、バグではない

並べ替えが起きる代表的な理由がストアバッファです。書き込み完了を待たずに次へ進む設計が、結果として順序の逆転を生みます。

④ バリアで順序を固定する

書き手と読み手の両方にバリアが要る
CPU 0(書き手)WRITE_ONCE(data, 42);
CPU 1(読み手)x = READ_ONCE(flag);
それぞれバリアを挟む
smp_wmb()以前のstoreを以後のstoreより先に
書き込みバリア
smp_rmb()以前のloadを以後のloadより先に
読み込みバリア
バリアの後
WRITE_ONCE(flag, 1);
y = READ_ONCE(data);
保証される結果
x == 1 なら y == 42古い data は見えない
  • ・memory-barriers.txt は「CPU間のやりとりでは、ある種のメモリバリアは常に対で使うべきである」と述べている
  • ・片方だけにバリアを置いても順序は保証されない
  • ・READ_ONCE()/WRITE_ONCE() はロードやストアの分割・値の再利用・勝手な読み書きの挿入を防ぐ
  • ・全順序が必要なときは smp_mb()(汎用バリア)を使う

書き手の smp_wmb() と読み手の smp_rmb() は必ず対で使います。片方だけでは、もう片方の並べ替えを止められません。

⑤ acquire と release は一方通行

バリアには「片側だけ通す」種類がある
acquire(取得側)smp_load_acquire()
release(解放側)smp_store_release()
止める向き
後ろの操作は前に出られないacquireより後は必ず後
下向きに通さない壁
前の操作は後ろに出られないreleaseより前は必ず前
上向きに通さない壁
通してしまう向き
前の操作は後ろへ抜けてよいacquireの手前の操作は流れ込める
後ろの操作は前へ抜けてよいreleaseの後の操作は流れ込める
典型的な用途
ロック獲得 = acquire内側の操作が前へ漏れ出さない
ロック解放 = release内側の操作が後ろへ漏れ出さない
  • ・memory-barriers.txt は acquire / release を一方向にだけ透過する壁として説明している
  • ・ロックの内側の操作が外に漏れないことだけを保証すればよいので、片側で足りる
  • ・片側しか止めないぶん全順序バリアより安価
  • ・同ドキュメントは RELEASE の後に ACQUIRE が続いても完全なメモリバリアにはならないと明記している

acquire と release が一方通行なのは、ロックの内側を閉じ込めるのに片方向で十分だからです。必要な保証だけ買うので、smp_mb() より安く済みます。

⑥ アーキテクチャによって強さが違う

同じCコードでも必要なバリアの数が変わる
x86-64(TSO)store→loadだけが並べ替わる
ARM / RISC-V(弱い)load・storeとも広く並べ替わる
store の後に store を並べた場合
順序が保たれるsmp_wmb() は実質コンパイラバリア
順序は保たれない実際の命令が必要
移植時に起きること
x86で動いたコードがARM/RISC-Vで壊れるバリア忘れが表面化する
  • ・RISC-V の既定のメモリモデルは RVWMO(RISC-V Weak Memory Ordering)
  • ・RVWMOでは、あるハートのメモリ命令は他のハートから異なる順序で観測されうる
  • ・DEC Alpha は特に弱く、Linux v4.15 以降は READ_ONCE() に smp_mb() が追加されている
  • ・SMPバリアはユニプロセッサ向けビルドではコンパイラバリアに縮退する

x86-64 のTSOは比較的強く、バリアを忘れても動いてしまいがちです。ARM や RISC-V へ移植した瞬間に潜在バグが表面化します。

⑦ RISC-V は止めたい組み合わせだけを指定する

fence 命令の先行集合と後続集合
fence pred, succ止めたい2つの集合を書く
指定できる操作の種類
Rメモリ読み
Wメモリ書き
I / Oデバイス入出力
意味
pred の操作は succ の操作より先に見える例: fence w, w は先の書きを後の書きより前に
④のバリアとの対応
fence w, w書き込みバリア相当
fence r, r読み込みバリア相当
fence rw, rw汎用バリア相当
  • ・仕様は「他のRISC-Vハートや外部デバイスは、FENCEより前の先行集合の操作より先に、後続集合の操作を観測できない」と述べている
  • ・I と O はメモリではなくデバイスI/Oを対象にするため、MMIO の順序付けに使う
  • ・pred=RW, succ=RW の特別な形として FENCE.TSO があり、x86的なTSO相当の順序を与える
  • ・FENCE.I は命令フェッチ用で、データの順序付けとは別物

RISC-V は必要な組み合わせだけを指定させます。全部止める必要がないなら弱いフェンスを選べる、という設計思想がここに表れています。

公式ドキュメントで詳しく ↗