マルチスレッドのカスピ海モンスター





C ++マルチスレッドでは、「非常に高速」は「非常に安全」を除外しないことを示したいと思います。 これは、任意の多数のスレッドで効率的で安定したプログラムを作成できると同時に、マルチスレッドのデバッグに多くの時間を費やすことを回避できることを意味します。 あなたが私が自分の足で撃たないように管理する方法と、私がそれを支払う方法に興味があるなら、歓迎



7〜8年前に、マルチスレッドプログラムを作成する必要が増えたとき、友人のキャプテンエビデンスは次の事実に注目しました。スレッドが増えるほど、相互作用が活発になり、同期オブジェクトが必要になり、テスト段階で眠れない夜が長くなります。 マルチスレッドのエラーは美しい若い女性に似ているため、状況は複雑です。エラーは常に偶然目に見えますが、特定のエラーを再度見つけることははるかに困難です。



一般に、プログラム内のスレッドの数が安定して5〜6を超えると、10個以上になるまで、これで何かを行う必要があり、非常に迅速に行う必要があることに気付きました。

同時に、同じWin APIにWaitForMultipleObjectsなどのCookieがある場合、クロスプラットフォーム環境に切り替えると、ミューテックス、クリティカルセクション、および無条件のシグナルサポートしかありません(そして、素晴らしいクロスプラットフォームU ++フレームワークに切り替えたときに起こりました) 。



ソリューションの検索に数か月かかりましたが、注目すべきプログラミング言語Erlangの説明に目が行きました。 彼は、プログラムの安定性を大幅に向上させる、新しいものではなく、非常にエレガントなクロススレッド相互作用のシステムを提案しました。



要するに、私たち自身の言葉で言えば、各サブタスクが独自の「アドレス空間」(またはデータセット)で機能する、つまり他のサブタスクから分離されているという事実について話しているのです。 そして、サブタスクが相互作用する唯一の方法は、非同期メッセージを交換することです。 これにはすべて、真面目な理論、多くの巧妙な名前と機能がありますが、友人のキャプテンエビデンスは、これらすべての退屈な詳細が本当に好きではなく、ポイントにまっすぐ進むように頼みます。



各フローをコンベアとして提示します。 これは、新しいメッセージの到着を待機している「無限の」ループでスレッドがスピンすることを意味します。 メッセージが処理されるとすぐに、パイプライン化されたストリームは次のものを処理するか、新しいものを予期してスリープ状態になります。



引数付きの着信メッセージがマルチスレッド環境から渡されることを少し忘れてみましょう。 パイプラインの内部には、内部クラスデータを使用して、スレッドセーフな着信引数を処理するための最も単純なシングルスレッドループがあります。 このような作業には同期オブジェクトが不要であることは明らかです。 これは、マルチスレッドについて何も知らない通常のコードです。 私たちのクラス-不幸な人は、そのデータを誰とも共有しなかったことを思い出してください。つまり、別のストリームのオブジェクトがそれらに影響を与えることはできません(これは、カプセル化要件にうまく適合します)。



これらの同じ非同期メッセージをどのように、そしてどのような形で送信するかを決定することは残っています。 この場所で、私の友人K.O. 頑固に黙ったので、私は自分で決断をしなければなりませんでした。 そのような各クラスのメッセージの種類の列挙はすぐに明らかになりました

enum {MESSAGE_....., MESSAGE_....., MESSAGE_.....}
      
      



残酷に見え、その処理
 switch (messageType) { case MESSAGE_.....: ....... break; case MESSAGE_.....: ....... break; case MESSAGE_.....: ....... break; case MESSAGE_.....: ....... break; }
      
      



感謝の子孫からの正当な憎悪と下痢の光線を投げかけます。



一般的に、処理のためにキューに入れて、すぐにargumentsでコールバックを開始しました。 そこで、不要な列挙を取り除き、コードを読みやすくしました。 次のようになります。
 //,       class SomeJobThread : public CallbackThread { public: //thread SomeJobThread void DoSomeJob(String arg1, double arg2) { //      //    } private: //  }; SomeJobThread someJob; someJob.Request(&SomeJobThread::DoSomeJob, "OMG!", -1);
      
      







このコードは少し変わって見えますが、最初は初めてです。 同時に、それは非常に表現力があり、同期オブジェクト、サイクルなどで過負荷になりません。

実際、フローデータを単一のクラスに結合します。継承により、単純なシングルスレッドコードで構成されるストリームをパイプライン化し、その中にハンドラーを記述します。 それだけです!



このアプローチを適用すると、マルチスレッドアプリケーションを構築するアプローチがわずかに変わりますが、信じる価値はあります。



ここで、私の友人K.O.は、愛情を込めて目を細めて、スレッドセーフに支払うことを述べています。



実際、次のことが起こります。 処理サイクルは内部同期オブジェクトを使用し、入力にメッセージがなくなるまで常に「スリープ」します。 これは、開発者のカルマをクリアするだけでなく、プロセッサクロックと惑星のエコロジーの闘争に実行可能な貢献をします。 私の実装でのコールバック呼び出しは、仮想関数呼び出しに加えて、引数を使用した非仮想関数呼び出しと同等です。



キューとストリームの同期は、同期オブジェクトを介して最も簡単な方法で実行されます。この作業はユーザーコードに対して完全に透過的です。 ロック-ポインタをキューから引き出します-ロックを解除します。 多くのリソースはこれを取り上げません。 さらに、ノンブロッキングキューを使用できます。



引数のコピーについて。 ポッドタイプのコピーは、かなり迅速な操作であり、ほとんどのアプリケーションでは一般的に見えません。 より複雑な引数については、破壊的なコピーを使用するのが理にかなっています。 たとえば、文字列は、内部データへのポインターの破壊的な送信を通じて渡すことができます(このメカニズムはU ++で積極的に使用されています)。 したがって、引数をリストまたは連想配列に引数として渡すことは非常に安価な操作です。 実際、実際のプログラムでの任意の複雑なパラメーターの転送は、最大でいくつかのPOD変数のコピーになります。



そして最後に、メモリの使用。 キューの各要素は、引数に加えて、ポインターと2つのポインターの仮想テーブル(total ==(1 + 1 + 2)* sizeof(void *))を含む小さな構造体であり、かなりの量です。



そして最後に、どのアプローチもあらゆる場面で万能薬ではないことを理解する必要があります。 たとえば、高性能Webサーバーのメインスレッドは、別の領域のタスクです。 しかし、デスクトップアプリケーションでのほとんどすべてのマルチスレッド作業は、グローブとしてこのアプローチに当てはまります。 実際には、1,000個または2個を超えるコールバックのストリームキューは、設計エラーまたはコーディングエラーの結果です。 どちらもキューの長さの制限とデバッグのアサートによってキャッチされます。 これは、各キューが64K未満のメモリを占有することを意味し、今日の標準ではほとんど認識できません。



それだけでなく、パイプライン化されたアプローチにより、同期オブジェクトの総数が著しく減少し、その結果、ロックの数が減少し、その結果、プログラムの安定性が向上し、生産性が向上することがわかりました。



他に言えることは... キューはクールです。 キューを恐れないでください!





PSこの記事では、原則を説明したばかりで、アプローチの概要を説明しました。 パイプラインフローを使用するプロジェクトの設計、逆通知、負荷の高いアプリケーション用のパイプラインフローのプールの作成に関連する多くのポイントがあります。 記事がコミュニティにとって興味深いものである場合、これらのポイントをより詳細に開示します。



All Articles