メモリバリア
メモリバリア(英: memory barrier)またはメモリフェンス(英: memory fence)とは、その前後のメモリ操作の順序性を制限するCPUの命令の一種である。
CPUには、性能最適化策としてアウト・オブ・オーダー実行を行うものがあり、メモリのロード命令やストア命令を含めて順序を入れ替えて実行することがある。この命令の並べ替えは、ひとつのスレッドの中で一般に暗黙のうちに行われるが、マルチスレッドプログラムやデバイスドライバでは慎重に制御しない限り予測不能の動作を生じる原因となる。順序性の制限の方法はハードウェア依存であり、そのアーキテクチャによって定義される。アーキテクチャによってはいくつかのバリアを用意して、それぞれ異なった順序性制限を実現している場合がある。
メモリバリアは低レベルの機械語で使われることが多く、複数のデバイスが共有するメモリを操作するのに使われる。そのようなコードとして、同期プリミティブ、マルチプロセッサシステムでのロックフリーなデータ構造、何らかのハードウェア機器を制御するデバイスドライバなどがある。
簡単な例
[編集]プログラムが1個のCPUで動作している場合、ハードウェアは全てのメモリ操作がプログラムされた順番通りに行われたかのように見えるよう命令を実行する。従ってメモリバリアは不要である。しかし、メモリが複数の機器によって共有されている場合(マルチプロセッサシステムのCPU群や、メモリマップドI/Oなど)、アウトオブオーダー実行によってプログラムの結果が変わってしまうことがある。例えば、2番目のCPUから見て、1番目のCPUが行ったメモリ操作はプログラム上の順番と違って見えるかもしれない。
以下の2プロセッサプログラムは、アウトオブオーダー実行によってプログラムの動作が影響される具体例である。
まず、メモリ位置 x と f の値は共に 0 であるとする。プロセッサ#1 で動作するプログラムは f の値がゼロ以外になるまでループし、その後 x の値を表示する。プロセッサ#2 で動作するプログラムは x に 42 を格納してから f に 1 を格納する。擬似コードの一部を以下に示す。プログラムの各行が個々のプロセッサの命令に対応している。
プロセッサ#1: loop: 位置 f の値をロードし、0 ならば loop へ分岐 位置 x の値を表示 プロセッサ#2: 位置 x に 42 を格納 位置 f に 1 を格納
表示されるのは常に 42 であることが期待されているが、プロセッサ#2 のストア命令がアウトオブオーダーで実行されれば、f が x の前に更新される可能性があり、"0" が表示される可能性も出てくる。上記の例のように f がプロセッサ間の同期を担っている場合や、f へのストアがメモリマップドI/Oを介したデバイスへのコマンド発行(この場合、プロセッサ#1 がデバイスに置き換わる)であるケースではこのような挙動は受け入れられない。メモリバリアをプロセッサ#2の位置 f へのストア命令の直前に挿入すると、他のプロセッサから見ても x が f の前に更新されているように観測されることを保証できる。
低レベルアーキテクチャ向けのプリミティブ
[編集]メモリバリアは、アーキテクチャのメモリモデルの定義の一部の低レベルプリミティブである。命令セットと同様、メモリモデルはアーキテクチャによって様々であるため、その動作を概括的に述べることは適切ではない。一般にメモリバリアを正しく使用するには、プログラミング対象のハードウェアのアーキテクチャマニュアルを読むべきである。とはいうものの、以下ではいくつかの実在するメモリバリアについて紹介する。
いくつかのアーキテクチャでは、「フルフェンス」と呼ばれる一種類のメモリバリア命令だけを提供する。フルフェンス命令は、その前の全ロード/ストア命令がフェンス後のロード/ストア命令の前に完了することを保証する。一例としては PowerPC のeioio
命令がある[1]。他のアーキテクチャでは、「acquire」命令と「release」命令でメモリバリアを構成する。これはリード・アフター・ライト操作の可視性に関するもので、読み込む側 (acquire) と書き込む側 (release) がそれぞれの観点で命令を使用する[2]。具体的には、acquire側では順序性が必要なメモリ操作の後にメモリバリアを置き、メモリ操作の結果を他のCPUやスレッドに見せてからそれに依存する処理に入る。Release側は逆にメモリバリアの後に順序性が必要なメモリ操作を実行し、それまでの処理をすべて終了させてからその旨を示すメモリ操作を行う。いくつかのアーキテクチャでは主記憶とI/Oメモリの操作の組み合わせに合わせて何種類かのメモリバリア命令を提供する。メモリバリア命令が複数種類存在するアーキテクチャでは、それぞれの命令にかかるコストに大きな違いがある点が重要である。
順序性が問題になるメモリの内容が単一の整数値など1つのメモリ操作命令のみで操作できる場合、これとメモリバリアを組み合わせることによりロックプリミティブよりもさらに軽量な同期機構を実装することができる。参照カウントやメモリ上のフラグが典型例である他、スピンロックやミューテックスのような軽量ロックプリミティブにあってもロック変数の実装手段としてしばしば使用する。環境によってはメモリ操作命令とメモリバリアの組み合わせの需要が多いことから、これらをAPIとして整備していることがある。例えばLinuxカーネルではマクロとしてatomic_operation_visibility()
が提供されており、operationにメモリ操作命令、visibilityに可視性を指定することによりこれらの組み合わせをC言語から簡単に利用できる。
マルチスレッドプログラミングとメモリ可視性
[編集]マルチスレッド化されたプログラムは一般に、POSIXスレッド(Pthreads)あるいはWin32などのAPIが提供する同期プリミティブや、(通例そのようなAPIを使って実装された)Javaや.NETなどの高水準言語あるいはフレームワークが提供する同期プリミティブまたは同期構文を使用する。ミューテックスやセマフォなどのプリミティブは、複数スレッドが共有資源にアクセスするための同期機能を提供する[注釈 1]。これらのプリミティブは必要なメモリ可視性 (visibility) を提供するためにメモリバリア機能も実装していることが多い[6][7]。そのような保証のある環境においてこれらの同期機構による排他制御を実装する場合は、メモリバリアをプログラマが明示的に使用する必要はない。ほとんどの用途では、同期プリミティブや同期構文によって提供される暗黙的なメモリバリアのほうが簡潔な手段である[8]。
各APIやプログラミング環境は、原則として自身の高レベルメモリモデルを持っていて、メモリ可視性を定義している。そのような環境ではメモリバリアを必要とすることはないが、メモリ可視性がどうなっているかを可能な限り理解しておくことは重要である。ただし、メモリモデルの仕様が必ずしも文書化されていたり明確化されていたりするわけではない。
プログラミング言語のセマンティクス(意味論)が機械語の命令コードとは異なったレベルで抽象化されて定義されているように、プログラミング環境のメモリモデルはハードウェアのメモリモデルとは抽象化のレベルが異なっている。これらを区別して理解し、低レベルなメモリバリア命令と特定のプログラミング環境のメモリ可視性の意味が異なることを理解することが重要である。例えば、あるプラットフォームにおけるPthreadsの実装では、POSIX規格で要求されているものよりも強いメモリバリアを使用しているかもしれない。実装されたメモリ可視性を前提としてプログラムを作成すると、仕様上のメモリ可視性を前提としたプログラムよりも移植性が低くなる可能性がある。
アウトオブオーダー実行とコンパイラによる命令順序の最適化
[編集]メモリバリア命令はハードウェアレベルの命令並べ替えに対するものである[9]。メモリマップドI/Oアクセス時に必要とされる。コンパイラも最適化処理の一環として命令の並べ替えをすることがある。いずれの場合も並列処理では同様の問題を引き起こすので、マルチスレッドで共有されるデータに関わるコードの最適化を抑制する手段を提供する必要がある。なお、そのような手段が必要となるのは、同期プリミティブで保護されていないデータのみである。
C言語およびC++のキーワードvolatile
を使用すると、その変数に関するメモリ操作の並べ替えや省略といった最適化を抑制することができる。これは、シングルプロセッサシステムでのマルチスレッドやシグナルハンドラといった割り込みに対する一種のバリアを提供するものである。しかし、volatile
はコンパイラの最適化に関するものであるため、マルチプロセッサシステムでの並べ替え問題への解としては不十分である[10]。
いくつかの言語はコンパイラの最適化にもアウト・オブ・オーダー実行にも対処する機能を持つものもあるが、そのコンパイラが生成したコードが本当に並べ替えを行っていないか調べるなど、十分注意して使用すべきである。コンパイラによる並べ替え問題を防ぐ必要があるときにはアセンブリ言語を直接使用することも考えられるが、一般的ではなく、生産性やメンテナンス性の観点からは現実的な選択肢ではない。コンパイラやオペレーティングシステムが用意しているアトミック操作のための組み込み関数やAPI関数を利用するなどして、リオーダーを抑制すべきである。
Java 1.5(Java 5とも呼ばれる)は新たなメモリモデルを採用しており、キーワードvolatile
によって、ある種のハードウェアによる命令並べ替えとコンパイラによる並べ替えを防ぐことを保証している[11][12]。一般的なアトミック操作にはjava.util.concurrent.atomic
パッケージに用意されているクラス群を使う。C#のvolatile
もJavaと類似の機能を持つ[13]。一般的なアトミック操作にはSystem.Threading.Interlocked
クラスを使用する[14]。前述のようにC/C++標準規格のvolatile
ではサポートされていないが、Microsoft Visual C++コンパイラのvolatile
では独自拡張として同様の機能をサポートしている[15]。Visual C++では、x86/x64に限り、バリア用の組み込み関数をサポートしているが、非推奨となっており、C++11で標準ライブラリに追加されたstd::atomic_thread_fence
およびstd::atomic<T>
を使うことが推奨されている[16]。
関連項目
[編集]脚注
[編集]注釈
[編集]出典
[編集]- ^ May, Cathy; Silha, Ed; Simpson, Eick; Warren, Hank (1993). The PowerPC architecture: A SPECIFICATION FOR A NEW FAMILY OF RISC PROCESSORS. Morgan Kaufmann PUblishers, Inc. p. 350. ISBN 1-55860-316-6
- ^ Acquire and Release Semantics - Windows drivers | Microsoft Learn
- ^ Interprocess Synchronization - Win32 apps | Microsoft Learn
- ^ Critical Section Objects - Win32 apps | Microsoft Learn
- ^ Mutex Class (System.Threading) | Microsoft Learn
- ^ Synchronization and Multiprocessor Issues - Win32 apps | Microsoft Learn
- ^ Semaphore (Java Platform SE 8 )
- ^ Thread.MemoryBarrier Method (System.Threading) | Microsoft Learn
- ^ “PowerPC storage model and AIX programming” (英語). ibm.com. 2021年1月10日閲覧。
- ^ POS03-C. volatile を同期用プリミティブとして使用しない
- ^ Atomic Access (The Java™ Tutorials > Essential Java Classes > Concurrency)
- ^ Javaにおける同期(パート3):アトミック操作とデッドロック
- ^ volatile - C# Reference | Microsoft Learn
- ^ Interlocked Class (System.Threading) | Microsoft Learn
- ^ volatile (C++) | Microsoft Learn
- ^ _ReadWriteBarrier | Microsoft Learn
外部リンク
[編集]いずれも英文
- Microsoft Driver Development: Memory Barriers on Multiprocessor Architectures
- HP technical report HPL-2004-209: Threads Cannot be Implemented as a Library
- Linux kernel memory barrier issues on multiple types of CPUs
- “High-performance multithreading is very hard - The Old New Thing - Site Home - MSDN Blogs”. 2011年5月22日時点のオリジナルよりアーカイブ。2023年7月15日閲覧。