並列コンピューティング-少しすくい

「カブトムシ、カブトムシ、カブトムシ」-スンフィング、プンバアは夢で非難され、彼らは彼を夢見ていた!


マルチスレッドアプリケーションを作成する場合、さまざまなストリームで共有される変数を変更する瞬間の「競合」に特に注意する必要があることは誰もが知っています。 さらに、そのような人種は非常にまれであるため、検出が非常に困難である理由、まれな状況で発生し、予測できない動作、さらにはシステムの崩壊に至る傾向がある理由があります。 最後の声明に同意する必要がある場合は、この投稿の最初の声明と議論し、起こりうる問題を顕在化させるプロセスを強化できることを示します。これにより、発生の原因を特定し、それらを排除することが容易になります。 このような強化の主なアイデアは、マルチコアシステムでの並列コンピューティングの使用です。



ちょっとした理論-現代のシステムでスレッドを作成するとき、それは特定のアクションを実行したいという願望であり、正確に実行されるとき、実行システムは独自の内部アルゴリズムによって決定します。 間接的な影響は、優先順位とサービス間隔を設定することで可能ですが、これもシステムによって考慮されるか、この特定の状況では無視できる単なる希望です。 実行順序に影響するパラメーターの1つは、さまざまなプロセスを同時に実行できるシステムで使用可能なハードウェアコアの数です。 マイクロコントローラーでは、1つのコアを処理し、それに応じてプロセスを切り替えるさまざまな方法に慣れていますが、切り替えの瞬間に起こりうる問題が正確に発生します。また、切り替え頻度はプロセスの実行時間の最小量に依存するため、このパラメーターに影響を与える可能性は非常に高いです限られていますが、このようなイベントは非常にまれであり、デバッグ中にすべての人が結果を観察できるとは限りません。 しかし、手頃な価格のMKコンピューティングシステムの世界は制限されておらず、多くのPCには、マザーボードを購入したときにコンピューターストアの売り手に伝えられないほど多くのコアがあります。 これらのコアの利点は何ですか? 多くの実行システムは、コンテキストを切り替えることなく、さまざまなスレッドの異なるコアで同時に起動を実際に編成しますが、可能性のあるレースは非常に激しくなり(数桁の頻度が高くなります)、プログラム実行プロセスへの影響は肉眼で観察できます



簡単な例を考えてみましょう-2つのスレッドを開始します。1つのスレッドは変数の値を100万倍、2番目のスレッドは同じ変数の値を100万倍増やします。 開始前にゼロに等しい場合、両方のプロセスの完了後の変数の値はどうなりますか? もちろん、ゼロではなく、「そうでなければ、アルメニアのラジオはこれについて尋ねなかっただろう」。 正解は、特別な予防措置を講じない限り、100万から100万までの範囲の任意の数値です。これについては後で詳しく説明します。 根拠がないようにするために、プログラムの例と2回の実行の結果を示します。
#include <iostream> #include <thread> #define NUMBER 1000000 using namespace std; static int counter=0; //         int f1(void) { for (int i=0; i<NUMBER; i++) { counter++; // ldi r1,#counter; ld r0,@r1; inc r0; st r0,@r1 }; }; int f2(void) { for (int i=0; i<NUMBER; i++) { counter--; }; }; int main() { thread t1(f1); thread t2(f2); //    t1.join(); t2.join(); //    cout << counter << endl; } 340865 -557870
      
      





準備ができていないユーザーにとってはやや予期しないものですか? MKでフローを処理した人は、数十から数十の範囲の数を期待できますか? これは、問題が完全に発生するという不確実な状態の強化の魅力です。 まず、結果としてゼロを取得しないのはなぜですか(もちろん、取得できますが、このイベントの確率は非常に小さいです)。 問題は、ほとんどのRISCアーキテクチャではRAM内のデータを使用した直接操作が不可能であるため、counter incrementコマンドがコメントで指定された一連のアセンブラコマンドに変わることです。 切り替えの例を見てみましょう-カウンター2で、最初のスレッドがアセンブラーコマンド1および2を実行し、レジスタr0で2、この瞬間に最初のスレッドの実行が中断され、2番目のスレッドが動作を開始し、カウンターで2番目のスレッドが1000回それから1を引き、1から998の数字を受け取り、メモリ内の最後の書き込みコマンドを実行します。-998はカウンターにあります。ここでは、2番目のスレッドが中断され、最初のスレッドが実行されます。コマンド3および4を実行し、カウンター3に配置されます。 、1000の減算の結果は消えました。 もちろん、これを行うには、コマンド2が実行されてコマンド4が実行された後に最初のスレッドを中断する必要があります。これはあまり頻繁には発生しません。これは、上記のフラグメントに加えて、単純なプログラムでもサイクルの編成に関連するコマンドが存在するためですが、確率障害を取得する必要がある場所を正確に取得することは、まったくゼロではありません。



MKに、レジスタではなくメモリの数を増減できるチームがあれば、そのような迷惑は発生しませんが、プリエンプティブマルチタスクの場合のみです。 フローの並列処理モードで作業している場合、そのようなコマンドが存在しても、データバスの操作に関連するより微妙な同じ瞬間から私たちを救うことはできません。 実際、メモリセルの内容を変更するコマンドでさえ、メモリ上で直接実行することはできません(そのようなシステムがあるかもしれませんが、私は個人的にそれらを知りません)-バスレベルでは、メモリからALUへの読み取りがあり、実際に変更を実行し、受信したデータを記録します同じアドレスのメモリになります。つまり、1つのスレッドが別のスレッドの結果を歪める可能性があります。 観察している競争力の種類を判断することは可能です(これを好奇心reader盛な読者のためのタスクとして残します)が、これは結果に影響を与えません。明らかに間違った結果、さらにランダムに歪んだ結果が得られました。

