C#からC ++にawait / asyncアナログを書くためのテクニック

通常、このような記事では、「C ++のawait / asyncのアナログ」という形式の見出しを作成し、その内容はインターネット上のどこかに投稿された別のライブラリの説明に限定されます。 しかし、この場合、このようなものは必要ありません。タイトルは記事の本質を正確に反映しています。 以下をご覧ください。



背景


この記事のすべてのコード例は、「C#vs.」という形式の「古典的な」紛争の1つでの議論のために私が発明したものです。 C ++」を1つのフォーラムで。 議論は終わったが、コードは残っていたので、なぜそれを通常の記事にしないのかと考えました。 このような歴史的な理由により、この記事ではC#とC ++のアプローチの多くの比較を行います。



問題ステートメント-非同期プログラミング



多くの場合、作業中に別のスレッドでいくつかのアクションを実行し、元の(通常はUI)ストリームで結果を処理するタスクが発生します。 これは、いわゆる非同期プログラミングの種類の1つです。 このタスクはよく知られており、ほとんどのプログラミング言語でさまざまなソリューションがあります。 たとえば、C ++では、次のようになります。

auto r=async(launch::async, [&]{return CalcSomething(params);}); DoAnother(); ProcessResult(r.get());//get - 
      
      





コールブロッキングスキームの場合。 または:

 auto r=async(launch::async, [&]{return CalcSomething(params);}); while(r.wait_for(chrono::seconds(0))!=future_status::ready) DoAnother(); ProcessResult(r.get());
      
      





尋問チャート付き。 まあ、一般的に、UIストリームの場合、最も簡単な方法は、既に動作しているサイクルを使用して通知スキームを作成することです。

 thread([=]{PostMessage(CalcSomething(params));}).detach(); ... OnDataMessage(Data d){ProcessResult(d.get<type>());}
      
      





どうやらここでは特に複雑なことはありません。 これはC ++コードですが、C#ではすべてが文字通り同じように記述され、スレッドとタスクの代わりにスレッドとタスクのみが記述されます。 ただし、最後のオプションには1つの小さなマイナスがあります。計算コードと処理コードは異なるコンテキストにあります(さらに、異なるソースファイルにあることもあります)。 より厳密なアーキテクチャには便利な場合もありますが、常に落書きを減らしたいと思います... C#の最近のバージョンでは、好奇心solution盛なソリューションが登場しました。



C#実装



C#の最近のバージョンでは、次のように簡単に記述できます。

 private async void Handler(Params prms) { var r = await Task.Run(() => CalcSomething(prms)); ProcessResult(r); }
      
      





知らない人のために、ここで一連の呼び出しがどのように行われるかを説明します。 Handler関数がUIスレッドから呼び出されるとします。 Handler関数からの戻りは、非同期CalcSomethingタスクを開始した直後に発生します。 さらに、UIスレッドと並行して実行され、その完了後、UIスレッドが現在のタスクから解放されると、2番目のスレッドから受信したデータでProcessResultを実行します。



ある種の魔法ですよね? 実際、確かにいくつかのマイナス点があります(実装の途中で削除します)が、一般的には、非同期コードを記述するために必要なものとまったく同じように見えます。 この魔法はどのように機能しますか? 実際には非常に簡単です-ここではいわゆるコルーチンが使用されます。



共同手順



簡単な手順は、複数のエントリポイントを持つコードブロックです。 それらは、非常に多くの並列タスク(たとえばサーバー実装)の場合に最もよく使用されます。この場合、そのような数のスレッドの存在はすでに完全に非効率的です。 この場合、スレッドの外観を作成することができ(協調マルチタスク)、これによりコードが大幅に簡素化されます。 共同手順を使用して、いわゆるジェネレーターを実装することもできます。 共同手順の実装は、言語に組み込まれているか、ライブラリの形式であるか、OSによって提供されることもあります(Windowsでは、共同手順はファイバーと呼ばれます)。



C#では、コプロシージャはそのような古典的な目的ではなく、奇妙な構文糖の実装に使用されていました。 ここでの実装は言語に組み込まれていますが、最高とはほど遠いものです。 これは、いわゆるスタックレス実装です。これは、必要なローカル変数とエントリポイントを保存する状態マシンです。 これにより、C#実装の欠点のほとんどが続きます。 そして、コールスタック全体に「非同期」を配置する必要性と、マシンの余分なオーバーヘッド。 ちなみに、awaitはC#でのコプロシージャの最初の出現ではありません。 収量は同じですが、より限定されています。



