メモリ順序とは、 CPUによるコンピュータメモリへのアクセス順序のことである。メモリ順序は、コンパイル時にコンパイラによって生成される命令の順序と、実行時にCPUが実行する順序の両方に依存する。[1] [2]しかし、マルチスレッドとメモリマップドI/O以外では、メモリ順序はあまり重要ではない。なぜなら、コンパイラまたはCPUが操作の順序を変更する場合、その順序変更によって通常のシングルスレッドコードの出力が変更されないことを必ず保証しなければならないからである。[1] [2] [3]
メモリ順序は、演算順序が変更できないか、変更してもどのスレッドにも目に見える影響を与えない場合、強い順序または逐次一貫性があると言われます。 [1] [4]逆に、あるスレッドが別のスレッドから発生する演算順序を予測できない場合、メモリ順序は弱い順序または緩和されていると言われます。 [1] [4]多くの単純に書かれた並列アルゴリズムは、弱いメモリ順序でコンパイルまたは実行すると失敗します。[5] [6]この問題は、プログラムにメモリバリア命令を挿入することで解決されることが多いです。 [6] [7]
キャッシュやメモリバンクなどの異なる種類のメモリの帯域幅を最大限に活用するために、完全に強い順序付けを保証するコンパイラやCPUアーキテクチャはほとんどありません。[1] [5]一般的に使用されているアーキテクチャの中で、x86-64プロセッサは最も強いメモリ順序を備えていますが、それでもメモリストア命令をメモリロード命令の後まで延期することがあります。[5] [8]一方、DEC Alphaプロセッサはメモリ順序について実質的に保証しません。[5]
ほとんどのプログラミング言語には、定義された順序で文を実行する実行スレッドという概念があります。従来のコンパイラは、高水準の式を、基盤となるマシンレベルのプログラムカウンタを基準とした低水準の命令列に変換します。
実行の影響は、2 つのレベルで確認できます。1 つはプログラム コード内の高レベル、もう 1 つは並行プログラミングで他のスレッドや処理要素から見たマシン レベル、もう 1 つはマシン状態にアクセスできるハードウェア デバッグ ツールを使用したデバッグ中のマシン レベルです (このサポートの一部は、実行コアとは別に機能的に独立した回路として CPU またはマイクロコントローラに直接組み込まれており、実行コア自体は実行状態の静的検査のために停止している場合でも動作し続けます)。コンパイル時のメモリ順序は前者に関係し、これらの他のビューには関係しません。
コンパイル時に、ハードウェア命令は高水準コードで指定されたよりも細かい粒度で生成されることがよくあります。手続き型プログラミング言語における主な目に見える効果は、名前付き変数への新しい値の代入です。
合計 = a + b + c; print(合計);
print文は変数sumに代入する文の後に続くため、print文が計算された変数を参照する場合、sumこの結果を前の実行シーケンスの観測可能な結果として参照します。プログラムシーケンスの規則で定義されているように、print関数呼び出しが を参照する場合sum、 の値はsum変数への最も最近実行された代入sum(この場合は直前の文)の値でなければなりません。
マシンレベルでは、1つの命令で3つの数値を加算できるマシンはほとんどないため、コンパイラはこの式を2つの加算演算に変換する必要があります。プログラム言語のセマンティクスによって、コンパイラが式を左から右の順序でしか変換できない(例えば)場合、生成されるコードは、プログラマが元のプログラムに次のような文を書いたかのように見えます。
合計 = a + b; 合計 = 合計 + c;
コンパイラが加算の結合法則を利用できる場合、代わりに次のようなコードが生成されるかもしれません。
合計 = b + c; 合計 = a + 合計;
コンパイラが加算の交換法則を利用することも許可されている場合、代わりに次のようなコードが生成されるかもしれません。
合計 = a + c; 合計 = 合計 + b;
ほとんどのプログラミング言語の整数データ型は、整数オーバーフローがない場合に数学の整数の代数にのみ従い、ほとんどのプログラミング言語で使用できる浮動小数点データ型の浮動小数点演算は丸め効果において可換ではなく、式の順序の影響が計算結果の小さな違いに現れることに注意してください (ただし、最初の小さな違いは、より長い計算では任意の大きな違いに連鎖する可能性があります)。
プログラマーが整数オーバーフローや浮動小数点の丸めの影響を懸念している場合、同じプログラムを次のように元の高レベルでコーディングできます。
合計 = a + b; 合計 = 合計 + c;
多くの言語では、文の境界をシーケンスポイントとして扱い、ある文のすべての効果が次の文の実行前に完了するように強制します。これにより、コンパイラは文の順序に従ってコードを生成します。しかし、文は多くの場合より複雑で、内部関数呼び出しを含むこともあります。
合計 = f(a) + g(b) + h(c);
マシン レベルでは、関数の呼び出しには通常、関数呼び出し用のスタック フレームの設定が含まれ、マシン メモリへの多数の読み取りと書き込みが伴います。ほとんどのコンパイル言語では、コンパイラは関数呼び出しf、g、 をh都合の良いように自由に順序付けることができるため、プログラム メモリの順序が大幅に変更されます。純粋な関数型プログラミング言語では、関数呼び出しが目に見えるプログラム状態 (戻り値以外) に副作用をもたらすことは禁止されており、関数呼び出しの順序によるマシン メモリの順序の違いは、プログラムの意味には重要ではありません。手続き型言語では、呼び出された関数に、 I/O 操作 の実行 や、グローバル プログラム スコープでの変数の更新 などの副作用がある場合があります。これらの副作用はどちらも、プログラム モデルに目に見える影響をもたらします。
繰り返しになりますが、これらの効果を気にするプログラマーは、元のソース プログラムを次のように表現する際に、より細心の注意を払うことができます。
合計 = f(a); 合計 = 合計 + g(b); 合計 = 合計 + h(c);
ステートメント境界がシーケンス ポイントとして定義されているプログラミング言語では、関数は、、を呼び出しf、gをhその正確な順序で実行する必要があります。
ここで、ポインタをサポートする C や C++ などの言語で、ポインタ間接参照を使用して表現された同じ合計を考えてみましょう。
合計 = *a + *b + *c;
式を評価することはポインタの*x「逆参照」と呼ばれ、 の現在の値で指定された場所からメモリを読み取ることを伴います。 ポインタからの読み取りの効果は、アーキテクチャのメモリ モデルxによって決まります。 標準プログラム ストレージから読み取る場合、メモリ読み取り操作の順序による副作用はありません。組み込みシステムのプログラミングでは、メモリ マップド I/Oが非常に一般的です。メモリの読み取りと書き込みによって I/O 操作がトリガーされたり、プロセッサの動作モードが変更されたりするなど、非常に目立つ副作用があります。 上記の例では、今のところ、ポインタがこれらの副作用のない通常のプログラム メモリを指していると仮定します。 コンパイラは、これらの読み取りをプログラムの順序で適切に並べ替えることができ、プログラムから見える副作用はありません。
割り当てられた値もポインタ間接である 場合はどうなるでしょうか?
*合計 = *a + *b + *c;
ここで、言語定義では、コンパイラがこれを次のように分割することを許可する可能性は低いです。
// コンパイラによって書き換えられたもの // 一般的に禁止されている *合計 = *a + *b; *合計 = *合計 + *c;
これはほとんどの場合効率的とは見なされず、ポインタ書き込みはマシンの可視状態に潜在的な副作用をもたらします。コンパイラはこの特定の分割変換を許可していないため、メモリ位置への書き込みは、sum値式内の3つのポインタ読み取りの後に論理的に続く必要があります。
しかし、プログラマーが整数オーバーフローの目に見えるセマンティクスを懸念し、次のようにステートメントをプログラム レベルで分割するとします。
// プログラマーが直接作成したもの // エイリアシングの懸念あり *合計 = *a + *b; *合計 = *合計 + *c;
最初の文は2回のメモリ読み取りをエンコードしており、これらは(順序は*sum問わず) への最初の書き込みに先行する必要があります。2番目の文は2回のメモリ読み取りをエンコードしており(順序は問わず)、これらは の2回目の更新に先行する必要があります*sum。これにより、2回の加算操作の順序は保証されますが、アドレスエイリアシングという新たな問題が発生する可能性があります。つまり、これらのポインタはいずれも同じメモリ位置を参照する可能性があります。
たとえば、この例では、*cと が*sum同じメモリ位置にエイリアスされていると仮定し、*sum両方の代わりに を使ってプログラムの両方のバージョンを書き換えます。
*合計 = *a + *b + *合計;
ここでは問題はありません。 として書き込んだ元の値は*cに代入すると失われ*sum、 の元の値も同様に失われます*sumが、これはそもそも上書きされているため、特に問題にはなりません。
// *c と *sum をエイリアスするとプログラムは次のようになります *合計 = *a + *b; *合計 = *合計 + *合計;
ここで、 の元の値は*sum最初のアクセスの前に上書きされ、代わりに次の代数的等価値が得られます。
// 上記のエイリアスケースの代数的等価物 *合計 = (*a + *b) + (*a + *b);
*sumステートメントの並べ替えにより、
まったく異なる値が割り当てられます。
エイリアシング効果の可能性があるため、ポインタ式は目に見えるプログラム効果をリスクにさらすことなく並べ替えることが困難です。一般的なケースでは、エイリアシング効果は発生しない可能性があり、コードは以前と同様に正常に動作しているように見えます。しかし、エイリアシングが存在するエッジケースでは、深刻なプログラムエラーが発生する可能性があります。これらのエッジケースが通常の実行では全く発生しないとしても、悪意のある攻撃者がエイリアシングが存在する入力を巧みに作り出す可能性が生じ、コンピュータセキュリティの脆弱性につながる可能性があります。
前のプログラムを安全に並べ替えると次のようになります。
// 適切な型の一時ローカル変数'temp'を宣言する 温度 = *a + *b; *合計 = 温度 + *c;
最後に、関数呼び出しが追加された間接的なケースを考えてみましょう。
*合計 = f(*a) + g(*b);
コンパイラは、関数呼び出しの前に と を評価する*aか、関数呼び出しの後まで*bの評価を延期するか、関数呼び出しの後までの評価を延期するかを選択できます。関数と にプログラムから見える副作用がない場合、これら3つの選択肢はすべて、同じプログラム効果を持つプログラムを生成します。またはの実装に、ポインタまたはとのエイリアシングの影響を受けるポインタ書き込みの副作用が含まれる場合、これら3つの選択肢はそれぞれ異なるプログラム効果を持つ可能性があります。
*bf*agfgfgab
一般的に、コンパイル言語の仕様は、どのポインタが潜在的にエイリアス化されているか、そしてどのポインタがそうでないか、コンパイラがコンパイル時に正式に判断できるほど詳細ではありません。最も安全な方法は、すべてのポインタが常に潜在的にエイリアス化されているとコンパイラが想定することです。このレベルの保守的な悲観論は、エイリアスが全く存在しないという楽観的な想定と比較して、ひどいパフォーマンスを生み出す傾向があります。
その結果、C/C++ などの多くの高水準コンパイル言語は、可能な限り最高のパフォーマンスを追求するためにコンパイラがコードの並べ替えで楽観的な想定を行える場所と、セマンティック ハザードを回避するためにコンパイラがコードの並べ替えで悲観的な想定を行える場所について、複雑で洗練されたセマンティック仕様を持つように進化しました。
現代の手続き型言語における副作用の最大のクラスはメモリ書き込み操作であるため、メモリ順序に関する規則はプログラム順序セマンティクスの定義において主要な要素となります。上記の関数呼び出しの順序変更は一見異なる考慮事項のように見えるかもしれませんが、これは通常、呼び出された関数内部のメモリ効果が、関数呼び出しを生成する式内のメモリ操作と相互作用することへの懸念へと発展します。
最近のコンパイラは、as-if ルールによってこれをさらに一歩進めて、目に見えるプログラムの意味に影響がない限り、あらゆる並べ替え(ステートメント間であっても)が許可されることがあります。このルールによれば、変換されたコード内の演算順序は、指定されたプログラム順序と大きく異なる可能性があります。エイリアシングが実際に存在する場合(これは通常、未定義の動作を示す不正なプログラムとして分類されます)、コンパイラが、異なるポインタ式にはエイリアスの重複がないという楽観的な仮定を行うことが許可されている場合、コードを実行または直接コード検査する前に、積極的なコード最適化変換の悪影響を推測することは不可能です。未定義の動作の領域は、ほぼ無限の現れ方をします。
コンパイラによる最適化によって意味が変化する可能性のある、不適切なプログラムを書かないように、言語仕様を参照するのはプログラマの責任です。Fortranは伝統的に、これらの問題を認識するためにプログラマに大きな負担をかけており、システムプログラミング言語のCやC++もそれに劣りません。
一部の高級言語では、ポインタ構造を完全に排除しています。これは、プロのプログラマーの間でも、細部への注意と警戒のレベルが高すぎて確実に維持できないと考えられるためです。
メモリ順序セマンティクスの完全な理解は、この分野に精通しているプロのシステムプログラマーの間でさえ、難解な専門分野とみなされています。ほとんどのプログラマーは、自分のプログラミングの専門知識の範囲内で、これらの問題を適切に理解できればそれで満足しています。メモリ順序セマンティクスの専門性が最も高いのは、並行コンピューティングモデルをサポートするソフトウェアフレームワークを開発するプログラマーです。
ローカル変数へのポインタが外部に漏れた場合、その変数はエイリアシングがないとは想定できないことに注意してください。
合計 = f(&a) + g(a);
関数が、f指定された へのポインタを用いて何を行うかは予測できません。これには、a関数が後からアクセスするグローバル状態にコピーを残すことも含まれますg。最も単純なケースでは、fは変数 に新しい値を書き込みa、この式は実行順序において不明確になります。 は、ポインタ引数の宣言にconst 修飾子をf適用することで、このような事態を明らかに防ぐことができ、式は明確に定義されます。そのため、現代の C/C++ 文化では、あらゆる実行可能なケースにおいて関数引数の宣言に const 修飾子を付与することに、やや強迫観念を抱いています。
CおよびC++では、内部でconst属性を型キャストで破棄することが危険な方法として許可されていますf。もし上記fの式を破るような方法でこれを行うのであれば、そもそもポインタ引数の型をconstとして宣言すべきではありません。
他の高水準言語は、言語自体に提供されているこの保証を破る抜け穴のない強力な保証に相当する宣言属性に傾いています。アプリケーションが別のプログラミング言語で書かれたライブラリをリンクする場合、この言語の保証はまったく役に立ちません (ただし、これはひどく悪い設計であると考えられています)。
これらのバリアは、コンパイル時にコンパイラが命令を並べ替えることを防ぎます。実行時に CPU によって並べ替えられることは防ぎません。
asm volatile("" ::: "メモリ");
__asm__ __volatile__ ("" ::: "メモリ");
アトミックシグナルフェンス(メモリオーダー取得解放)
__memory_barrier()
_ReadBarrier() _WriteBarrier() _ReadWriteBarrier()
多くのプログラミング言語では、様々な種類のバリアを他の操作(ロード、ストア、アトミックインクリメント、アトミックコンペア&スワップなど)と組み合わせることができるため、バリアの前後(あるいはその両方)に追加のメモリバリアは必要ありません。対象となるCPUアーキテクチャに応じて、これらの言語構造は、ハードウェアのメモリ順序保証に応じて、特殊命令、複数の命令(バリアとロードなど)、または通常の命令に変換されます。
SMPシステム にはいくつかのメモリ一貫性モデルがあります。
一部のCPUでは
SMP をサポートする多くのアーキテクチャには、実行時に読み取りと書き込みをフラッシュするための特別なハードウェア命令があります。
lfence (アセンブリ)、void _mm_lfence(void) sfence (asm), void _mm_sfence(void) [18] mfence (asm), void _mm_mfence(void) [19]
同期(アセンブリ)
同期(アセンブリ)[20] [21]
mf(アセンブリ)
dcs (アセンブリ)
dmb (アセンブリ) dsb(アセンブリ) isb(asm)
一部のコンパイラは、ハードウェア メモリ バリア命令を発行する 組み込みをサポートしています。
__sync_synchronize。atomic_thread_fence()コマンドが追加されました。MemoryBarrier()ヘッダーにマクロがあります(非推奨)。[25] [13]__machine_r_barrierには、、__machine_w_barrierおよびがあります__machine_rw_barrier。コンパイラがデータアクセス命令をスケジュールしないバリアを作成します。コンパイラはメモリバリアを越えてレジスタにローカルデータを割り当てることはできますが、グローバルデータを割り当てることはできません。