メモリバリアとは?仕組み・種類とC++/ARMでの使い方を解説
メモリバリア(memory barrier、メモリーバリアやメモリフェンスとも呼ぶ)とは、CPUやコンパイラによる命令の並べ替えを抑え、複数スレッドから見たメモリの読み書き順序を保証する仕組みです。シングルスレッドでは並べ替えが起きても結果は変わりませんが、マルチスレッドでは他スレッドがその途中状態を観測するため、順序保証がないと同期が壊れます。この記事では、メモリバリアが必要になる理由(メモリオーダリング)、リード・ライト・フルの種類、C++のstd::memory_order、ARMのDMB/DSB/ISB、そして混同されやすいvolatileとの違いまでを、実装例つきで整理します。
まとめ:メモリバリアの要点
- 役割:メモリアクセスの並べ替えを禁止し、バリア前後の順序を固定する。並列処理で他スレッドから見える順序を保証するための最終手段。
- 必要な理由:CPUのアウト・オブ・オーダー実行とストアバッファ、コンパイラの最適化により、実際のメモリ反映順序はソースコードの記述順と一致しないため。
- 種類:読み込み同士を止めるリードバリア、書き込み同士を止めるライトバリア、すべてを止めるフルバリアの3系統。acquire/releaseは片方向だけを止める軽量なバリア。
- 使い方:C++なら
std::atomicとmemory_order、またはstd::atomic_thread_fence。ARMならDMB/DSB/ISB、x86ならMFENCE/SFENCE/LFENCE。 - 注意:volatileはメモリバリアではない。スレッド間同期には使えず、アトミック型とバリアを使う。
以下で、なぜ並べ替えが起きるのかという仕組みから、実際のバリアの選び方・書き方までを順に見ていきます。
メモリバリアとは|命令の並べ替えを制御する仕組み
メモリバリアは、その位置を越えてメモリアクセスが前後に移動することを禁止する命令です。プロセッサとコンパイラは、依存関係がなければ実行効率のためにロード(読み込み)とストア(書き込み)の順序を自由に入れ替えます。バリアを挟むと「バリアより前のアクセスは、バリアより後のアクセスより先に完了・観測される」ことが保証され、この一線が並べ替えを止める境界になります。
単一スレッド内では、CPUは自分自身から見た結果の一貫性を必ず守るため並べ替えは問題になりません。順序が意味を持つのは、別のスレッドやDMA・割り込みハンドラが、共有メモリの更新を横から観測するときです。ロックを使わずにフラグや共有変数でスレッド間の受け渡しをする場面(ロックフリー実装、デバイスドライバ、割り込み処理)で、メモリバリアが必須になります。
メモリバリアが必要な理由|メモリオーダリングと並べ替え
メモリオーダリングとは、プログラムが発行したメモリアクセスが、実際にメモリへ反映され他コアから見える順序のルールです。この順序が記述順とずれるのは、ハードウェアとソフトウェアの両方に原因があります。
アウト・オブ・オーダー実行とストアバッファ
現代のCPUはアウト・オブ・オーダー実行(out of order execution)を行い、後続の命令でも入力が揃えば先に処理します。さらにストアバッファ(store buffer)を持ち、書き込みをいったんバッファに溜めてから順次キャッシュへ書き出す構造です。このため、あるコアが「変数Aに書いてから変数Bを読む」順で命令を発行しても、Bの読み込みがAの書き込みより先に他コアへ見える、といった逆転が起こります。x86でもこのStoreLoadの逆転(ストアがバッファに残ったまま後続ロードが先に見える)だけは許容されており、バリアなしでは防げません。
強いメモリオーダリングと弱いメモリオーダリング
並べ替えをどこまで許すかはアーキテクチャで大きく異なります。x86/x86-64はTSO(Total Store Order)と呼ばれる強いメモリオーダリングで、許される並べ替えはStoreLoadのみです。一方ARM・POWER・RISC-Vは弱いメモリオーダリングで、依存関係がなければほぼすべての組み合わせを並べ替えます。同じロックフリーのコードでも、x86では動いたのにARMで壊れる典型的な原因がこの差です。移植時はバリアを弱いモデル基準で設計する必要があります。
| 項目 | x86 / x86-64(強い) | ARM / POWER / RISC-V(弱い) |
|---|---|---|
| モデル名 | TSO(Total Store Order) | Weak / Relaxed |
| 許される並べ替え | StoreLoadのみ | ほぼ全種類 |
| フルバリア命令 | MFENCE | DMB |
| 移植時の注意 | 暗黙の順序に依存しがち | 明示バリアが必須 |
「x86で動いたから正しい」は誤りで、弱いモデルで検証しないとバリア不足は表面化しません。
メモリバリアの種類|リード・ライト・フルの3分類
メモリバリアは、止める対象の組み合わせで分類します。細かくはLoadLoad・LoadStore・StoreStore・StoreLoadの4方向がありますが、実務では次の3系統+片方向のacquire/releaseで捉えると扱いやすくなります。
| 種類 | 止める対象 | 主な用途 |
|---|---|---|
| リードバリア(ロードバリア) | バリア前後のロード同士 | 読み込み順の固定 |
| ライトバリア(ストアバリア) | バリア前後のストア同士 | 公開データの順序保証 |
| フルバリア | ロード・ストアすべて | 完全な順序固定 |
| acquire | 後続アクセスの前方移動 | ロック取得側・読み手 |
| release | 先行アクセスの後方移動 | ロック解放側・書き手 |
acquire/releaseは、フルバリアより片方向だけを止める軽量なバリアです。書き手がデータを用意してからフラグをrelease書き込みし、読み手がフラグをacquire読み込みしてからデータを参照する、という組み合わせで、フルバリアより低コストに受け渡しを同期できます。まず必要な方向だけをacquire/releaseで止め、それで足りないときにフルバリアへ上げるのが実務の順序です。
C++でのメモリバリアの使い方|std::atomicとmemory_order
C++11以降は、インラインアセンブラを書かずに標準ライブラリでメモリバリアを表現できます。多くの場合、明示的なフェンスよりstd::atomicの操作に順序を指定する書き方が推奨されます。
std::atomicとmemory_orderによる順序指定
std::atomicの各操作にmemory_orderを渡すと、その操作に付随するバリアを指定できます。値はrelaxed・consume・acquire・release・acq_rel・seq_cstの6種で、既定は最も強いseq_cst(逐次一貫)です。生産者がreleaseで公開し、消費者がacquireで受け取るのが基本形です。
#include <atomic>
#include <thread>
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // 通常の書き込み
ready.store(true, std::memory_order_release); // ここまでの書き込みを公開
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // 公開を待つ
// acquire以降で data の 42 が必ず見える
int x = data;
}
relaxedは順序を保証せずアトミック性だけを与える最速の指定で、カウンタの加算など順序が不要な用途に使います。consumeは仕様が複雑で多くの実装がacquireに格上げするため、現行の実務では基本的に使いません。迷ったら既定のseq_cstから始め、性能計測でボトルネックになった箇所だけacquire/releaseへ緩めるのが安全です。
std::atomic_thread_fenceによる明示的フェンス
変数単位ではなく、コードのある地点にバリアを置きたいときはstd::atomic_thread_fenceを使います。複数の変数をまとめて公開する場合などに向きます。
#include <atomic>
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);
}
フェンスとアトミック変数の順序指定は同じハードウェアバリアに落ちるため、どちらか一方に統一すると読みやすくなります。標準ライブラリで書けば、x86ではMFENCE、ARMではDMBといったアーキテクチャごとの命令へコンパイラが自動で変換します。
ARM・組み込みでのバリア命令|DMB・DSB・ISB
OSやライブラリを介さずレジスタやデバイスを直接触る組み込み開発では、CPU命令のバリアを直接使います。ARMには強さの異なる3命令があります。
| 命令 | 名称 | 効果 |
|---|---|---|
| DMB | Data Memory Barrier | メモリアクセスの順序のみ保証 |
| DSB | Data Synchronization Barrier | 先行アクセスの完了まで後続命令を停止 |
| ISB | Instruction Synchronization Barrier | パイプラインを破棄し命令を再フェッチ |
DMBはロード・ストアの順序だけを整える最も軽いバリアで、スレッド間のデータ受け渡しに使います。DSBはより強く、先行するメモリアクセスやキャッシュ・TLB操作がすべて完了するまで後続の命令実行そのものを止めます。ISBはメモリ順序ではなく、システムレジスタ変更後などに古い命令フェッチを捨てて取り直すための命令です。順序保証だけならDMB、周辺機器の設定反映を待つならDSB、と使い分けます。
ARM Cortex-Mでは、CMSISの組み込み関数で直接呼び出せます。
// CMSIS intrinsics(ARM Cortex-M)
__DMB(); // データメモリバリア
__DSB(); // データ同期バリア
__ISB(); // 命令同期バリア
x86でも同様にMFENCE(フル)・SFENCE(ストア)・LFENCE(ロード)の3命令があり、TSOでは通常DMB相当が不要なぶん、必要になるのはStoreLoadを止めるMFENCEが中心です。RustのアトミックもC++と同じモデルで、Ordering列挙(Relaxed/Acquire/Release/AcqRel/SeqCst)とstd::sync::atomic::fenceが、これらのCPU命令へ変換されます。
volatileとメモリバリアの違い|同期には使えない理由
C/C++のvolatile属性は、しばしばメモリバリアと混同されますが別物です。volatileが保証するのは「その変数へのアクセスをコンパイラが省略・キャッシュしない」ことだけで、CPUによる並べ替えもストアバッファも止めません。他スレッドから見た順序やアトミック性は一切保証されないため、volatileでスレッド間の同期を組むと、x86では偶然動いてもARMでは壊れます。
volatileが正しく必要なのは、メモリマップドI/Oレジスタや、シグナルハンドラと共有するsig_atomic_tなど、コンパイラのアクセス最適化を止めたい場面に限られます。スレッド間で値を共有するなら、volatileではなくstd::atomic(Javaのvolatileはこれに近い意味を持ちますが、C/C++のvolatileとは別物です)を使うのが正解です。「volatileを付けたから並列でも安全」という思い込みは、再現性の低いバグの典型的な原因になります。
コンパイラ最適化とメモリバリア|コンパイラバリア
並べ替えはCPUだけでなくコンパイラも行います。最適化でロード・ストアの順序が入れ替わったり、ループ外へ移動したりするため、CPUバリアとは別にコンパイラの並べ替えを止める「コンパイラバリア」が必要になる場面があります。GCC/Clangでは、メモリクロバーを指定した空のインラインアセンブラがコンパイラバリアとして働きます。
asm volatile("" ::: "memory"); // コンパイラの並べ替えのみ抑止(CPUは止めない)
ただしこれはコンパイラの並べ替えを止めるだけで、CPUのハードウェア並べ替えは防げません。std::atomicやstd::atomic_thread_fenceを使えば、コンパイラバリアとCPUバリアの両方が同時にかかるため、通常はアトミック操作に一本化するのが安全です。コンパイラバリア単体で足りるのは、同一スレッド内でシグナルハンドラや割り込みとだけ順序を合わせる限定的なケースです。
よくある質問
volatileとメモリバリアは何が違いますか?
volatileはコンパイラにアクセスの省略・キャッシュをさせないだけで、CPUの並べ替えやストアバッファは止めず、スレッド間の順序も保証しません。メモリバリアはCPUとコンパイラ両方の並べ替えを止めます。スレッド間同期にはvolatileではなくstd::atomicとバリアを使ってください。
DMBとDSBはどう使い分けますか?
スレッド間のデータ受け渡しなど「メモリアクセスの順序」だけを保証したいならDMBです。周辺機器のレジスタ設定やキャッシュ・TLB操作が完了してから次に進みたい場合は、後続命令の実行そのものを止めるDSBを使います。DSBはDMBより強く、そのぶんコストも高くなります。
x86ではメモリバリアは不要ですか?
不要ではありません。x86はTSO(強いモデル)でStoreLoad以外の並べ替えを許さないため必要な箇所は少ないものの、ストア後に別アドレスをロードする順序を保証するにはMFENCEが要ります。ARMなど弱いモデルへ移植すると一気にバリアが必要になるため、x86の暗黙順序に依存した設計は避けます。
C++でメモリバリアはどう書きますか?
基本はstd::atomicの操作にmemory_orderを指定します。書き手はstore(値, std::memory_order_release)、読み手はload(std::memory_order_acquire)が定番です。地点にバリアを置きたいときはstd::atomic_thread_fenceを使います。既定のseq_cstから始め、計測してから緩めるのが安全です。
メモリバリアを入れすぎるとどうなりますか?
正しさは保てますが、パイプラインのストールやストアバッファのフラッシュで性能が落ちます。特にフルバリアやDSBは重いため、まずはacquire/releaseで必要な方向だけを止め、性能計測でボトルネックを確認したうえで最小限に絞るのが定石です。