C ++には何がありますか? 言語自体に共同手順はありませんが、多くの異なるライブラリの実装があります。 Boostにもあり、最も効果的なオプションであるstackfullを実装しています。 これは、すべてのプロセッサおよびスタックレジスタをそれぞれ保存/復元することで機能します-基本的には実際のスレッドのように、これだけがOSに頼ることなくすべてであるため、ほとんど瞬時です。 また、Boostのすべてと同様に、さまざまなOS、コンパイラ、プロセッサで正常に動作します。



C ++では、C#よりも強力な協調手続きの実装があるため、独自のバージョンのawait / async構文シュガーを記述しないのは罪です。



C ++実装



Boost.Coroutineライブラリが提供するものを見てみましょう。 まず、コルーチンクラスのインスタンスを作成し、コンストラクターにコンストラクター関数(ファンクター、ラムダ関数)を渡す必要があります。この関数には、特別なファンクターが渡されるパラメーターが1つ(おそらく、これ以上)必要です。

 using Coro=boost::coroutines::coroutine<void()>; Coro c([](Coro::caller_type& yield){ ... yield();//  ... yield();//  ... }); ... c();//      
      
      





関数の実行は、コプロシージャのコンストラクターですぐに開始されますが、yieldファンクターの最初の呼び出しまでしか継続しません。 その後、すぐにコンストラクターから戻ります。 さらに、コプロシージャ(ファンクターでもある)をいつでも呼び出すことができ、yield呼び出し後に切断されたのと同じコンテキストで関数内で実行が継続されます。 この説明は、私たちが必要とする構文糖を実装するために必要なものと正確に一致していませんか?



これで必要なものはすべて揃いました。 テンプレートとマクロのちょっとした魔法を適用することは残っています(これはC#オプションとまったく同じように見えるためです)。

 using __Coro=boost::coroutines::coroutine<void()>; void Post2UI(const void* coro); template<typename L> auto __await_async(const __Coro* coro, __Coro::caller_type& yield, L lambda)->decltype(lambda()) { auto f=async(launch::async, [=](){ auto r=lambda(); Post2UI(coro); return r; }); yield(); return f.get(); } void CallFromUI(void* c) { __Coro* coro=static_cast<__Coro*>(c); (*coro)(); if(!*coro) delete coro; } #define async_code(block) { __Coro* __coro=new __Coro; *__coro=__Coro([=](__Coro::caller_type& __yield){block});} #define await_async(l) __await_async(__coro, __yield, l)
      
      





実装全体では、悲惨な20行の単純なコードが必要です。 もちろん、それらを別のhppファイルに入れてライブラリのような名前にすることもできますが、それはばかげています。 確かに、さらに2、3行を定義する必要がありますが、これはすでにGUIフレームワーク(またはネイティブAPI全般)の選択に依存しています。 次のようなもの:

 void Post2UI(const void* coro) {PostMessage(coro);} void OnAsync(Event& event) {CallFromUI(event.Get<void*>());}
      
      





しかし、これはほんの数行です。1つはアプリケーション全体に、1つは同じフレームワーク上のすべてのアプリケーションに同じです。 その後、次のコードを簡単に記述できます。

 void Handler(Params params) async_code ( auto r = await_async([&]{return CalcSomething(params);}); ProcessResult(r); )
      
      





計算のシーケンスは、C#バージョンとまったく同じです。 また、C#のように、関数シグネチャを変更する(呼び出しスタック全体に非同期を追加する)必要もありませんでした。 さらに、ここでは、関数に対して1つの非同期タスクを実行することに限定されません。 並列実行のために複数の非同期ブロックを一度に実行することも、ループ内を歩くこともできます。 たとえば、次のコード:

 void Handler(const list<string>& urls) { for(auto url: urls) async_code ( result+=await_async([&]{return CheckServer(url);}); ) }
      
      





リスト内の各要素に対してCheckServerの並列実行を開始し、すべての結果を結果変数に収集します。 さらに、同期、ロックなどが必要ないことは明らかです。 コード結果+ = ...はUIスレッドでのみ実行されます。 C#では、これも当然問題なく記述されていますが、ループで呼び出す別の関数を作成する必要があります。



テスト中



実装のサイズとシンプルさにもかかわらず、テストを行って正しく機能することを確認しています。 これを行うには、お気に入りのGUIフレームワークで、1つの入力フィールド(複数行)と1つのボタンからなる最も単純なテストアプリケーションを記述するのが最適です。 次に、テストは次のように一般化されます(不要な詳細は削除されます)。

 class MyWindow: public Window { void TestAsync(int n) async_code ( output<<L"    "<<this_thread::get_id()<<'\n'; auto r=await_async([&]{ this_thread::sleep_for(chrono::seconds(1)); wostringstream res; res<<L"    "<<this_thread::get_id()<<L"   "<<n; return res.str(); }); output<<L"    "<<this_thread::get_id()<<L": "<<r<<'\n'; ) void OnButtonClick(Event&) { TestAsync(12345); TestAsync(67890); output<<L" MessageBox   "<<this_thread::get_id()<<'\n'; MessageBox(L"!"); output<<L"MessageBox    "<<this_thread::get_id()<<'\n'; } Editbox output; }; class MyApp : public App { virtual bool OnInit() { SetTopWindow(new MyWindow); return true; } void OnAsync(Event& event) { CallFromUI(event.Get<void*>()); } }; void Post2UI(const void* coro) { GetApp().PostMessage(ID_ASYNC, coro); }
      
      





MessageBoxは、モーダルウィンドウでの動作確認を表します。 得られた結果:

スレッド1から非同期で実行する

スレッド1から非同期で実行する

ストリーム1からMessageBoxを表示

結果をストリーム1に表示します:データ12345のストリーム2での作業が完了しました

結果をストリーム1に示します:データ67890のストリーム3の作業が完了しました

MessageBoxはスレッド1で閉じられています




まとめ



ライブラリについての記事の冒頭で述べたことを説明する必要はないと思う。 最新のツール(C ++ 11、Boost)を備えているため、C ++プログラマーであれば誰でも数十行のコードでC#から本格的なawait / async実装を作成できます。 さらに、この実装は、より柔軟(関数ごとに非同期ブロックがいくつか)、より便利(呼び出しスタックに沿って非同期を掛ける必要がない)、はるかに効率的です(オーバーヘッドの観点から)。



文学



1. en.cppreference.com/w/cpp/thread-標準ライブラリでのマルチスレッドサポート。

2. www.boost.org/doc/libs/1_54_0/libs/coroutine/doc/html/index.html-Boostでの共同手順の実装。



追加1



コメントは、C#からのawait / asyncが異なるスレッドだけでなく、1つのフレームワーク内でも機能することを正しく指摘しました。 まあ、実際にはこれは明らかにあるべき方法です、なぜなら これは、共同手順の最適な実装ではありませんが、元々はそのような作業のために作成されたものです。 そして、Boost'a手続きの助けを借りて、これが非常に簡単に実装されるのは当然です。 問題の元の声明の一部ではなかったという理由だけでそのようなコードを表示しませんでした(記事の冒頭を参照)。そのようなコードにはあまり意味がありません。 しかし、彼らは一般性に関するコメントにも興味を持っていたので(そのような解決策がawait / asyncを完全に置き換えることを示すため)、ここでそれを示します。



したがって、実装(20行)にさらにいくつか追加します。

 template<typename L> auto __await(const __Coro* coro, __Coro::caller_type& yield, L lambda)->decltype(lambda()) { Post2UI(coro); yield(); return lambda(); } #define await(l) __await_async(__coro, __yield, l)
      
      





その後、次のように記述できます(コードは以前とまったく同じで、await_asyncをawaitに置き換えただけです)。

 void Handler(Params params) async_code ( auto r = await([&]{return CalcSomething(params);}); ProcessResult(r); )
      
      





ここでも、待機の直後にハンドラーから制御が戻りますが、残りのコードは、ハンドラーと同じUIスレッドで実行され、しばらくしてから解放されます。 当然、ここでは追加のフローは作成されません。



All Articles