メモリバリア(memory barrier。メモリフェンス、メモリーバリアとも呼ぶ)は、CPUとコンパイラによるメモリアクセスの並べ替えを止め、ほかのスレッドやデバイスから見える読み書きの順序を保証する仕組みです。シングルスレッドでは並べ替えが起きても結果は変わりません。しかし複数のスレッドが同じメモリを見ると、記述順と違う順序で更新が見え、ロックを使わない同期が壊れます。
この記事では、x86のパソコンで並べ替えを100万回試して実際に数えた結果と、C++のmemory_orderがx86とARMでどの命令に変わるかをコンパイルして確かめた結果をもとに、バリアの種類、C++での書き方、ARMのDMB・DSB・ISBの使い分けまでを説明します。
まとめ:メモリバリアの要点
- 役割:バリアが対象とする種類のメモリアクセスについて、前のアクセスが後ろのアクセスより先に観測されることを保証する。
- 必要な理由:CPUのストアバッファとアウト・オブ・オーダー実行、コンパイラの最適化により、メモリへの反映順はソースコードの順と一致しない。x86でも、書き込みの後の読み込みが先に見える逆転(StoreLoad)は起きる。
- 実測:x86で2スレッドが互いに書いてから読む処理を100万回繰り返すと、relaxedでは48〜51%の回で両方が0を読んだ。acquire/releaseにしても49%前後で変わらず、seq_cstかフルフェンスで0回になった。
- C++の書き方:
std::atomicの操作にmemory_orderを指定するか、std::atomic_thread_fenceを置く。どの命令になるかはCPUで変わり、x86のrelease書き込みは普通のmov、AArch64ではstlrになる。 - ARMの命令:順序だけ守るなら
DMB、先行アクセスの完了まで待つならDSB、システム設定を変えた後に命令を取り直すならISB。 - 注意:C/C++の
volatileはメモリバリアではない。スレッド間の同期には使えない。
メモリバリアとは|命令の並べ替えを止める境界
メモリバリアは、その位置を越えてメモリアクセスが前後に移動することを禁止する命令です。CPUとコンパイラは、依存関係がなければ処理を速くするためにロード(読み込み)とストア(書き込み)の順序を入れ替えます。バリアは、指定した種類・範囲のメモリアクセスについて観測順序を制約します。フルバリアなら前後のロードとストアすべてが対象ですが、ストアだけ・片方向だけを止める種類もあり、先行アクセスの完了まで待つかどうかも命令ごとに異なります。
同じスレッドの中だけなら、CPUは自分から見た結果の一貫性を守るため、並べ替えは問題になりません。順序が意味を持つのは、ほかのスレッド、DMA、割り込みハンドラが共有メモリの更新を横から見るときです。ロックを使わずにフラグでデータを受け渡す処理(ロックフリーのキュー、デバイスドライバ、割り込み処理)でメモリバリアが必要になります。メモリフェンス(memory fence)は同じものを指す別名で、x86の命令名(MFENCE)やC++の関数名(atomic_thread_fence)はフェンスと呼んでいます。
メモリバリアが必要な理由|ストアバッファとメモリオーダリング
メモリオーダリングとは、プログラムが発行したメモリアクセスが、実際にメモリへ反映されてほかのコアから見える順序の決まりです。この順序が記述順とずれる原因は、ハードウェアとコンパイラの両方にあります。
アウト・オブ・オーダー実行とストアバッファ
アウト・オブ・オーダー実行を採用するCPUは、入力が揃った命令から先に処理します。さらに書き込みをいったんストアバッファに溜め、後からキャッシュへ書き出します。このため、あるコアが変数Xに書いてから変数Yを読んでも、Xへの書き込みがまだバッファにある間にYの読み込みが終わり、ほかのコアからは読み込みが先に起きたように見えます。これがStoreLoadの逆転です。
x86のTSOとARMの弱いメモリモデル
並べ替えをどこまで許すかはアーキテクチャで異なります。x86のメモリモデルを形式化したx86-TSOの論文では、通常のメモリに対するロードとストアで許される並べ替えはStoreLoadだけだとしています。ARM、POWER、RISC-V(既定のRVWMO)は弱いメモリモデルで、依存関係のないロードとストアはほぼどの組み合わせでも並べ替わります。
| 項目 | x86 / x86-64 | ARM / POWER / RISC-V |
|---|---|---|
| モデル | TSO(Total Store Order) | 弱いメモリモデル |
| 許される並べ替え | StoreLoadのみ | ほぼ全種類 |
| フルバリアの例 | MFENCE、lock付き命令 | DMB(ARM) |
| 移植で起きること | 暗黙の順序に頼りがち | x86で隠れた不足が出る |
x86で動いたロックフリーのコードがARMで壊れる典型的な原因は、このモデルの差です。次の章の実測のとおり、x86ではバリアが足りないコードでも一部の誤りしか表に出ません。
x86のメモリ並べ替え|Store BufferingとMessage Passingの実測
並べ替えが本当に起きるのかを、Intel Core i9-9880H(x86-64)/macOS 26.6.2/Apple clang 21.0.0(-O2)の環境で確かめました。2つのスレッドを常駐させ、毎回の試行の前にX=Y=0へ戻してから同時に走らせ、100万回の試行を3回繰り返しています。
Store Bufferingテスト:互いに書いてから読む
スレッドAは「X=1を書いてからYを読む(r1)」、スレッドBは「Y=1を書いてからXを読む(r2)」を実行します。記述順どおりなら、少なくとも片方は1を読むはずで、r1=r2=0にはなりません。
// スレッドA
X.store(1, ORD);
r1 = Y.load(ORD);
// スレッドB
Y.store(1, ORD);
r2 = X.load(ORD);
| 指定 | 生成命令(store→load) | r1=r2=0の回数(100万回中) |
|---|---|---|
| relaxed | mov → mov | 481,092〜509,104回 |
| store release / load acquire | mov → mov | 482,090〜495,227回 |
| seq_cst | xchg → mov | 0回 |
| relaxed+seq_cstフェンス | mov → lock or → mov | 0回 |
機械語は書いてから読む順のままなのに、約半数の試行で両方のスレッドが0を読みました。ストアバッファによるStoreLoadの逆転です。重要なのは2行目で、acquire/releaseに強めても結果は変わりません。acquire/releaseはStoreLoadを止めないため、x86のコンパイラは普通のmovしか出していません。お互いの書き込みを確実に見せ合うアルゴリズム(Dekkerのアルゴリズムなど)では、seq_cstかフルフェンスが必要です。なお、発生率は試行の組み方とCPUで大きく変わるので、約50%という値は性質ではなく、この環境での目安です。
Message Passingテスト:x86では表に出ない誤り
次に、スレッドAが「data=1、flag=1」の順に書き、スレッドBが「flagを読んでからdataを読む」処理を、すべてrelaxedで100万回試しました。flag=1を読んだ試行は435,432〜589,055回あり、そのうちdata=0を読んだ回数は3回の実行とも0回でした。TSOはStoreStoreとLoadLoadを並べ替えないためです。
この実験ではdataとflagの両方がアトミックなので未定義動作ではありませんが、flag=1ならdata=1という受け渡し条件は保証されず、ARMのような弱いモデルではdata=0が起こりえます。x86のテストで0回だったことは、正しいことの証明になりません。フラグでデータを渡す処理は、x86で動いていても必ずrelease/acquireで書いてください。
メモリバリアの種類|4方向の分類とacquire/release
メモリバリアは、止める組み合わせで分類します。基本はLoadLoad・LoadStore・StoreStore・StoreLoadの4方向で、実務では次の分類で考えると整理しやすくなります。
| 種類 | 止める並べ替え | 主な用途 |
|---|---|---|
| リードバリア | ロード同士(LoadLoad) | 読む順を固定する |
| ライトバリア | ストア同士(StoreStore) | データ→フラグの公開順 |
| フルバリア | 4方向すべて | StoreLoadを止める唯一の手段 |
| acquire | 後続のアクセスが前に出ること | フラグを読む側・ロック取得 |
| release | 先行のアクセスが後ろへ出ること | フラグを書く側・ロック解放 |
acquire/releaseは片方向だけを止める軽いバリアです。書き手がデータを用意してからフラグをreleaseで書き、読み手がフラグをacquireで読んでからデータを使えば、フルバリアなしでデータを受け渡せます。ただし前章の実測のとおりStoreLoadは止めないので、両方向の可視性が必要な処理にはフルバリアを使います。
C++のメモリバリア指定|std::atomicとmemory_order
C++11以降は、アセンブラを書かずに標準ライブラリでメモリバリアを表現できます。まずはstd::atomicの操作に順序を指定する書き方を使い、地点にバリアを置きたいときだけフェンスを使います。
memory_orderの6値とconsumeの扱い
std::memory_orderには、relaxed・consume・acquire・release・acq_rel・seq_cstの6つの値があります(cppreferenceのmemory_order)。既定は最も強いseq_cstで、relaxedは不可分性だけを保証し順序は保証しません。consumeは、C++26向けの提案P3475R2で非推奨となり、acquireと同じ意味に変わります。新しく書くコードでconsumeを使う理由はありません。
| 値 | 保証 | 使う場面 |
|---|---|---|
| relaxed | 不可分性のみ | 統計カウンタ |
| acquire | 読み込み側の片方向 | フラグ・ロックを取る側 |
| release | 書き込み側の片方向 | フラグ・ロックを渡す側 |
| acq_rel | acquire+release | CASなどの読み書き操作 |
| seq_cst | 全スレッドで単一の順序 | 既定値・StoreLoadが要る処理 |
| consume | C++26で非推奨 | 使わない |
release/acquireによるデータの受け渡し
生産者がreleaseで公開し、消費者がacquireで受け取るのが基本形です。下のコードは、ThreadSanitizer(-fsanitize=thread)を付けて50回、付けずに1,000回実行し、データ競合の報告とassertの失敗がないことを確認しています。
#include <atomic>
#include <cassert>
#include <thread>
std::atomic<bool> ready{false};
int data = 0; // 通常の変数(atomicではない)
void producer() {
data = 42; // 1. データを書く
ready.store(true, std::memory_order_release); // 2. ここまでの書き込みを公開
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // 3. 公開を待つ
assert(data == 42); // 4. 必ず42が見える
}
int main() {
std::thread t1(producer), t2(consumer);
t1.join();
t2.join();
}
releaseの書き込みと、その値を読んだacquireの読み込みの間に同期関係ができ、releaseより前のdata = 42が読み手から必ず見えます。どちらかをrelaxedにすると、この保証はなくなります。
std::atomic_thread_fenceによるフェンスの配置
複数の変数をまとめて公開するときは、std::atomic_thread_fenceでコードの地点にバリアを置けます。フェンスはアトミック変数の読み書きと組み合わせて初めて同期を作るので、フラグの読み書き自体はアトミックにします。
#include <atomic>
#include <cassert>
#include <thread>
int a = 0, b = 0;
std::atomic<bool> flag{false};
void publish() {
a = 1;
b = 2;
std::atomic_thread_fence(std::memory_order_release); // 前の書き込みを後ろへ逃がさない
flag.store(true, std::memory_order_relaxed);
}
void subscribe() {
while (!flag.load(std::memory_order_relaxed)) {}
std::atomic_thread_fence(std::memory_order_acquire); // 後の読み込みを前へ出さない
assert(a == 1 && b == 2);
}
int main() {
std::thread t1(publish), t2(subscribe);
t1.join();
t2.join();
}
このコードは通常のビルドで1,000回実行してassertの失敗はありませんでしたが、ThreadSanitizerを付けるとsubscribe()でデータ競合と報告されました。ThreadSanitizerが単独のフェンスによる同期を追跡しないための誤検知で、フェンスで書いたコードはこの道具では検証しにくくなります。この受け渡し例は操作ごとのrelease/acquireでも表せますが、フェンスとアトミック操作では順序付けの対象が異なるため一般には単純置換できません。1つのデータ構造ではどちらかに揃えると読みやすくなり、検証のしやすさでは操作ごとの指定が有利です。適切に配置されたstd::atomicの操作は、単一命令かどうかにかかわらず不可分性を保証します。アライメント要件を守り、ロックフリーかどうかはis_lock_free()で別に確認します。構造体の配置はメモリアライメントとは?構造体パディング・アライメント違反とC言語での制御を実測で解説で扱っています。
memory_orderが変わる命令|x86-64とAArch64の生成コード比較
同じC++のコードが各CPUでどの命令になるかを、Apple clang 21.0.0(-O2)でアセンブリを出力して比べました。AArch64はARMv8.0相当の-mcpu=cortex-a53と、Apple Silicon向けの既定値の2通りです。
| 操作 | x86-64 | AArch64(ARMv8.0) | AArch64(Apple既定) |
|---|---|---|---|
| load relaxed | mov | ldr | ldr |
| load acquire | mov | ldar | ldapr |
| load seq_cst | mov | ldar | ldar |
| store relaxed | mov | str | str |
| store release | mov | stlr | stlr |
| store seq_cst | xchg | stlr | stlr |
| fence acquire | (命令なし) | dmb ishld | dmb ishld |
| fence release | (命令なし) | dmb ish | dmb ish |
| fence seq_cst | lock or | dmb ish | dmb ish |
| fetch_add seq_cst | lock xadd | ldaxr/stlxrのループ | ldaddal |
この表から読み取れることは3つあります。1つ目に、x86ではこの表の単純なreleaseストアとacquireロードには追加のバリア命令が付きません。ただし、fetch_addなどの読み書き操作にもlock付き命令が必要で、命令数だけでは実行コストを判断できません。clangはフェンスにMFENCEではなくlock付きのorを使っており、前章の実測ではこれでStoreLoadの逆転が0回になりました。2つ目に、AArch64のアトミック操作はDMBではなく、順序付きのロード・ストア命令(ldar・stlr)になります。DMBが出るのはフェンスを書いたときだけです。これはArmが公開しているC/C++ Atomics ABI(2025Q4)の対応表と一致します。3つ目に、ARMv8.3のRCpc拡張がある環境ではacquireの読み込みがldaprに、ARMv8.1のLSE拡張がある環境ではfetch_addが1命令のldaddalになり、同じソースでも対象CPUの指定で命令が変わります。
SIMD命令も同じく対象CPUの指定で生成コードが変わります。NEONとAVX2の違いはSIMDとは?AVX2・NEONの仕組みと自動ベクトル化・組み込み関数の書き方を参照してください。
ARMのDMB・DSB・ISBの使い分け
OSやライブラリを介さずにレジスタや周辺機器を直接操作する組み込み開発では、バリア命令を直接書きます。ARMには強さの異なる3つの命令があります。
3命令の違いとCMSISの関数
| 命令 | CMSIS関数 | 保証すること |
|---|---|---|
| DMB(Data Memory Barrier) | __DMB() | 前後のメモリアクセスの見かけの順序 |
| DSB(Data Synchronization Barrier) | __DSB() | 先行アクセスの完了まで後続命令を止める |
| ISB(Instruction Synchronization Barrier) | __ISB() | パイプラインを破棄し命令を取り直す |
CMSIS-Coreの説明では、__DMB()は明示的なメモリ操作の見かけの順序を保証するが完了は保証せず、__DSB()はそれより前の明示的なメモリアクセスがすべて完了したときに完了し、__ISB()はパイプラインをフラッシュして後続の命令をキャッシュかメモリから取り直させる、と定義しています。スレッド間のデータ受け渡しならDMB、書き込みが周辺機器に届いてから次へ進みたいならDSB、命令の取り込み方に影響する設定を変えた後ならISB、と使い分けます。
AArch64のdmb ishのishはInner Shareable(システム構成とメモリ属性で定まる内部共有ドメイン)を表し、ishldは先行ロードと後続のロード・ストアを、ishstは先行ストアと後続ストアを順序付けます。スレッド間の同期ではこの範囲で足り、デバイスまで含めるときはsy(システム全体)を使います。
DSBとISBが必要になる実例:MPUの有効化
CMSIS 6のARM_MPU_Enable()(Armv7-M向けMPUのヘッダ)は、3つのバリアを次の順で使っています。
__STATIC_INLINE void ARM_MPU_Enable(uint32_t MPU_Control)
{
__DMB();
MPU->CTRL = MPU_Control | MPU_CTRL_ENABLE_Msk;
#ifdef SCB_SHCSR_MEMFAULTENA_Msk
SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk;
#endif
__DSB();
__ISB();
}
まずDMBで先行するメモリアクセスと後続の設定書き込みの観測順序を保証し、DSBでその書き込みの完了を待ち、ISBで新しいメモリ保護設定のもとで後続の命令を取り直させます。ベクタテーブルの切り替えやスリープ命令(WFI)の直前でも同じ理屈で、設定が反映される前に次の処理へ進ませないためにDSB(必要ならISB)を置きます。リアルタイムOSの上で割り込みとタスクがデータを共有する設計はRTOSとは?リアルタイムOSの仕組み・主要製品の比較と採用判断を実装者目線で解説で扱っています。センサー機器やゲートウェイのファームウェアまで含めた開発は、AI・IoTソリューションでも相談を受け付けています。
x86のMFENCE・SFENCE・LFENCEの保証範囲
x86には3つのフェンス命令があります。Intel SDMによると、MFENCEは先行するロードとストアがすべて見えるようになるまで後続のロードとストアを待たせ、SFENCEは先行するストアについて同じことを後続のストアに対して行い、LFENCEは先行するすべての命令がローカルに完了するまで実行されず、完了するまで後続の命令の実行も始めません。通常のメモリではTSOがStoreStoreとLoadLoadを守るので、C++のコンパイラが通常のアトミック操作で出すのはStoreLoadを止める命令(xchgやlock付き命令)だけで、SFENCEとLFENCEは出しません。
Linuxカーネル・Java・Rustでのメモリバリア
C++以外の環境にも、同じ考え方のバリアがあります。
| 環境 | フルバリア | 片方向・部分的なバリア |
|---|---|---|
| Linuxカーネル | smp_mb() | smp_rmb()、smp_wmb() |
| Java | VarHandle.fullFence() | acquireFence()、releaseFence() |
| Rust | fence(Ordering::SeqCst) | Acquire、Release、AcqRel |
| C++ | atomic_thread_fence(seq_cst) | acquire、release、acq_rel |
Linuxカーネルのmemory-barriers.txtでは、smp_mb()は読み書きの両方、smp_rmb()は読み込み同士、smp_wmb()は書き込み同士を順序付けます。SMP用のバリアは単一プロセッサ向けのビルドではコンパイラバリアに縮退し、READ_ONCE()・WRITE_ONCE()は指定したアクセスへのコンパイラの変換を止めるだけで、別のアドレスとの順序は保証しません。
Javaでは、Java SE 27のVarHandleにfullFence()・acquireFence()・releaseFence()があります。Rustのstd::sync::atomic::fenceはC++20と同じメモリモデルで、OrderingにはRelaxed・Acquire・Release・AcqRel・SeqCstの5つがあります。ただし、fenceにRelaxedを渡すとパニックします。Javaの仮想スレッドのようにスレッドの実装が変わっても、共有変数の可視性の規則は同じです(Java Virtual Thread(仮想スレッド)とProject Loomとは?作り方・仕組み・落とし穴を実務目線で解説)。
volatileとメモリバリアの違い|同期に使えない理由
C/C++のvolatileが保証するのは、その変数へのアクセスをコンパイラが省略したりレジスタに残したりしないことだけです。CPUによる並べ替えもストアバッファも止めず、不可分性も保証しません。volatileでスレッド間の同期を組むと、x86では前章のMessage Passingのように偶然動き、ARMで壊れます。C/C++でvolatileが必要なのは、メモリマップドI/Oのレジスタや、シグナルハンドラと共有するvolatile sig_atomic_tのように、コンパイラによるアクセスの省略を止めたい場面です。
Javaのvolatileは意味が違います。Java言語仕様の17.4.5では、volatileフィールドへの書き込みは、そのフィールドの後続の読み込みすべてにhappens-before関係を持ちます。C++のstd::atomic(seq_cst)に近い意味で、同期に使えます。C/C++とJavaで同じキーワードの意味が違う点が、混同の原因になっています。
コンパイラバリア|asm volatileとCPUバリアの違い
並べ替えはコンパイラも行います。最適化でロードとストアの順序が入れ替わったり、ループの外へ出されたりするので、CPUのバリアとは別にコンパイラの並べ替えだけを止める「コンパイラバリア」があります。GCCとClangでは、拡張asmのmemoryクロバーを指定した空のインラインアセンブラがこれに当たります。
asm volatile("" ::: "memory"); // コンパイラの並べ替えだけを止める(CPU命令は出ない)
この1行は機械語を1命令も生成しないので、CPUの並べ替えは防げません。std::atomicやstd::atomic_thread_fenceを使えばコンパイラとCPUの両方に効くため、通常はこちらを使います。C++では、同じスレッドで実行されるシグナルハンドラとの順序付けにstd::atomic_signal_fenceを使えます。同じコアで動く割り込み処理でも、MMIOや周辺機器との順序・完了の保証にはCPUのバリアが必要な場合があります。
メモリバリアを自分で書くべきでない場面と失敗パターン
メモリバリアを手で書くのは、ロックフリーのデータ構造、ドライバ、割り込み処理など、ロックを使えない箇所に限ります。業務アプリケーションの共有データは、std::mutexなどのロックで守ってください。ロックの取得と解放にはacquire/releaseの意味が含まれているので、ロックの内側でバリアを足す必要はありません。DBの行を守る話であれば、楽観ロックと悲観ロックとは?違い・使い分け・実装をDB別に解説【2026年版】の範囲です。
よくある失敗は次の4つです。
- x86のテストだけで正しいと判断する:Message Passingの実測のとおり、x86ではrelaxedで受け渡しが崩れる結果が0回だった。ARMの実機で試し、release/acquireの同期関係をコード上で確かめる。ThreadSanitizerは非アトミックな変数のデータ競合を見つける道具で、すべてアトミックなコードの順序の誤りは検出できない。
- acquire/releaseでStoreLoadを止めたつもりになる:互いに書いてから読む処理では、Store Bufferingの実測のとおり約半数で逆転が残った。seq_cstかフルフェンスが要る。
- ダブルチェックロッキングを素のポインタで書く:初期化済みのフラグやポインタはアトミックにし、release/acquireで公開する。C++では関数内のstatic変数か
std::call_onceで済むことが多い。 - 計測せずに全部をrelaxedへ緩める:既定のseq_cstで書き、プロファイラでアトミック操作が重いと分かった箇所だけ緩める。
よくある質問
メモリフェンスとメモリバリアは違うものですか?
同じものを指します。x86の命令名(MFENCE、SFENCE、LFENCE)やC++の関数名(std::atomic_thread_fence)ではフェンス、ARMの命令名(DMB、DSB、ISB)やLinuxカーネルの文書ではバリアと呼ぶ傾向があるだけです。
DMBとDSBはどう使い分けますか?
スレッド間のデータ受け渡しなど、メモリアクセスの順序だけを保証したいならDMBです。周辺機器のレジスタへの書き込みやキャッシュ操作が完了してから次へ進みたい場合は、完了まで後続の命令を止めるDSBを使います。DSBのほうが強く、そのぶん待ち時間も長くなります。
x86ではメモリバリアは不要ですか?
不要ではありません。x86はTSOでStoreLoad以外の並べ替えを許しませんが、書いてから別の変数を読む順序を保証するにはフルバリアが必要です。実測では、バリアなしで約半数の試行が逆転しました。C++では既定のseq_cstで書けば、コンパイラがxchgやlock付き命令を出します。
memory_order_consumeは使うべきですか?
使う理由はありません。多くのコンパイラが実装でacquireに格上げしており、C++26向けのP3475R2で非推奨となって意味もacquireと同じになります。依存関係による順序付けを期待するなら、acquireで書いてください。
メモリバリアを入れすぎるとどうなりますか?
結果は正しいままですが、ストアバッファが空になるまでの待ちや、パイプラインの停止で遅くなります。特にフルバリアとDSBは重い命令です。まずrelease/acquireで必要な方向だけを止め、計測で問題が出た箇所だけを見直します。