非同期プロセスにスタック構造が使用されていますか?


10

この質問には、スタックの使用目的を説明するEric Lippertによる優れた回答があります。何年もの間、一般的に言えば、スタックとは何か、どのように使用されるかを知っていましたが、彼の回答の一部は、非同期プログラミングが標準となっている今日、このスタック構造があまり使用されていないのか疑問に思います。

彼の答えから:

スタックは、コルーチンのない言語での継続の具体化の一部です。

具体的には、これのコルーチンなし部分は私に不思議に思っています。

彼はここでもう少し説明します:

コルーチンは、それらがどこにあったかを記憶し、しばらくの間別のコルーチンに制御を委譲し、後で中断したところから再開することができますが、必ずしも呼び出されたコルーチンの直後ではありません。C#での "yield return"または "await"を考えてください。C#では、次のアイテムが要求されたとき、または非同期操作が完了したときの場所を覚えておく必要があります。コルーチンまたは同様の言語機能を持つ言語では、継続を実装するために、スタックよりも高度なデータ構造が必要です。

これはスタックに関しては優れていますが、スタックが単純すぎてより高度なデータ構造を必要とするこれらの言語機能を処理できない場合、どの構造を使用するかについて未回答の質問が残りますか?

技術が進歩するにつれてスタックはなくなりますか?それを置き換えるものは何ですか?ハイブリッドタイプのものですか?(たとえば、私の.NETプログラムは、非同期呼び出しにヒットするまでスタックを使用し、完了するまで他の構造に切り替えます。その時点で、スタックは、次の項目を確認できる状態に戻されますか? )

これらのシナリオはスタックに対して高度すぎると言うことは完全に理にかなっていますが、スタックを置き換えるものは何ですか?私がこの数年前に知ったとき、スタックは非常に高速で軽量であり、アプリケーションからヒープから離れて割り当てられたメモリの一部であり、手元のタスクの効率的な管理をサポートしていたので(スタックは意図されていましたか?)。何が変わったの?

回答:


14

ハイブリッドタイプのものですか?(たとえば、私の.NETプログラムは、非同期呼び出しにヒットするまでスタックを使用しますが、完了するまで他の構造に切り替えます。その時点で、スタックは次の項目を確認できる状態に戻されますか? )

基本的にはい。

私たちが持っていると仮定します

async void MyButton_OnClick() { await Foo(); Bar(); }
async Task Foo() { await Task.Delay(123); Blah(); }

これは、継続が具体化される方法の非常に簡略化された説明です。実際のコードはかなり複雑ですが、これはアイデアを全体に広めます。

ボタンをクリックします。メッセージがキューに入っています。メッセージループはメッセージを処理し、クリックハンドラーを呼び出して、メッセージキューの戻りアドレスをスタックに配置します。つまり、ハンドラーが完了したに発生するのは、メッセージループが実行を継続する必要があるということです。したがって、ハンドラの続きはループです。

クリックハンドラーはFoo()を呼び出し、自身の戻りアドレスをスタックに格納します。つまり、Fooの続きはクリックハンドラーの残りの部分です。

FooはTask.Delayを呼び出し、自分自身の戻りアドレスをスタックに入れます。

Task.Delayは、すぐにタスクを返すために必要な魔法を実行します。スタックがポップされ、Fooに戻ります。

Fooは返されたタスクをチェックして、完了したかどうかを確認します。そうではない。待機継続はBlah()を呼び出すことなので、FooはBlah()を呼び出すデリゲートを作成し、タスクの継続としてデリゲートする署名をします。(私はちょっと誤った説明をしましたが、あなたはそれをキャッチしましたか?もしそうでなければ、すぐにそれを明らかにします。)

次に、Fooは独自のTaskオブジェクトを作成し、それを未完成としてマークし、スタックの上にあるクリックハンドラーに返します。

クリックハンドラーはFooのタスクを調べ、それが不完全であることを発見します。ハンドラーでのawaitの継続はBar()の呼び出しであるため、クリックハンドラーは、Bar()を呼び出すデリゲートを作成し、Foo()によって返されるタスクの継続として設定します。次に、スタックをメッセージループに戻します。

メッセージループはメッセージを処理し続けます。最終的に、遅延タスクによって作成されたタイマーマジックが機能し、遅延タスクの継続を実行できるというメッセージをキューに投稿します。そのため、メッセージループはタスクの継続を呼び出し、通常どおりスタックにスタックします。そのデリゲートはBlah()を呼び出します。Blah()はそれを実行し、スタックを返します。

今、何が起こりますか?ここでトリッキーなビットです。遅延タスクの継続は、Blah()を呼び出すだけではありません。 また、Bar()の呼び出しをトリガーする必要がありますが、そのタスクはBarについて知りません!

Foo 、(1)Blah()を呼び出すデリゲートを実際に作成し、(2)Fooが作成し、イベントハンドラーに返すタスクの継続を呼び出します。これが、Bar()を呼び出すデリゲートを呼び出す方法です。

これで、必要なすべてのことを正しい順序で実行できました。ただし、メッセージループ内のメッセージの処理を長時間停止することはなかったため、アプリケーションは応答性を維持しました。

これらのシナリオはスタックに対して高度すぎると言うことは完全に理にかなっていますが、スタックを置き換えるものは何ですか?

