メモリオーダリング
メモリオーダリング(英: Memory Ordering)とは、CPUによるコンピュータメモリへのアクセス順序を表わす。この言葉は、コンパイル時のコンパイラに生成されるメモリオーダリングか、実行時にCPUによって生成されるメモリオーダリングのいずれかを指す。 近代的なマイクロプロセッサでは、メモリオーダリングはメモリ操作の順番を入れ替えるCPUの特性を示す。メモリリオーダリングはアウト・オブ・オーダー実行の一種であり、キャッシュメモリやメモリバンクといった異なるタイプのメモリのバスを最大限に有効活用するために利用される。 現在のほとんどのユニプロセッサのメモリ操作は、プログラムコードで指定された順番では実行されない。シングルスレッドのプログラムでは、すべてのアウト・オブ・オーダー実行はプログラマから隠され、すべての命令は順番通りに実行されたように見える。しかしながら、マルチスレッド環境(もしくはメモリバスで他のハードウェアと接続されている場合)では、問題が起こりえる。この場合、問題を避けるためにはメモリバリアを使わなければならない。 コンパイル時メモリオーダリングコンパイラはコンパイル時に、命令の実行順序を自由に入れ替えることができる。しかし、メモリアクセスの順番が重要な場合、問題が起きる。 ほとんどのプログラミング言語は、定義された順序で文を実行する実行スレッドの概念を持っています。従来のコンパイラは、高レベルの式を低レベルの命令列に変換し、機械レベルのプログラムカウンタに関連付けていた。 実行効果は2つのレベルで見ることができる。1つはプログラムコード内の高レベルで、もう1つは機械レベルで、他のスレッドや処理要素から見た並行プログラミングで、もう1つは機械状態にアクセスできるハードウェアデバッグ支援を使用したデバッグ時である(このサポートは、実行コアとは別に、機能的に独立した回路としてCPUやマイクロコントローラに直接組み込まれていることが多く、実行状態を静的に検査するためにコア自体が停止していても動作し続ける)。コンパイル時のメモリ順序は前者に関係するものであり、これらの他の見解には関係しない。 プログラム順序の一般的な問題式の評価によるプログラム順序の影響コンパイル時には、上位コードで指定されたものよりも細かい粒度のハードウェア命令が生成されることがある。手続き型プログラミングで観察できる主な効果は、名前の付いた変数に新しい値を割り当てることである。 sum = a + b + c; print(sum); 変数sumに代入する文の後に マシンレベルでは、1つの命令で3つの数字を足すことができるマシンはほとんどないので、コンパイラはこの式を2つの加算演算に変換する必要がある。プログラム言語のセマンティクスにより、コンパイラが式を左から右の順に翻訳するように制限されている場合、生成されるコードはプログラマが元のプログラムに次のような文を書いたかのようになる。 sum = a + b; sum = sum + c; コンパイラが加算の連想性を利用することを許可されている場合、代わりに次のようなコードが生成される。 sum = b + c; sum = a + sum; コンパイラが加算の可換性を利用することを許可されている場合、代わりに次のように生成されるかもしれない。 sum = a + c; sum = sum + b; ほとんどのプログラミング言語の整数データ型は、整数のオーバーフローがない場合に数学の整数の代数に従うだけであり、また、ほとんどのプログラミング言語で利用可能な浮動小数点データ型の浮動小数点演算は、丸め効果においては可換ではなく、表現の順序の影響が計算結果の小さな違いとして現れることに注意すべきである(ただし、最初の小さな違いは、長い計算の間に任意の大きな違いに連鎖する可能性がある)。 プログラマが整数のオーバーフローや浮動小数点の丸め効果を気にする場合は、同じプログラムを元のハイレベルで次のようにコード化することができる。 sum = a + b; sum = sum + c; 関数呼び出しによるプログラム順序の影響多くの言語は文の境界をシーケンスポイントとして扱い、次のステートメントが実行される前に、ある文のすべての効果が完了するように強制する。これにより、コンパイラは表現されたステートメント順に対応するコードを生成することになる。しかし、ステートメントはしばしばより複雑で、内部の関数呼び出しを含むことがある。 sum = f(a) + g(b) + h(c); マシンレベルでは、関数を呼び出すには、通常、関数呼び出しのためのスタックフレームを設定する必要があり、これにはマシンメモリへの多くの読み取りと書き込みが含まれる。ほとんどのコンパイル言語では、コンパイラは都合の良いように関数呼び出し このような効果を気にするプログラマは、元のソースプログラムをより厳密に表現することができる。 sum = f(a); sum = sum + g(b); sum = sum + h(c); ステートメントの境界がシーケンスポイントとして定義されているプログラミング言語では、関数呼び出し メモリ順序の具体的な問題ポインタ表現によるプログラム順序の影響次にポインタをサポートするC/C++などの言語で、同じ加算をポインタ間接指定で表現した場合を考える。 sum = *a + *b + *c; 式 もし代入された値がポインタ間接であったなら? *sum = *a + *b + *c; 言語定義ではコンパイラがこれを次のように分解することはできそうにない。 // コンパイラによって書き換えられる // 一般的に禁止されている *sum = *a + *b; *sum = *sum + *c; となります。 これはほとんどの場合、効率的とは言えず、ポインタの書き込みには、目に見えるマシンの状態に副作用が生じる可能性がある。コンパイラはこの特別な分割変換を許可していないため、 しかしプログラマが整数オーバーフローの目に見えるセマンティクスを気にして、このステートメントをプログラムレベルで次のように分割したとする。 // プログラマーが直接作成したもの // エイリアシングを考慮して *sum = *a + *b; *sum = *sum + *c; 最初のステートメントは、2つのメモリ読み込みをエンコードしており、これらは 例えば、この例では *sum = *a + *b + *sum; これは何の問題もない。最初に // *cと*sumをエイリアスした場合のプログラムの内容 *sum = *a + *b; *sum = *sum + *sum;
// 上のエイリアスケースの代数的等価性 *sum = (*a + *b) + (*a + *b); となり、文の並べ替えにより ポインタ式はエイリアシングの影響を受ける可能性があるため、プログラムに目に見える影響を与えることなく再編成することは困難である。一般的なケースでは、エイリアシングの影響はないので、コードは以前のように正常に実行されているように見える。しかしエイリアシングが存在するエッジケースでは、深刻なプログラムエラーが発生する可能性がある。このようなエッジケースが通常の実行では全くないとしても、悪意のある者がエイリアシングが存在する入力を仕組んで、コンピュータ・セキュリティの悪用につながる可能性がある。 先ほどのプログラムを安全に並べ替えると以下のようになる。 // 適切な型の一時的なローカル変数'temp'を宣言する temp = *a + *b; *sum = temp + *c; 最後に、関数呼び出しを追加した間接的なケースを考えてみよう。 *sum = f(*a) + g(*b); コンパイラは、 言語仕様におけるメモリ順序一般的に、コンパイルされた言語の仕様は、コンパイラがコンパイル時にどのポインタがエイリアスになる可能性があり、どのポインタがエイリアスにならないかを正式に判断できるほど詳細ではない。最も安全な方法は、コンパイラが常にすべてのポインタがエイリアスになる可能性があると仮定することである。このような保守的な悲観論は、エイリアスが存在しないという楽観的な仮定と比較して、恐ろしいほどのパフォーマンスを生み出す傾向がある。 その結果、C/C++などの多くの高級コンパイル言語では、最高のパフォーマンスを追求するためにコンパイラがコードの並べ替えで楽観的な仮定をすることが許される場合と、セマンティックな危険性を回避するためにコンパイラがコードの並べ替えで悲観的な仮定をすることが要求される場合について、複雑で洗練されたセマンティック仕様を持つようになった。 現代の手続き型言語では、メモリ書き込み操作が最大の副作用となるため、プログラムの順序セマンティクスを定義する際には、メモリ順序に関する規則が重要な要素となる。上記の関数呼び出しの順序変更は、別の検討事項のように見えるかもしれないが、これは通常、呼び出された関数の内部のメモリ効果と、関数呼び出しを生成する式のメモリ操作との相互作用に関する問題に発展する。 その他の困難と複雑さas-ifでの最適化最近のコンパイラでは、さらに一歩進んで、目に見えるプログラムのセマンティクスに影響がなければ、どのような並べ替えでも(文をまたいでも)許されるというas-ifルールを採用している場合がある。このルールの下では、翻訳されたコード内の操作の順序は、指定されたプログラムの順序とは大きく異なる。エイリアスが実際に存在する場合(通常は、未定義の動作を示す不正なプログラムに分類される)に、エイリアスの重なりがない別々のポインタ式をコンパイラが楽観的に仮定することが許されている場合、積極的なコード最適化変換の悪影響は、コードの実行またはコードの直接検査の前には推測できない。未定義の動作とは、無限の可能性を秘めているのである。 コンパイラによる最適化の結果、セマンティクスが変更される可能性のある不正なプログラムを書かないように、言語仕様を確認するのはプログラマの責任である。システムプログラミング言語であるCやC++も同様である。 高級言語の中にはポインターを使わないものもあるが、これはプロのプログラマーであっても、このような注意深さや細部へのこだわりを確実に維持するのは難しいと考えられているからである。 メモリ順序のセマンティクスを完全に把握することは、この分野に精通している一部のプロのシステムプログラマにとっても、難解な専門分野であると考えられている。ほとんどのプログラマーは、自分のプログラミングの専門知識の範囲内で、これらの問題を十分に理解している。メモリ順序セマンティクスに特化した極端な例としては、コンカレント・コンピューティング・モデルをサポートするソフトウェア・フレームワークを作成するプログラマーが挙げられる。 ローカル変数のエイリアシングローカル変数へのポインタが外部に漏れた場合、ローカル変数にエイリアシングがないとは言えないことに注意すべきである。 sum = f(&a) + g(a); 関数fが与えられたaへのポインタに対して何をしたかはわからない。関数gが後でアクセスするグローバルな状態にコピーを残しておくこともできる。最も単純なケースでは、fは変数aに新しい値を書き込み、この式は実行順に定義されないものとなる。fは、ポインタ引数の宣言にconst修飾子を適用することで、このような動作を目立たなくすることができ、この式は正しく定義される。このように、現代のC/C++の文化は、すべての実行可能なケースで関数の引数宣言にconst修飾子を与えることに、やや強迫観念的になっている。 C/C++では、fの内部が危険な手段としてconstness属性を型キャストすることを許可している。もしfが上の式を壊すような方法でこれを行うのであれば、そもそもポインタ引数の型をconstとして宣言すべきではない。 他の高級言語では、このような宣言属性は強力な保証となり、この保証を破るための抜け道が言語自体に用意されていないという傾向がある。アプリケーションが別のプログラミング言語で書かれたライブラリをリンクする場合、この言語の保証はすべて外れる(ただし、これはひどい悪意のある設計と考えられる)。 コンパイル時メモリバリアの実装→「メモリバリア」も参照
これらのバリアは、コンパイラがコンパイル時に命令を入れ替えないように抑制する一方で、実行時のCPUによるリオーダリングを抑制することはできない。
asm volatile("" ::: "memory"); もしくは __asm__ __volatile__ ("" ::: "memory"); は、GCCコンパイラが、その前後の読み書き命令を入れ替えることを禁止する。[1]
__memory_barrier()
_ReadWriteBarrier() が存在する。[4] 実行時メモリオーダリングSMPシステムの場合
いくつかのCPUでは、
脚注
関連項目外部リンク
|