2つのコアの実行モードとスイッチングモードの違いは次のとおりです。 最初のケースでは、競合は各サイクルで文字通り発生します(切り替えが頻繁に発生すると言うことができます)が、結果の歪みは1を超えません。したがって、比較的均一な分布で完全に乱数を受け取ります。 2番目の場合、歪みははるかに少ない頻度で発生します(切り替え時のみ、常にではない場合もあります)が、歪みは、ストリームの次の実行中に実行されるすべての操作を構成します。 したがって、ジャンプで変化する値を取得し、受信したデータは個別のステップにグループ化されます。 さらに、特定の条件下では、正しい結果を得ることができます。これは、デバッグ時にこの問題に気付かないという事実につながります。 これが、エラーを明らかにするプロセスを強化するために、複数のコアでプログラムを実行することを強くお勧めする理由です。



これらの熊手は広く知られており、美しく照らされた場所に立つため、時々踏まれるのを妨げません。並列計算で観察されます。 熊手を詳細に調べ、デバッグに必要な頻度で熊手を踏む方法を学習したので、熊手データを回避する方法と回避策の長さについて少しお話します。

コンピューティングの競争力を考慮してプログラムを変更することにより、期待した結果、つまりゼロを正確に保証する手段があります。 まず第一に、これらはミューテックスと呼ばれるオブジェクトです。つまり、共有リソース(この場合はカウンターと呼ばれるメモリ領域)へのアクセスを許可するフラグです。 私たちはコードを見ます-すべてがうまく、理解可能で、正しいです、たった1つの小さな欠点があります-スレッドの完了を期待するプログラムの実行時間が20回以上増加しました-つまり、私たちはかなり長い弧に沿ってレーキを回っています。

 #include <mutex> using namespace std; static mutex m; int f1(void) { for (int i=0; i<NUMBER; i++) { m.lock(); counter++; m.unlock(); }; };
      
      



これが発生した理由は明らかです-単純な変更操作の代わりに、mutexを追加でキャプチャします。2番目のスレッドがキャプチャしようとするのを停止し、操作を実行してmutexを解放します。 ただし、このオプションの正確性に加えて、このオプションの主な利点は、スレッドおよびミューテックスパッケージがある場所であればどこでも機能することです。 この方法の主な欠点は、現在リソースの競合があるかどうかに関係なく、常に追加の操作を実行する必要があることです。

CompareAndSwap操作に関連付けられている、少し複雑ですが、はるかに短いトラバースパスがあります。
 using namespace std; int f1(void) { int j; for (int i=0; i<NUMBER; i++) { do { j=counter; } while (__sync_val_compare_and_swap(&counter,j,j+1) != j ); }; };
      
      



ここでも、すべてが正しく、少し明確ではありませんが、ノンブロッキング操作に関する関連資料を読んだ後、すべてが所定の位置に落ち、実行時間は前のものに比べて半分になりますが、最も単純なバージョンよりも10倍以上遅いです。 ノンブロッキング操作に関する資料が私たちに軽微な損失を約束したことを除いて、すべてが非常に期待されていますが、ここで損失はまだ重要です。 それにもかかわらず、リソースの頻繁な競争の状況では(そして、それは私たちの国では残酷です)、この方法は好ましくないので、すべてが正しいです、私たちは正直に警告されました。 このオプションの欠点は、システム上にそのようなコマンドがハードウェアに実装されている必要があることです。これは常にそうであるとは限りません。 プラスは、リソースに対する実際の競合がある場合にのみ操作が繰り返されることです。それ以外の場合、追加コストは最小限です。

熊手を踏むことなく、熊手にさらに近づけることができます。これが原子操作の方法です。 コードを見る
 int f1(void) { for (int i=0; i<NUMBER; i++) { __sync_add_and_fetch(&counter,1); }; };
      
      



すべてが完全に正しく、一見ほとんど理解できますが、原子操作に関する資料を読むと、完全に明確で、コンパクトで、十分に高速です-私の実行時間はたった2+倍しか増えていません(あなたにとっては異なる場合があります)アトミック操作はC&Sのような珍しい獣であり、対応するソフトウェアの使用を開始すると、ミューテックスの速度レベルまで即座に低下します。

要約すると、アトミック操作がある場合、それらを共有リソースへの基本アクセスに使用すると、最速で最もコンパクトなコードが得られ、並列処理のタイプはまったく重要ではありません-それは常に機能します。 C&Sチームは優れた代替手段です。ただし、リソースをめぐる激しい競争がある場合、オーバーヘッドは高くなる可能性があります。 そして、ミューテックスの使用は、他に何もなければ最も高価なオプションです。 ところで、ミューテックスについての少しの議論-彼らはそれをどのように行うのでしょうか? コンテキスト切り替えオプションのすべてが明確な場合、割り込みを禁止するか、シェダーの操作を禁止することで切り替えを禁止できます(状態を復元する問題を簡単に解決できるため、後者の方が望ましい)。その後、多くのコアを持つバリアントでは、答えはそれほど明白ではありません。 ほとんどの場合、長いバスロックを使用したり、C&Sコマンドを使用したりする必要があります。 XCHレジスタおよびメモリコマンドを使用してC&Sコマンドのアナログを実装する場合、非常に興味深いオプションがまだありますが、この場合、コマンドはアトミックモードで実行する必要があります。 他のオプションを知っている人がいれば、情報やリンクを共有してください。



All Articles