デリゲートのクロージャクラスを介した相互参照を含むタスクオブジェクトのグラフ。これらのクロージャクラスは、最後に実行されたawaitの位置とローカルの値を追跡する状態マシンです。さらに、この例では、オペレーティングシステムによって実装されたアクションのグローバル状態キューと、それらのアクションを実行するメッセージループ。

演習:メッセージループのない世界でこれがすべてうまくいくとどう思いますか?たとえば、コンソールアプリケーション。コンソールアプリで待つのはかなり異なります。これまでに知っていることから、それがどのように機能するかを推測できますか?

私がこの数年前に知ったとき、スタックは非常に高速で軽量であり、アプリケーションからヒープから離れて割り当てられたメモリの一部であり、手元のタスクの効率的な管理をサポートしていたので(スタックは意図されていましたか?)。何が変わったの?

スタックは、メソッドのアクティブ化の有効期間がスタックを形成する場合に便利なデータ構造ですが、私の例では、クリックハンドラーのアクティブ化Foo、Bar、Blahはスタックを形成しません。したがって、そのワークフローを表すデータ構造をスタックにすることはできません。むしろ、ワークフローを表す、ヒープに割り当てられたタスクとデリゲートのグラフです。待機とは、ワークフローの中で、先に開始された作業が完了するまでワークフローをさらに進めることができないポイントです。待機中に、特定の開始済みタスクが完了したどうかに依存しない他の作業を実行できます。

スタックは単なるフレームの配列であり、フレームには、(1)関数の中央(呼び出しが発生した場所)へのポインター、および(2)ローカル変数と一時の値が含まれます。タスクの継続は同じです。デリゲートは関数へのポインタであり、関数の途中(待機が発生した場所)の特定のポイントを参照する状態にあり、クロージャには各ローカル変数または一時変数のフィールドがあります。フレームはもうすてきなきちんとした配列を形成しませんが、すべての情報は同じです。


1
とても助かります、ありがとう。両方の回答を承認済みとしてマークできたとしても、私はそれらを空白にしておくことができないので(ただし、返信する時間を高く評価されなかったと誰にも思われたくない)
jleach

3
@ jdl134679:質問への回答があったと感じた場合は、回答としてマークすることをお勧めします。これは、回答を書くのではなく読みたい場合、人々がここに来るべきであるというシグナルを送信します。(もちろん、良い答えを書くことは常に推奨されます。)誰がチェックマークを取得するかは気にしません。
Eric Lippert 2017年

8

Appelが書いた古い紙のガベージコレクションは、スタックの割り当てよりも高速です。読むにはまた、彼の継続をしてコンパイルブックとガベージコレクションハンドブック。一部のGCテクニックは(直感的には)非常に効率的です。継続渡しスタイルはカノニカル定義プログラム全体の変換(CPSの変換を(概念的ヒープ割り当てとコール・フレームを交換スタックを取り除くために)クロージャ個々の「値」又は「オブジェクト」などの個々のコールフレームを「reifying」言い換えれば、 )。

しかし、コールスタックは依然として非常に広く使用されており、現在のプロセッサはコールスタック専用の専用ハードウェア(スタックレジスタ、キャッシュ機構など)を備えています(これは、ほとんどの低レベルのプログラミング言語(特にC)は、コールスタックで実装します)。スタックがあることに注意してくださいまた、キャッシュフレンドリー(およびそれが重要多くのパフォーマンスのため)。

実際には、コールスタックはまだここにあります。しかし、今ではそれらの多くがあり、コールスタックは多くの小さなセグメント(たとえば、それぞれ4Kバイトの数ページ)に分割され、ガベージコレクションまたはヒープ割り当てが行われることがあります。これらのスタックセグメントは、リンクされたリスト(または必要に応じて、より複雑なデータ構造)に編成できます。たとえば、GCCコンパイラ-fsplit-stackオプションあります(特にGoとその「ゴルーチン」と「非同期プロセス」に役立ちます)。スプリットスタックを使用すると、数百万の小さなスタックセグメントで構成される何千ものスタック(およびコルーチンの実装が容易になります)を使用でき、スタックの「巻き戻し」はより高速(または少なくともシングルチャンクとほぼ同じ速さ)になります。スタック)。

(言い換えれば、スタックとヒープの違いはぼやけていますが、プログラム全体の変換が必要になるか、呼び出し規約とコンパイラに互換性がないように変更する必要があります)

これそのこと、およびCPS変換について説明している多くの論文この例など)も参照してください。ASLRcall / ccについてもお読みください。継続についてもっと読む(&STFW)。

.CLRおよび.NETの実装には、多くの実用的な理由により、最先端のGCおよびCPS変換がない場合があります。これは、プログラム全体の変換に関連するトレードオフです(低レベルのCルーチンを使いやすく、CまたはC ++でランタイムをコーディングする必要があります)。

Chicken Schemeは、CPS変換を使用して従来とは異なる方法でマシン(またはC)スタックを使用しています。割り当てはすべてスタックで行われ、それが大きくなりすぎると、世代別のGCのコピーと転送の手順により、最近割り当てられたスタックの値が移動します(おそらく現在の継続)をヒープに追加すると、スタックは大幅に削減されsetjmpます。


SICPプログラミング言語の語用論ドラゴンブックLisp In Small Piecesもお読みください。


1
とても助かります、ありがとう。両方の回答を承認済みとしてマークできたとしても、私はそれらを空白にしておくことができないので(ただし、返信する時間を高く評価されなかったと誰にも思われたくない)
jleach
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.