同時システムは、さまざまな同時実行モデルを使用して実装できます。同時実行モデルは、スレッドがシステム内でどのように連携して、与えられたタスクを完了するかを指定します。同時実行モデルが異なれば、作業を分割する方法も異なり、スレッドは異なる方法で相互に通信および連携する場合があります。この同時実行モデルのチュートリアルでは、執筆時点で最も広く使用されている同時実行モデルについて詳しく説明します。
同時実行モデルは分散システムに似ています
この記事で説明されている同時実行モデルは、分散システムで使用されるさまざまなフレームワークに似ています。同時システムでは、異なるスレッドが相互に通信します。分散システムでは、さまざまなプロセスが (おそらくはさまざまなコンピューター上で) 通信します。本質的に、スレッドとプロセスは非常に似ています。さまざまな同時実行モデルがさまざまな分散フレームワークに似ていることが多いのはこのためです。
もちろん、分散システムには、ネットワーク障害、リモートコンピューターやプロセスのクラッシュなど、追加の課題もあります。ただし、大規模なサーバーで同時実行されるシステムでは、CPU の障害、ネットワーク カードの障害、ディスクの障害などが発生した場合に、同様の問題が発生する可能性があります。このような障害が発生する可能性は低いかもしれませんが、理論的には発生する可能性があります。
同時実行モデルは分散システム フレームワークに似ているため、多くの場合、相互にアイデアを学ぶことができます。たとえば、ワーカー (スレッド) 間で作業を分散するモデルは、分散システムの負荷分散に似ています。ロギング、フォールト トレランスなどのエラーを処理するための手法も同じです。
パラレル ワーカー
最初の同時実行モデル。これをパラレル ワーカー モデルと呼びます。受信したタスクはさまざまなワーカーに割り当てられます。これが図です:
並列ワーカー同時実行モデルでは、エージェントは受信した作業をさまざまなワーカーに分散します。各ワーカーはタスク全体を完了します。ワーカー全体が並行して動作し、異なるスレッドで、場合によっては異なる CPU で実行されます。
自動車工場にパラレルワーカーモデルが導入された場合、各車は労働者によって生産されます。このワーカーには構築の指示が与えられ、最初から最後まですべてを構築します。
パラレル ワーカー同時実行モデルは、Java アプリケーションで最も広く使用されている同時実行モデルです (ただし、それは変わりつつあります)。 Java パッケージ java.util.concurrent 内の多くの同時実行ユーティリティ クラスは、このモデルを使用するように設計されています。 Java エンタープライズ アプリケーションでもこのモデルの痕跡を見ることができます。
パラレル ワーカーの利点
パラレル ワーカー同時実行モデルの利点は、比較的理解しやすいことです。アプリケーションの並列性を高めるには、ワーカーを追加するだけです。
たとえば、Web クローラー機能を実装している場合、さまざまな数のワーカーを使用して一定数のページをクロールし、どのワーカーのクロール時間が短い (つまり、パフォーマンスが高い) かを確認します。 Web スクレイピングは IO 集中型のジョブであるため、コンピューター上の CPU/コアごとに複数のスレッドが存在する可能性があります。データのダウンロードを待機している間、長時間アイドル状態になるため、CPU あたり 1 つのスレッドでは少なすぎます。
パラレル ワーカーの欠点
パラレル ワーカー同時実行モデルには、表面下にいくつかの欠点が潜んでいます。欠点のほとんどについては、以下のセクションで説明します。
共有状態の取得は複雑です
実際には、パラレルワーカー同時実行モデルは上記で説明したよりも複雑です。この共有ワーカーは、多くの場合、メモリまたは共有データベース内の一部の共有データにアクセスする必要があります。以下の図は、パラレル ワーカー同時実行モデルの複雑さを示しています。
この共有状態の一部は、ワークキューなどの通信メカニズム内にあります。ただし、この共有状態の一部には、ビジネス データ、データ キャッシュ、データベース接続プールなどが含まれます。
共有状態が並列ワーカー同時実行モデルに侵入するとすぐに、複雑になり始めます。このスレッドは、あるスレッドによる変更が他のスレッドから見えるようにするために、何らかの方法で共有データにアクセスする必要があります (このスレッドを実行している CPU の CPU キャッシュに留まるだけでなく、メイン メモリにプッシュされます)。スレッドは、競合状態、デッドロック、その他多くの共有状態の同時実行の問題を回避する必要があります。
さらに、共有データ構造にアクセスするときに、スレッドが互いに待機すると、並列計算部分が失われます。多くの同時データ構造が詰まっています。これは、いつでも 1 つまたは限られたスレッドのセットがアクセスできることを意味します。これにより、これらの共有データ構造で競合が発生する可能性があります。競合が多いと、共有データ構造にアクセスするコード部分の実行において、本質的にある程度のシリアル化が発生します。
最新のノンブロッキング同時アルゴリズムは競合を減らし、パフォーマンスを向上させる可能性がありますが、ノンブロッキング アルゴリズムは実装が困難です。
永続的なデータ構造も別のオプションです。永続的なデータ構造は、変更されると常に以前のバージョンを保持します。さらに、複数のスレッドが同じ永続データ構造を指しており、スレッドの 1 つがそれを変更すると、変更を行ったスレッドは新しい構造への参照を取得します。他のすべてのスレッドは古い構造への参照を保持し、変更されないままになります。 Scala プログラミング言語には、いくつかの永続的なデータ構造が含まれています。
永続データ構造は、共有データ構造を同時に変更するための洗練された簡潔なソリューションである一方で、パフォーマンスが良くありません。
たとえば、永続リストはすべての新しい要素をリストの先頭に追加し、新しく追加された要素への参照を返します (これによりリストの残りの部分が実行されます)。他のすべてのスレッドは、リスト内の最初に前の要素への参照を維持しており、リストは他のスレッドには変更されていないように見えます。この新しく追加された要素は表示されません。
このような永続的なリストは、リンクされたリストとして実装されます。残念ながら、リンク リストは最新のソフトウェアではうまく機能しません。リスト内の各要素は個別のオブジェクトであり、これらのオブジェクトはコンピューターのメモリ全体に分散することができます。最新の CPU はデータへの順次アクセスがはるかに高速であるため、リストではなく配列の上にデータを実装すると、最新のハードウェアでのパフォーマンスが向上します。配列にはデータが順番に格納されます。この CPU キャッシュは、一度に大きなチャンクをキャッシュにロードでき、ロードされたデータはこの CPU キャッシュに直接アクセスできます。リンク リスト内の要素はすべての RAM に分散されるため、リンク リストを使用してこれを実装することは不可能です。
ステートレスワーカー
システム内で共有されている状態は、他のスレッドによって変更できます。したがって、ワーカーは、最新のコピーで動作しているかどうかを確認するために、必要になるたびにこの状態を再読み取りする必要があります。これは、共有状態がメモリ内にあるか外部データベース内にあるかに関係なく当てはまります。内部的に状態を維持しない (ただし、毎回再読み取りする必要がある) ワーカーはステートレスであると言われます。
毎回データを再読み込みする必要がある場合、速度が遅くなります。特にこの状態が外部データベースに保存されている場合はそうです。
タスクの順序を決定できない
並列ワーカー モデルのもう 1 つの欠点は、タスクの実行順序を決定できないことです。どのタスクが最初に実行され、どのタスクが最後に実行されるかを保証する方法はありません。タスク A はタスク B の前にワーカーに与えられますが、タスク B はタスク A より前に実行される場合があります。
パラレル ワーカー モデルは当然ながら非決定的であるため、特定の時点でのシステムの状態を推論することが困難になります。また、あるタスクが別のタスクの前に確実に実行されるようにすることも困難です (基本的に不可能です)。
組立ライン
2 番目の同時実行モデル。私はそれを組立ライン同時実行モデルと呼びます。私がこの名前を選んだのは、単に「パラレル ワーカー」の比喩にもっと単純に適合するためです。他の開発者は、他の名前 (リアクティブ システム、イベント駆動型システムなど) を使用して、プラットフォームまたはコミュニティに依存しています。説明するための画像例を次に示します。
この作業員は、工場の組み立てラインで働く作業員に似ています。各ワーカーは全体の作業の一部のみを実行します。その部分が完了すると、作業者はタスクを次の作業者に引き継ぎます。
各ワーカーは独自のスレッドで実行され、ワーカー間で状態を共有することはありません。したがって、これはシェアードナッシング同時実行モデルとして言及されることがあります。
パイプライン同時実行モデルを使用するシステムは、通常、ノンブロッキング IO を使用して設計されています。ノンブロッキング IO とは、ワーカーが IO 操作 (ネットワーク接続からのファイルやデータの読み取りなど) を開始するときに、ワーカーは IO 呼び出しが完了するのを待たないことを意味します。 IO 操作が非常に遅いため、IO 操作が完了するのを待つのは CPU の無駄です。この CPU は、同時に他のことも実行できます。 IO 操作が終了すると、IO 操作の結果 (データの読み取りまたはデータの書き込みのステータスなど) が別のワーカーに渡されます。
ノンブロッキング IO の場合、この IO 操作はワーカー間の境界範囲を決定します。ワーカーは、IO 操作を開始する必要があるまで、できる限りのことを行います。その後、それを制御するタスクを放棄します。この IO 操作が終了すると、パイプライン内の次のワーカーも IO 操作を開始する必要があるまで、このタスクの作業を続けます。
実際、これらのタスクは必ずしも生産ライン上で行われるわけではありません。ほとんどのシステムは 1 つのタスクを実行するだけではないため、ワーカー間のタスクの流れは、どのタスクを実行する必要があるかによって異なります。実際には、複数の異なる仮想パイプラインが同時に実行されます。以下の図は、実際のパイプライン システムでタスクがどのように流れるかを示しています。
タスクは、同時に実行するために複数のワーカーを実行することもあります。たとえば、ジョブはタスク実行プログラムとタスク ログの両方を指す場合があります。この図は、3 つのパイプラインすべてが同じワーカーにタスクを指すことによってどのように最終的に終了するかを示しています (最後のワーカーは中央のパイプラインにあります)。
リアクティブ システム、イベント駆動型システム
パイプライン同時実行モデルを使用するシステムは、通常、リアクティブ システム、イベント駆動型システムと呼ばれます。このシステムのワーカーは、外部から受け取った、または他のワーカーによって発行された、システム内で発生するイベントに反応します。イベントの例としては、HTTP リクエストやメモリにロードされるファイルの終わりなどが挙げられます。 この記事の執筆時点では、興味深いリアクティブ/イベント駆動型のプラットフォームが多数利用可能であり、将来的にはさらに多くなるでしょう。より一般的なもののいくつかは次のようになります:
Vert。Java/JVM については時代遅れだと思います)アクター モデルでは、すべてのワーカーはアクターと呼ばれます。アクターは相互にメッセージを送信できます。メッセージは非同期に送信され、実行されます。前述のように、アクターを使用して 1 つ以上のタスクを実装できます。アクター モデルは次のとおりです。
これを書いている時点では、このチャネル モデルはより回復力があるように見えます。ワーカーは、パイプラインの後続のワーカーによってどのようなタスクが実行されるかを知る必要はありません。必要なのは、このジョブに対してチャネルが何を指すか (またはメッセージの送信先) を知ることだけです。チャネルのリスナーは、チャネルに書き込みを行っているワーカーに影響を与えることなく、サブスクライブまたはキャンセルできます。これにより、ワーカー間の疎結合が可能になります。
パイプラインの利点
このパイプライン同時実行モデルには、並列ワーカー モデルに比べていくつかの利点があります。次のセクションでは、最大の利点について説明します。
状態の共有なし
ワーカーが他のワーカーと状態を共有しないという事実は、実装するすべての同時実行の問題について考える必要がないことを意味します。これにより、ワーカーの実装が容易になります。ワーカーを実装するときに、その作業を 1 つのスレッドだけが実行する場合、それは本質的にシングルスレッド実装になります。
ステートフルワーカーワーカーは他のスレッドがデータを変更しないことを知っているため、このワーカーはステートフルです。ステートフル。つまり、操作に必要なデータをメモリ内に保持し、最終的な変更を外部ストレージ システムに書き戻すだけです。したがって、ステートフル ワーカーはステートレス ワーカーよりも高速です。
より優れたハードウェア統合シングルスレッドコードにはこの利点があり、多くの場合、基盤となるハードウェアによりよく適応します。まず、コードがシングルスレッド モードで実行できると仮定すると、より最適化されたデータ構造とアルゴリズムを作成できます。
2 番目のシングルスレッド ステートフル ワーカーは、前述のようにデータをメモリにキャッシュできます。データがメモリにキャッシュされると、実行中のスレッドの CPU の CPU キャッシュにもデータがキャッシュされる可能性が高くなります。これにより、データへのアクセスが高速になります。
基盤となるハードウェアの動作方法から恩恵を受ける方法でコードが記述される場合、私はそれをハードウェア統合と呼びます。一部の開発者は、これをハードウェアの動作方法と呼んでいます。私がハードウェア統合という言葉を好むのは、コンピュータには機械的な部品が非常に少ないためです。また、この記事では「共感」という言葉が「よりよくフィットする」ことの比喩として使われていますが、「適合」という言葉はより合理的であることを表現していると私は考えています。
とにかく、これは厄介です。好きな言葉を使ってください。
逐次タスクが可能
タスクの順序をある程度保証するパイプライン同時実行モデルに基づいた同時システムを実装することが可能です。連続タスクにより、任意の時点でのシステムの状態を推論することが容易になります。さらに、受信したすべてのタスクをログに書き込むことができます。このログは、システムの一部に障害が発生した場合に、障害点から再構築するために使用できます。このタスクは特定の順序でログに書き込むことができ、この順序がタスクの固定順序になります。設計の図を以下に示します。
固定タスク シーケンスの実装は確かに簡単ではありませんが、可能です。可能であれば、データのバックアップ、復元、コピーなどのタスクが大幅に簡素化されます。これらはすべてログ ファイルを通じて実行できます。
パイプラインの欠点
パイプライン同時実行モデルの主な欠点は、タスクの実行がプロジェクト内の複数のワーカーおよび複数のクラスに分散されることが多いことです。したがって、特定のタスクに対してどのコードが実行されているかを正確に確認することはさらに困難になります。
コードを書くのも難しくなるかもしれません。ワーカー コードは多くの場合、コールバック関数として記述されます。より多くのネストされたコールバックが付属するコードは、一部の開発者がどのコールバックを呼び出すかに飽きてしまう可能性があります。コールバック地獄とは、コードの動作を追跡し、各コールバックがデータにアクセスするために必要なデータを判断することがより困難になることを意味します。
パラレルワーカー同時実行モデルを使用すると、これがより簡単になります。このワーカー コードを開いて、実行されたコードをほぼ最初から最後まで読むことができます。もちろん、並列ワーカー コードはさまざまなクラスに分散することもできますが、実行順序はコードから読みやすくなります。
関数型並列処理
関数型並列処理は 3 番目の同時実行モデルであり、長年にわたり多くの議論が行われてきました。
関数型並列処理の基本的な考え方は、関数呼び出しを使用してプログラムを実装することです。関数は、パイプライン同時実行モデル (別名リアクティブ システムまたはイベント駆動型システム) と同様に、相互にメッセージを送信する「エージェント」または「アクター」として見なされます。ある関数が別の関数を呼び出すときは、メッセージを送信するのと似ています。
関数に渡されるすべてのパラメーターはコピーされるため、受信側関数の外部にこのデータを結合できるエンティティは存在しません。このコピーは、共有データの静的な状態を回避するために重要です。これにより、関数はアトミック操作と同様に実行されます。各関数呼び出しは、他の関数呼び出しとは独立して実行できます。
関数呼び出しを個別に実行できる場合、各関数呼び出しは別の CPU で実行できます。つまり、実装された関数アルゴリズムは複数の CPU で並行して実行できるということです。
Java 7 では、ForkAndJoinPool を含む java.util.concurrent パッケージを取得します。これは、関数型並列処理と同様のことを実現するのに役立ちます。 Java 8 では、並列ストリームが得られ、大規模なコレクションの反復を並列化するのに役立ちます。 ForkAndJoinPool に不満を抱いている開発者がいることを忘れないでください (私の ForkAndJoinPool チュートリアルにいくつかの批判へのリンクがあります)。
関数型並列処理で最も難しいのは、どの関数を呼び出して並列化するかを知ることです。 CPU 間で関数呼び出しを調整すると、オーバーヘッドが発生します。関数によって完了される作業単位には、ある程度のオーバーヘッドが必要です。関数呼び出しが非常に小さい場合、関数呼び出しを並列化しようとすると、シングルスレッドの CPU のみで実行するよりも実際に遅くなる可能性があります。
私の理解では (もちろんこれは完璧ではありませんが)、リアクティブ システムとタイム ドライブを使用してアルゴリズムを実装し、作業の分解を完了できます。これは関数型並列処理に似ています。イベント駆動モデルを使用すると、並列化の量と方法をより詳細に制御できるようになります (おそらく)。
また、タスクを複数の CPU に分割すると、調整のコストがかかります。これは、そのタスクが現在このプログラムによって実行されている唯一のタスクである場合にのみ意味があります。ただし、システムが他の複数のタスク (Web サーバー、データベース サーバー、その他多くのシステムなど) を実行している場合、単一のタスクを並列化する意味はありません。いずれにせよ、コンピューター上の他の CPU は他のタスクの実行で忙しいため、機能が遅い並列タスクによって CPU を中断する必要はありません。パイプライン同時実行モデルを使用するのが最も賢明です。これは、オーバーヘッドが少なく (シングルスレッド モードでの順次実行)、基盤となるハードウェアへの準拠性が高いためです。
どの同時実行モデルが最適ですか
それでは、どの同時実行モデルが最適なのでしょうか?
通常はそれだけです。答えはシステムがどうなるかによって異なります。タスクが本質的に並列かつ独立しており、共有状態を必要としない場合は、並列作業モデルを使用してシステムを実装できます。
ただし、多くのタスクは、本来は並行して独立しているわけではありません。このような種類のシステムでは、このパイプライン同時実行モデルには欠点よりも利点の方が多く、並列ワーカー モデルよりも利点の方が大きいと私は考えています。
パイプライン構造のコードを自分で記述する必要さえありません。 Vert.x のような最新のプラットフォームは、すでにこれの多くを行ってくれます。個人的には、私の次のプロジェクトは、Vert.x のようなプラットフォーム上で実行されるデザインを調査する予定です。 Java EEにはメリットが無いような気がします。
上記は Java 同時実行モデルの詳細な紹介です。その他の関連コンテンツについては、PHP 中国語 Web サイト (www.php.cn) に注目してください。