11月初旬、C ++言語に特化した次のC ++会議C ++ CoreHard Autumn 2018が開催されました。むき出しのマルチスレッド」、競合プログラミングモデル。 このレポートのカット版では、記事に変換されました。 櫛でとめ、場所で整え、場所で補う。
私はこの機会に、次の大規模なカンファレンスをミンスクで開催し、
講演する機会を与えてくれた
CoreHardコミュニティに感謝の意を表します。 また
、YouTubeのレポートの
ビデオレポートを迅速に公開するために。
それでは、会話の主なトピックに移りましょう。 つまり、C ++でマルチスレッドプログラミングを簡素化するために使用できるアプローチ、これらのアプローチの一部がコードでどのように見えるか、特定のアプローチに固有の機能、それらの間で共通する機能などです。
注:レポートの元のプレゼンテーションでエラーとタイプミスが見つかったため、この記事では、
Googleスライドまたは
SlideShareにある更新および編集されたバージョンのスライドを使用し
ます 。
裸のマルチスレッドは悪です!
繰り返しのバナリティから始める必要がありますが、それでも依然として関連性があります。
ベアスレッド、ミューテックス、条件変数を使用したマルチスレッドC ++プログラミングは、 汗 、 痛み 、 血です。
Habréのこの記事の最近の良い例は、「
Tacticoolモバイルオンラインシューティングゲームのメタサーバーの
アーキテクチャ 」
です。 その中で、彼らはどうやってどうやってCやC ++でのマルチスレッドコードの開発に関連するあらゆる種類のレーキを収集したかについて話しました。 競合の結果として「メモリパス」が発生し、並列化に失敗したため生産性が低下しました。
その結果、すべてが自然に終了しました。
最も重要なバグの検索と修正に数週間を費やした後、現在のソリューションのすべての欠点を修正するよりも、 すべてをゼロから書き直す方が簡単であると判断しました。
サーバーの最初のバージョンで作業しているときにC / C ++を食べ、別の言語でサーバーを書き直しました。
現実の世界では、居心地の良いC ++コミュニティの外で、開発者はC ++の使用が依然として適切で正当化されている場合でもC ++の使用を拒否する方法の優れたデモンストレーションです。
しかし、なぜですか?
しかし、なぜ、C ++での「むき出しのマルチスレッド」が悪であると繰り返し言われた場合、人々はより良いアプリケーションに値する忍耐力でそれを使い続けますか? 責任の所在:
結局のところ、時間と多くのプロジェクトでテストされた1つのアプローチとはほど遠いものです。 特に:
- 俳優
- 通信順次プロセス(CSP)
- タスク(非同期、約束、先物など)
- データフロー
- リアクティブプログラミング
- ...
主な理由は依然として無知であることが望まれます。 これが大学で教えられることはまずありません。 専門職に就く若い専門家は、彼らがすでに知っていることをほとんど使っていません。 そして、その後ナレッジベースが補充されない場合、人々はベアスレッド、ミューテックス、condition_variablesを使い続けます。
今日は、このリストの最初の3つのアプローチについて説明します。 そして、抽象的にではなく、1つの簡単なタスクの例として話しましょう。 この問題を解決するコードが、アクター、CSPプロセス、チャネル、およびタスクを使用してどのように見えるかを示してみましょう。
実験への挑戦
次のHTTPサーバーを実装する必要があります。
- 要求(画像ID、ユーザーID)を受け入れます。
- このユーザーに固有の「透かし」が付いた写真を提供します。
たとえば、そのようなサーバーは、サブスクリプションによってコンテンツを配信する有料サービスで必要になる場合があります。 このサービスの写真がどこかで「ポップアップ」した場合、その上の「透かし」によって、誰が「酸素をブロック」する必要があるかを理解することが可能になります。
タスクは抽象的であり、デモプロジェクトShrimpの影響下でこのレポートのために特別に策定されました(すでに説明しました:
No. 1 、
No。2 、
No。3 )。
このHTTPサーバーは次のように機能します。
クライアントからリクエストを受信した後、2つの外部サービスを使用します。
- 最初はユーザーに関する情報を返します。 そこから「透かし」を含む画像を取得します。
- 2番目は元の画像を返します
これらのサービスは両方とも独立して機能し、両方に同時にアクセスできます。
要求の処理は互いに独立して行うことができ、単一の要求を処理するときのいくつかのアクションでさえ並行して行うことができるため、競争力の使用はそれ自体を示唆しています。 頭に浮かぶ最も簡単なことは、着信要求ごとに個別のスレッドを作成することです。
しかし、one-request = one-workflowモデルは高価すぎて、うまくスケールしません。 これは必要ありません。
ワークフローの数に無駄に近づいても、少数のワークフローが必要です。
ここでは、着信HTTPリクエストを受信するための個別のストリーム、独自の発信HTTPリクエストのための個別のストリーム、受信したHTTPリクエストの処理を調整するための個別のストリームが必要です。 画像の操作を実行するためのワークフローのプールと同様に(画像の操作は非常に並列であるため、複数のストリームで同時に画像を処理すると、処理時間が短縮されます)。
したがって、私たちの目標は、少数のワークスレッドで多数の同時着信要求を処理することです。 さまざまなアプローチでこれを達成する方法を見てみましょう。
いくつかの重要な免責事項
メインストーリーに進み、コード例を解析する前に、いくつかのメモを作成する必要があります。
まず、次の例はすべて特定のフレームワークまたはライブラリに関連付けられていません。 API呼び出し名の一致は、ランダムで意図的ではありません。
第二に、以下の例にはエラー処理がありません。 これは、スライドがコンパクトで見えるように意図的に行われます。 また、レポートに割り当てられた時間に材料が適合するようにします。
第三に、この例では、プログラムの内部に他に何が存在するかについての情報を含むエンティティexecution_contextを使用しています。 このエンティティの入力は、アプローチに依存します。 アクターの場合、execution_contextには他のアクターへのリンクがあります。 CSPの場合、execution_contextには、他のCSPプロセスとの通信用のCSPチャネルがあります。 等
アプローチ#1:アクター
簡単なアクターモデル
アクターモデルを使用する場合、ソリューションは個別のオブジェクトアクターで構築され、各アクターは独自のプライベート状態を持ち、この状態はアクター自身以外の誰もアクセスできません。
アクターは、非同期メッセージを介して互いに対話します。 各アクターには独自のメールボックス(メッセージキュー)があり、そこにアクターに送信されたメッセージが保存され、その後の処理のためにそこから取得されます。
アクターは非常に単純な原則に基づいて動作します。
- アクターは振る舞いを持つエンティティです。
- アクターは着信メッセージに応答します。
- メッセージを受信すると、アクターは次のことができます。
- いくつかの(最終的な)数のメッセージを他のアクターに送信します。
- (最終的な)数の新しいアクターを作成します。
- 後続のメッセージを処理するための新しい動作を定義します。
アプリケーション内で、アクターはさまざまな方法で実装できます。
- 各アクターは、個別のOSストリームとして表すことができます(これは、たとえば、C :: Just :: Thread Pro Actor Editionライブラリで発生します)。
- 各アクターは、スタックフルコルーチンとして表すことができます。
- 各アクターは、誰かがコールバックメソッドを呼び出すオブジェクトとして表すことができます。
この決定では、コールバックを持つオブジェクトの形でアクターを使用し、CSPアプローチのコルーチンを残します。
アクターのモデルに基づく決定スキーム
アクターに基づいて、問題を解決するための一般的なスキームは次のようになります。
HTTPサーバーの開始時に作成され、HTTPサーバーが動作している間は常に存在するアクターがあります。 これらは、HttpSrv、UserChecker、ImageDownloader、ImageMixerなどのアクターです。
新しい着信HTTP要求を受信すると、RequestHandlerアクターの新しいインスタンスを作成します。これは、着信HTTP要求への応答を発行した後に破棄されます。
RequestHandlerアクターコード
着信HTTP要求の処理を調整するrequest_handlerアクターの実装は、次のようになります。
class request_handler final : public some_basic_type { const execution_context context_; const request request_; optional<user_info> user_info_; optional<image_loaded> image_; void on_start(); void on_user_info(user_info info); void on_image_loaded(image_loaded image); void on_mixed_image(mixed_image image); void send_mix_images_request(); ...
このコードを解析しましょう。
リクエストを処理するために必要なものを保存する、または保存する属性にクラスがあります。 また、このクラスには、一度に呼び出されるコールバックのセットがあります。
最初に、アクターが作成された直後に、on_start()コールバックが呼び出されます。 その中で、2つのメッセージを他のアクターに送信します。 まず、これはクライアントIDを確認するためのcheck_userメッセージです。 次に、これは元のイメージをダウンロードするためのdownload_imageメッセージです。
送信される各メッセージでは、自分自身へのリンクを渡します(self()メソッドの呼び出しは、self()が呼び出されたアクターへのリンクを返します)。 これは、アクターが応答でメッセージを送信できるようにするために必要です。 check_userメッセージなどでアクターへのリンクを送信しない場合、UserCheckerアクターはユーザー情報の送信先を知ることができません。
ユーザー情報を含むuser_infoメッセージが応答として送信されると、on_user_info()コールバックが呼び出されます。 そして、image_loadedメッセージが送信されると、アクターはon_image_loaded()コールバックを呼び出します。 そして今、これらの2つのコールバック内には、アクターのモデルに固有の機能があります。応答メッセージを受信する順序を正確に知りません。 したがって、メッセージが到着する順序に依存しないようにコードを作成する必要があります。 したがって、各ハンドラーでは、受信した情報を対応する属性に最初に格納し、次に必要な情報をすべて収集しているかどうかを確認します。 もしそうなら、次に進むことができます。 そうでない場合は、さらに待機します。
そのため、send_mix_images_request()が呼び出されるonsがon_user_info()およびon_image_loaded()ifsにあるのはこのためです。
原則として、アクターのモデルの実装には、Erlangからの選択的受信やAkkaからのスタッシングなどのメカニズムがあります。これらのメカニズムを使用して、着信メッセージの処理順序を操作できますが、モデルのさまざまな実装の詳細の大部分を掘り下げないように、今日はこれについては説明しません俳優。
そのため、UserCheckerおよびImageDownloaderから必要なすべての情報を受信すると、send_mix_images_request()メソッドが呼び出され、mix_imagesメッセージがImageMixerアクターに送信されます。 結果の画像を含む応答メッセージを受信すると、on_mixed_image()コールバックが呼び出されます。 ここでは、このイメージをHttpSrvアクターに送信し、HttpSrvがHTTP応答を形成し、不要になったRequestHandlerを破棄するまで待機します(ただし、原則として、RequestHandlerアクターがon_mixed_image()コールバックで自己破壊するのを防ぐものはありません)。
それだけです。
RequestHandlerアクターの実装はかなり膨大なものであることが判明しました。 しかし、これは属性とコールバックを使用してクラスを記述し、さらにコールバックを実装する必要があるという事実によるものです。 しかし、RequestHandlerの動作のロジックは非常に簡単であり、request_handlerクラスのコードの量にもかかわらず、それを理解することは難しくありません。
アクターに固有の機能
これで、ActorsのModelの機能について少し語ることができます。
原子炉
原則として、アクターは着信メッセージにのみ応答します。 メッセージがあります-アクターはそれらを処理します。 メッセージなし-アクターは何もしません。
これは、アクターがコールバックを持つオブジェクトとして表されるアクターのモデルの実装に特に当てはまります。 フレームワークはアクターのコールバックをプルし、アクターがコールバックから制御を返さない場合、フレームワークは同じコンテキスト内の他のアクターにサービスを提供できません。
アクターは過負荷です
アクターでは、アクタープロデューサーがアクターコンシューマー向けのメッセージを、アクターコンシューマーが処理できるよりもはるかに速いペースで非常に簡単に生成できます。
これにより、アクター-コンシューマーの着信メッセージのキューが常に増加するという事実につながります。 キューの成長、つまり アプリケーションのメモリ消費が増加すると、アプリケーションの速度が低下します。 これにより、キューの成長がさらに速くなり、その結果、アプリケーションが完全に機能しなくなる可能性があります。
これはすべて、アクターの非同期相互作用の直接的な結果です。 通常、送信操作は非ブロッキングです。 そして、ブロックするのは簡単ではありません、なぜなら 俳優は自分自身に送ることができます。 そして、アクターのキューがいっぱいの場合、自分に送信するとアクターがブロックされ、これにより彼の作業が停止します。
そのため、俳優と協力する場合、過負荷の問題に真剣に注意を払う必要があります。
多くの俳優が常に解決策とは限りません。
原則として、アクターは軽量のエンティティであり、アプリケーションで大量に作成する誘惑があります。 1万人の俳優、10万人、100万人の俳優を作成できます。 そして鉄があなたを許せば、1億人の俳優でさえ。
しかし問題は、非常に多数のアクターの動作を追跡するのが難しいことです。 つまり 明確に正しく機能する俳優がいるかもしれません。 明らかに正しく動作しないか、まったく動作しない俳優もいますが、これについては確実に知っています。 しかし、あなたが何も知らないアクターがたくさんいる可能性があります。彼らはまったく働いているのか、正しく働いているのか、間違っているのか。 なぜなら、プログラム内に独自の動作ロジックを持つ1億の自律エンティティがある場合、これを監視することは誰にとっても非常に難しいからです。
したがって、アプリケーションで多数のアクターを作成する場合、適用された問題を解決するのではなく、別の問題が発生することが判明する場合があります。 したがって、複数のタスクを実行するより複雑で重いアクターを支持して、単一のタスクを解決する単純なアクターを放棄することが有益な場合があります。 しかし、アプリケーションにはそのような「重い」アクターが少なくなり、それらに従うのが簡単になります。
どこを見て、何をすべきか?
誰かがC ++でアクターを操作したい場合、独自のバイクを作成しても意味がありません。特にいくつかの既製のソリューションがあります。
これらの3つのオプションは、活発で、進化し、クロスプラットフォームで、文書化されています。 無料で試すこともできます。 さらに
、Wikipediaのリストには、新鮮度が異なる[ではない]いくつかのオプションがあります。
SObjectizerとCAFは、例外と動的メモリを適用できるかなり高レベルのタスクで使用するように設計されています。 そして、QP / C ++フレームワークは、組み込み開発に関与する人々にとって興味深いかもしれません。 彼が「投獄」されるのは、このニッチの下です。
アプローチ#2:CSP(順次プロセスの通信)
指にマタンなしのCSP
CSPモデルは、アクターモデルに非常に似ています。 また、自律エンティティのセットからソリューションを構築します。各エンティティは独自のプライベート状態を持ち、非同期メッセージを介してのみ他のエンティティと対話します。
CSPモデル内のこれらのエンティティのみが「プロセス」と呼ばれます。
CSPのプロセスは軽量であり、内部の作業を並列化する必要はありません。 何かを並列化する必要がある場合は、いくつかのCSPプロセスを開始するだけで、その内部にはもう並列化はありません。
CSPプロセスは非同期メッセージを介して相互作用しますが、メッセージはアクターのモデルのようにメールボックスではなく、チャネルに送信されます。 チャネルは、通常は固定サイズのメッセージキューと考えることができます。
メールボックスが各アクターに対して自動的に作成されるアクターモデルとは異なり、CSPのチャネルは明示的に作成する必要があります。 そして、2つのプロセスが相互にやり取りする必要がある場合、自分でチャネルを作成し、最初のプロセスに「ここに書く」と伝え、2番目のプロセスに「ここから読みます」と言う必要があります。
同時に、チャネルには少なくとも2つの操作があり、明示的に呼び出す必要があります。 1つ目は、チャネルにメッセージを書き込む書き込み(送信)操作です。
次に、チャネルからメッセージを読み取るための読み取り(受信)操作です。 また、明示的にread / receiveを呼び出す必要があるため、CSPはアクターモデルと区別されます。 アクターの場合、通常、読み取り/受信操作はアクターから隠されます。 つまり アクターフレームワークは、アクターキューからメッセージを取得し、取得したメッセージのハンドラー(コールバック)を呼び出すことができます。
CSPプロセス自体が読み取り/受信呼び出しのタイミングを選択する必要がありますが、CSPプロセスは受信したメッセージを判別し、抽出したメッセージを処理する必要があります。
「大」アプリケーション内で、CSPプロセスはさまざまな方法で実装できます。
- CSP-shnyプロセスは、個別のスレッドOSとして実装できます。 費用のかかるソリューションですが、プリエンプティブなマルチタスクを使用しています。
- CSPプロセスは、コルーチン(スタックフルコルーチン、ファイバー、グリーンスレッドなど)によって実装できます。 はるかに安価ですが、マルチタスクは協調的です。
さらに、CSPプロセスはスタックフルコルーチンの形式で提示されると想定しています(ただし、以下に示すコードはOSスレッドに実装することもできます)。
CSPベースのソリューション図
CSPモデルに基づくソリューションスキームは、アクターモデルの同様のスキームに非常に似ています(これは偶然ではありません)。
HTTPサーバーの開始時に開始され、常に機能するエンティティもあります。これらは、HttpSrv、UserChecker、ImageDownloader、ImageMixerのCSPプロセスです。 新しい着信要求ごとに、新しいCSP-shyプロセスRequestHandlerが作成されます。 このプロセスは、アクターモデルを使用する場合と同じメッセージを送受信します。
RequestHandler CSPプロセスコード
これは、RequestHandler CSPプロセスを実装する関数のコードのように見える場合があります。
void request_handler(const execution_context ctx, const request req) { auto user_info_ch = make_chain<user_info>(); auto image_loaded_ch = make_chain<image_loaded>(); ctx.user_checker_ch().write(check_user{req.user_id(), user_info_ch}); ctx.image_downloader_ch().write(download_image{req.image_id(), image_loaded_ch}); auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); auto image_mix_ch = make_chain<mixed_image>(); ctx.image_mixer_ch().write( mix_image{user.watermark_image(), std::move(original_image), image_mix_ch}); auto result_image = image_mix_ch.read(); ctx.http_srv_ch().write(reply{..., std::move(result_image), ...}); }
ここでは、すべてが非常に簡単であり、同じパターンが定期的に繰り返されます。
- 最初に、応答メッセージを受信するためのチャネルを作成します。 これが必要な理由は CSPプロセスには、アクターのような独自のデフォルトメールボックスがありません。 したがって、CSP-shnyプロセスが何かを受信する場合、この「何か」が書き込まれるチャネルを作成することで困惑するはずです。
- 次に、CSPマスタープロセスにメッセージを送信します。 このメッセージでは、応答メッセージのチャネルを示しています。
- 次に、応答メッセージを送信する必要があるチャネルから読み取り操作を実行します。
これは、ImageSPixer CSPプロセスとの通信の例で非常に明確に見られます。
auto image_mix_ch = make_chain<mixed_image>();
ただし、このフラグメントに注目する価値はあります。
auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read();
ここでは、俳優のモデルとは別の大きな違いがあります。 CSPの場合、適切な順序で応答メッセージを受信できます。
最初にuser_infoを待ちたいですか? 問題ありません。user_infoが表示されるまで読み取り時にスリープ状態になります。 この時間までにimage_loadedがすでに送信されている場合、それが読み取られるまでチャネルで待機します。
実際、上記のコードに付随できるのはそれだけです。 CSPベースのコードは、アクターベースのコードよりも小さくなりました。 驚くことではありません ここでは、コールバックメソッドを持つ別のクラスを記述する必要はありませんでした。 そして、CSPシャイプロセスRequestHandlerの状態の一部は、引数ctxおよびreqの形式で暗黙的に存在します。
CSP機能
CSPプロセスの反応性とプロアクティブ性
アクターとは異なり、CSPプロセスはリアクティブ、プロアクティブ、またはその両方にすることができます。 CSPプロセスが受信メッセージをチェックしたとします;もしあれば、それを処理しました。 そして、着信メッセージがなかったことを見て、彼は行列を乗算することを約束しました。
しばらくして、マトリックスのCSPプロセスは乗算にうんざりし、彼は再び着信メッセージをチェックしました。 新しいものはありませんか? さて、さて、行列をさらに乗算してみましょう。
そして、着信メッセージがなくてもCSPプロセスが何らかの作業を行うこの機能により、CSPモデルはアクターモデルとは大きく異なります。
ネイティブの過負荷保護メカニズム
原則として、チャネルは限られたサイズのメッセージのキューであり、いっぱいになったチャネルにメッセージを書き込もうとすると送信者が停止するため、CSPには過負荷に対する保護メカニズムが組み込まれています。
実際、スマートプロデューサープロセスと遅いコンシューマプロセスがある場合、プロデューサープロセスはすぐにチャネルをいっぱいにし、次の送信操作のために中断されます。 そして、プロデューサープロセスは、コンシューマプロセスが新しいメッセージのためにチャネル内のスペースを解放するまでスリープします。 場所が表示されるとすぐに、プロデューサープロセスが起動し、新しいメッセージをチャネルにスローします。
したがって、CSPを使用する場合、アクターのモデルの場合よりも過負荷の問題について心配する必要はありません。 確かに、ここには落とし穴がありますが、これについては少し後で説明します。
CSPプロセスの実装方法
CSPプロセスの実装方法を決定する必要があります。
各CSP-shnyプロセスが個別のOSスレッドで表されるようにできます。 高価でスケーラブルではないソリューションであることがわかりました。 しかし一方で、プリエンプティブマルチタスクを取得します:CSPプロセスが行列の乗算を開始するか、何らかのブロッキング呼び出しを行うと、OSは最終的にそれを計算コアから押し出し、他のCSPプロセスが機能するようにします。
各CSPプロセスをコルーチン(スタックフルコルーチン)で表すことができます。 これは、はるかに安価でスケーラブルなソリューションです。 ただし、ここでは協調的なマルチタスクのみを行います。 したがって、突然CSPプロセスが行列の乗算を使用すると、このCSPプロセスとそれに接続されている他のCSPプロセスを持つ作業スレッドがブロックされます。
別のトリックがあるかもしれません。 サードパーティのライブラリを使用するとしますが、その内部では影響を与えることはできません。 また、ライブラリ内では、TLS変数が使用されます(つまり、thread-local-storage)。 ライブラリ関数を1回呼び出すと、ライブラリはTLS変数の値を設定します。 その後、コルーチンは別の作業スレッドに「移動」します。これは可能です。 原則として、コルーチンは1つの作業スレッドから別のスレッドに移行できます。 ライブラリ関数に対して次の呼び出しを行うと、ライブラリはTLS変数の値を読み取ろうとします。 しかし、すでに別の意味があるかもしれません! そして、そのようなバグを探すことは非常に難しいでしょう。
したがって、CSPプロセスを実装する方法の選択を慎重に検討する必要があります。 オプションにはそれぞれ長所と短所があります。
多くのプロセスが常に解決策とは限りません。
アクターと同様に、プログラムに多くのCSPプロセスを作成する機能は、適用された問題の解決策とは限りませんが、自分で追加の問題を作成します。
さらに、プログラム内で発生していることの可視性の低さは、問題の一部にすぎません。 別の落とし穴に焦点を当てたいと思います。
実際、CSP-shnyhチャネルでは、デッドロックアナログを簡単に取得できます。 プロセスAはフルチャネルC1にメッセージを書き込もうとし、プロセスAは一時停止します。 満杯のチャネルC2に書き込もうとしたチャネルC1からプロセスBを読み取る必要があるため、プロセスBは中断されました。 プロセスAはチャネルC2から読み取る必要があります。それだけで、デッドロックが発生しました。
CSPプロセスが2つしかない場合、デバッグ中またはコードレビュー手順でもこのようなデッドロックを見つけることができます。 しかし、プログラムに数百万のプロセスがある場合、それらは互いにアクティブに通信し、そのようなデッドロックの可能性は大幅に増加します。
どこを見て、何をすべきか?
誰かがC ++でCSPを使用したい場合、残念ながらここでの選択はアクターほど大きくはありません。 さて、または私はどこを見て、どのように見えるかわかりません。 この場合、コメントが他のリンクを共有することを願っています。
ただし、CSPを使用する場合は、まず
Boost.Fiberに注目する必要があります。 ファイバー(コルーチン)、チャネル、およびmutex、condition_variable、barrierなどの低レベルプリミティブもあります。 これはすべて取得して使用できます。
スレッド形式のCSPプロセスに満足している場合は、
SObjectizerをご覧ください 。 CSPチャネルの類似物もあり、SObjectizer上の複雑なマルチスレッドアプリケーションは、アクターなしで作成できます。
アクターvs CSP
アクターとCSPは互いに非常に似ています。 繰り返し、私はこれら2つのモデルが互いに同等であるというステートメントに出くわしました。 つまり アクターでできることは、CSPプロセスでほぼ1対1で繰り返すことができ、その逆も同様です。 彼らはそれが数学的に証明されるとさえ言います。 しかし、ここでは何も理解できないので、何も言えません。 しかし、日常的な常識のレベルのどこかでの私自身の考えから、これはすべてもっともらしいように見えます。 実際、場合によっては、アクターをCSPプロセスに、CSPプロセスをアクターに置き換えることができます。
ただし、アクターとCSPにはいくつかの違いがあり、これらのモデルのそれぞれが有益であるか不利であるかを判断するのに役立ちます。
チャネルとメールボックス
アクターには、受信メッセージを受信するための単一の「チャネル」があります。これは、各アクターに対して自動的に作成される彼のメールボックスです。 そして、アクターはそこからメッセージを、メッセージがメールボックスにあった順序で正確に取得します。
これはかなり深刻な質問です。 アクターのメールボックスにM1、M2、M3の3つのメッセージがあるとします。 現在、アクターはM3のみに関心があります。
しかし、M3に到達する前に、アクターは最初にM1、次にM2を抽出します。そして、彼は彼らと何をしますか?繰り返しますが、この会話の一環として、Erlangからの選択的受信メカニズムとAkkaからの隠蔽については触れません。
一方、CSP-shnyプロセスには、現在メッセージを読み取りたいチャネルを選択する機能があります。そのため、CSPプロセスには、C1、C2、およびC3の3つのチャネルを含めることができます。現在、CSPプロセスはC3からのメッセージのみに関心があります。プロセスが読み取るのはこのチャネルです。そして、彼はこれに興味があればチャンネルC1とC2の内容に戻ります。反応性とプロアクティブ性
原則として、アクターはリアクティブであり、着信メッセージがある場合にのみ機能します。CSP-shyプロセスは、着信メッセージがない場合でもいくつかの作業を実行できます。シナリオによっては、この違いが重要な役割を果たす場合があります。ステートマシン
実際、アクターは有限状態マシン(KA)です。したがって、サブジェクトエリアに多くの有限状態マシンがあり、それらが複雑で階層的な有限状態マシンであっても、宇宙船実装をCSPプロセスに追加するよりも、アクターモデルに基づいて実装する方がはるかに簡単です。C ++では、ネイティブCSPサポートはまだありません。
Go言語の経験は、プログラミング言語とその標準ライブラリのレベルでサポートが実装されている場合、CSPモデルを使用することがいかに簡単で便利かを示しています。Goでは、「CSPプロセス」(別名ゴルーチン)の作成が簡単で、チャンネルの作成と操作が簡単です。複数のチャンネルを一度に操作するための組み込み構文があります(Go-shny select、読み取りだけでなく書き込みでも機能します)。標準ライブラリはgoroutinを認識しており、goroutinがstdlibからブロック呼び出しを行うときにそれらを切り替えることができます。C ++では、これまでのところ(言語レベルで)スタックフルコルーチンのサポートはありません。したがって、C ++でのCSPの操作は、松葉杖ではないにしても、場所によっては見えるかもしれません...同じGoの場合よりも、それ自体にもっと注意を払う必要があることは確かです。アプローチ番号3:タスク(async、future、wait_all、...)
最も一般的な言葉でのタスクベースのアプローチについて
タスクベースのアプローチの意味は、複雑な操作がある場合、この操作を個別のタスクステップに分割し、各タスク(タスク)が単一のサブ操作を実行することです。これらのタスクは、特別な非同期操作で開始します。非同期操作は、タスクが完了した後、タスクによって返される値が配置される将来のオブジェクトを返します。N個のタスクを起動し、N個のオブジェクト(将来)を受け取った後、これらすべてを何らかの形でチェーンで編成する必要があります。タスクNo. 1とNo. 2が完了すると、それらによって返される値はタスクNo. 3に分類されるようです。タスク3が完了すると、戻り値はタスク4、5、6に転送されます。等このような「タイ」には、特別な手段が使用されます。たとえば、futureオブジェクトの.then()メソッドや、関数wait_all()、wait_any()など。「指で」そのような説明はあまり明確ではないかもしれないので、コードに移りましょう。特定のコードに関する会話の中で、状況はより明確になるかもしれません(事実ではありません)。タスクベースのアプローチのRequest_handlerコード
タスクに基づいて着信HTTPリクエストを処理するコードは次のようになります。 void handle_request(const execution_context & ctx, request req) { auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); when_all(user_info_ft, original_image_ft).then( [&ctx, req](tuple<future<user_info>, future<image_loaded>> data) { async(ctx.image_mixer_ctx(), [&ctx, req, d=std::move(data)] { return mix_image(get<0>(d).get().watermark_image(), get<1>(d).get()); }) .then([req](future<mixed_image> mixed) { async(ctx.http_srv_ctx(), [req, im=std::move(mixed)] { make_reply(...); }); }); }); }
ここで何が起こっているのかを理解してみましょう。まず、独自のHTTPクライアントのコンテキストで実行する必要があり、ユーザーに関する情報を要求するタスクを作成します。返されるfutureオブジェクトは、user_info_ft変数に格納されます。次に、同様のタスクを作成します。これも独自のHTTPクライアントのコンテキストで実行する必要があり、元のイメージをロードします。返されるfutureオブジェクトは、original_image_ft変数に保存されます。次に、最初の2つのタスクが完了するまで待つ必要があります。直接書き留めたもの:when_all(user_info_ft、original_image_ft)。両方の将来のオブジェクトが値を取得したら、別のタスクを実行します。このタスクは、透かしと元の画像を含むビットマスクを取り、ImageMixerのコンテキストで別のタスクを実行します。このタスクは画像を混合し、完了すると、HTTPサーバーコンテキストで別のタスクが起動され、HTTP応答が生成されます。おそらく、コードで何が起こっているのかのそのような説明はあまり明確にされていません。したがって、タスクに番号を付けましょう。そして、それらの間の依存関係を見てみましょう(そこからタスクの順序が流れます)。そして、この画像をソースコードにオーバーレイすると、より明確になることを願っています。タスクベースのアプローチの機能
可視性
すでに明らかなはずの最初の機能は、タスクのコードの可視性です。すべてが彼女とうまくいくわけではありません。ここでは、コールバック地獄のようなものに言及できます。Node.jsプログラマーは非常によく知っています。しかし、Taskと密接に連携するC ++ニックネームも、このまさにコールバック地獄に突入します。エラー処理
別の興味深い機能はエラー処理です。一方では、関係者へのエラー情報の配信で非同期および将来を使用する場合、アクターまたはCSPの場合よりもさらに簡単になります。結局、CSPプロセスAでプロセスBにリクエストを送信し、応答メッセージを待つ場合、リクエストの実行中にBでエラーが発生した場合、プロセスAにエラーを配信する方法を決定する必要があります。- または、別の種類のメッセージとそれを受信するためのチャネルを作成します。
- または、単一のメッセージで結果を返します。このメッセージは、通常の誤った結果のstd :: variantになります。
未来の場合、すべてがよりシンプルです。未来から通常の結果を抽出するか、例外がスローされます。しかし、一方で、エラーのカスケードに簡単に遭遇する可能性があります。たとえば、タスクNo. 1で例外が発生し、この例外は将来のオブジェクトに落ち、タスクNo. 2に渡されました。問題2では、未来から価値をとろうとしましたが、例外がありました。そして、ほとんどの場合、同じ例外をスローします。したがって、次の未来に落ち、タスクNo. 3に進みます。また、例外もありますが、これもおそらくリリースされます。等
例外がログに記録されると、ログで同じ例外が繰り返し繰り返され、チェーン内の1つのタスクから別のタスクに移動することがわかります。タスクとタイマー/タイムアウトのキャンセル
また、タスクベースのキャンペーンのもう1つの非常に興味深い機能は、何か問題が発生した場合にタスクをキャンセルすることです。実際、150個のタスクを作成し、最初の10個を完了し、作業を続ける意味がないことに気付いたとします。残りの140をキャンセルするにはどうすればよいですか?これは非常に良い質問です:)別の同様の質問は、タイマーとタイムアウトで友達のタスクを作る方法です。外部システムにアクセスしており、待機時間を50ミリ秒に制限したいとします。タイマーの設定方法、タイムアウトの期限切れへの対応方法、タイムアウトの期限が切れた場合のタスクチェーンの中断方法 繰り返しますが、尋ねるのは答えるよりも簡単です:)不正行為
それでは、タスクベースのアプローチの機能についてお話しします。示されている例では、少しの不正行為が適用されています。 auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); });
ここでは、2つのタスクを独自のHTTPサーバーのコンテキストに送信し、それぞれが内部でブロッキング操作を実行します。実際、サードパーティサービスへの2つのリクエストを並行して処理できるようにするには、ここで独自の非同期タスクのチェーンを作成する必要がありました。しかし、ソリューションを多少見やすくし、プレゼンテーションのスライドに合わせるためにこれをしませんでした。アクター/ CSP vsタスク
3つのアプローチを検討し、アクターとCSPプロセスが互いに類似している場合、タスクベースのアプローチはそれらのいずれにも似ていないことがわかりました。そして、Actors / CSPはTaskと対照的であるように思われるかもしれません。しかし、個人的には、私は別の視点が好きです。アクターのモデルとCSPについて話すとき、タスクの分解について話します。このタスクでは、個別の独立したエンティティを選び出し、これらのエンティティのインターフェイスを説明します。どのメッセージを送信し、どのメッセージを受信し、どのチャネルを経由してメッセージを送信するかを説明します。つまり
アクターとCSPを操作して、インターフェイスについて話します。しかし、タスクを個々のアクターとCSPプロセスに分割するとします。彼らはどのくらい正確に仕事をしていますか?タスクベースのアプローチを採用するとき、実装について話し始めます。特定の作業がどのように実行されるか、どの副操作がどの順序で実行されるか、これらの副操作がデータに従ってどのように接続されるかなどについてつまり
Taskでの作業については、実装について説明しています。その結果、アクター/ CSPとタスクは相互にそれほど対立しませんが、相互に補完します。アクター/ CSPを使用して、タスクを分解し、コンポーネント間のインターフェイスを定義できます。そして、タスクを使用して特定のコンポーネントを実装できます。たとえば、Actorを使用する場合、ImageMixerなどのエンティティがあります。これは、スレッドプール上の画像で操作する必要があります。一般的に、ImageMixerアクターを使用してタスクベースのアプローチを使用することを妨げるものはありません。どこを見て、何をすべきか?
C ++でタスクを操作したい場合は、今後のC ++ 20の標準ライブラリに目を向けることができます。彼らはすでに.then()メソッドと、空き関数wait_all()およびwait_anyをすでに追加しています。詳細については、cppreferenceを参照してください。また、新しいasync ++ライブラリにはまだ程遠い。原則として、必要なものはすべてありますが、少しソースを変えてください。さらに古いMicrosoft PPLライブラリもあります。必要なものはすべて揃っていますが、ソースは自分のものです。Intel TBBライブラリに関する個別の追加。私の意見では、TBBのタスクグラフは既にデータフローアプローチであるため、タスクベースのアプローチに関する話では言及されていません。そして、このレポートが続けば、Intel TBBについての話は間違いなく来るでしょうが、データフローについての話の文脈でです。
もっと面白い
最近、Habréで、Anton Polukhinによる記事がありました。「C ++ 20を準備しています。実際の例を使用したコルーチンTS」。タスクベースのアプローチとC ++ 20のスタックレスコルーチンとの組み合わせについて説明しています。そして、タスクの可読性に基づいたコードは、CSPプロセスでのコードの可読性に近づくことが判明しました。だから誰かがタスクベースのアプローチに興味があるなら、この記事を読むのは理にかなっています。おわりに
さて、結果があまり多くないので、結果に移る時です。私が言いたい主なことは、現代の世界では、ある種のフレームワークを開発するか、特定の低レベルのタスクを解決する場合にのみ、裸のマルチスレッドが必要になる場合があるということです。また、アプリケーションコードを記述している場合、ベアスレッド、低レベルの同期プリミティブ、またはロックフリーコンテナに加えてロックフリーアルゴリズムを必要とすることはほとんどありません。長い間、時間をかけてテストされ、それ自体が十分に証明されたアプローチがあります。- 俳優
- 通信順次プロセス(CSP)
- タスク(非同期、約束、先物など)
- データフロー
- リアクティブプログラミング
- ...
そして最も重要なのは、C ++で使用できる既製のツールがあることです。何も循環させる必要はありません。試してみて、気に入ったら操作に移すことができます。とても簡単:試してみて、操作を開始してください。