どのように.NETで制御フローを実装して待機するのですか?


105

yieldキーワードを理解しているように、イテレーターブロック内から使用すると、制御のフローが呼び出し元のコードに戻り、イテレーターが再度呼び出されると、中断したところから再開されます。

また、await呼び出し先を待つだけでなく、呼び出し元に制御を戻し、呼び出し元が中断したところから再開するawaitsがメソッドをたます。

つまり、スレッドはありません。、非同期との「並行性」は、巧妙な制御の流れによって引き起こされる幻想であり、その詳細は構文によって隠されています。

現在、私は以前のアセンブリプログラマであり、命令ポインタやスタックなどに精通しており、通常の制御フロー(サブルーチン、再帰、ループ、分岐)がどのように機能するかを理解しています。しかし、これらの新しい構成要素-私はそれらを取得しません。

await到達すると、ランタイムはどのコードが次に実行する必要があるかをどのようにして知るのでしょうか?いつ中断したところから再開できるか、どのようにしてそれをどこで覚えているか?現在のコールスタックはどうなりますか?どういうわけか保存されますか?呼び出し元のメソッドがそれより前に他のメソッド呼び出しを行うとawaitどうなりますか?スタックが上書きされないのはなぜですか?そして、例外が発生してスタックが解放された場合、ランタイムはどのようにしてこれらすべてを処理しますか?

yield到達したとき、ランタイムは、物事をピックアップする必要があるポイントをどのように追跡しますか?イテレータの状態はどのように保存されますか?


4
TryRoslynオンラインコンパイラで生成されたコードを見ることができます
xanatos

1
Jon SkeetによるEduasyncの記事シリーズを確認してください。
レオニードヴァシレフ2017

回答:


115

以下の具体的な質問にお答えしますが、私たちがどのように歩留まりと待機を設計したかについての私の広範な記事を読んだほうがいいでしょう。

https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/

https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/

https://blogs.msdn.microsoft.com/ericlippert/tag/async/

これらの記事の一部は現在古くなっています。生成されるコードは多くの点で異なります。しかし、これらは確かにそれがどのように機能するかという考えをあなたに与えるでしょう。

また、ラムダがクロージャクラスとして生成される方法を理解していない場合は、まずそれを理解してください。ラムダを下げない場合、非同期の表と裏を作成することはできません。

待機時間に達すると、ランタイムはどのコードを次に実行する必要があるかをどのようにして知るのでしょうか。

await 次のように生成されます:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

それは基本的にそれです。Awaitは単なるファンシーなリターンです。

いつ中断したところから再開できるか、どのようにしてそれをどこで覚えているか?

さて、どうやってそれをせずに待たか?メソッドfooがメソッドbarを呼び出すとき、fooのアクティブ化のすべてのローカルが何のバーに関係なく、fooの中央に戻る方法を覚えています。

あなたはそれがアセンブラでどのように行われるか知っています。fooのアクティベーションレコードがスタックにプッシュされます。ローカルの値が含まれています。呼び出しの時点で、fooの戻りアドレスがスタックにプッシュされます。barが完了すると、スタックポインターと命令ポインターは必要な場所にリセットされ、fooは中断したところから続行します。

待機の継続はまったく同じですが、アクティブ化のシーケンスがスタックを形成しないという明白な理由でレコードがヒープに書き込まれます

タスクの継続として待機するデリゲートには、(1)次に実行する必要のある命令ポインターを与えるルックアップテーブルへの入力である数値、および(2)ローカルと一時のすべての値が含まれます。

そこにはいくつかの追加のギアがあります。たとえば、.NETでは、tryブロックの途中に分岐することはできません。そのため、tryブロック内のコードのアドレスを単にテーブルに貼り付けることはできません。しかし、これらは簿記の詳細です。概念的には、アクティブ化レコードは単にヒープに移動されます。

現在のコールスタックはどうなりますか?どういうわけか保存されますか?

現在のアクティベーションレコードの関連情報がスタックに最初に置かれることはありません。get-goからヒープ外に割り当てられます。(まあ、仮パラメーターは通常スタックまたはレジスターに渡され、メソッドの開始時にヒープ位置にコピーされます。)

呼び出し元のアクティベーションレコードは保存されません。待ちはおそらく彼らに戻るだろう、覚えておいてください、そうすれば彼らは普通に扱われるでしょう。

これは、awaitの単純化された継続渡しスタイルと、Schemeなどの言語で見られる真のcall-with-current-continuation構造との間の密接な違いであることに注意してください。これらの言語では、呼び出し元への継続を含む継続全体がcall-ccによってキャプチャされます

呼び出しメソッドが待機する前に他のメソッド呼び出しを行うとどうなりますか?スタックが上書きされないのはなぜですか?

これらのメソッド呼び出しが返されるため、待機の時点でそれらのアクティブ化レコードはスタックに存在しなくなります。

そして、例外が発生してスタックが解放された場合、ランタイムはどのようにしてこれらすべてを処理しますか?

キャッチされない例外が発生した場合、例外はキャッチされ、タスク内に格納され、タスクの結果がフェッチされると再スローされます。

前に述べた簿記を覚えていますか?例外のセマンティクスを正しく取得するのは大変な苦労でした。

収量に達した場合、ランタイムは、物事をピックアップする必要があるポイントをどのように追跡しますか?イテレータの状態はどのように保存されますか?

同じ方法。ローカルの状態はヒープに移されMoveNext、次に呼び出されたときに再開する必要がある命令を表す番号がローカルとともに格納されます。

繰り返しになりますが、例外が正しく処理されることを確認するために、イテレーターブロックにはたくさんのギアがあります。


1
作成者の背景(アセンブラーなど)の質問のために、これらの構成要素の両方が管理されたメモリなしでは不可能であることを言及する価値があるかもしれません。マネージメモリがなければ、クロージャの有効期間を調整しようとしないと、間違いなくブートストラップが作動します。
ジム

すべてのページのリンクが見つかりません(404)
Digital3D

すべての記事は現在利用できません。それらを再投稿してもらえますか?
のMichałTurczyn

1
@MichałTurczyn:彼らはまだインターネット上にいます。マイクロソフトはブログアーカイブの場所を移動し続けています。私はそれらをすべて個人サイトに徐々に移行し、時間があるときにこれらのリンクを更新してみます。
Eric Lippert

38

yield 2つの方が簡単なので、調べてみましょう。

私たちが持っているとしましょう:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

これは、次のように少しコンパイルされます。

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

手書きの実装ように、効率的でないIEnumerable<int>IEnumerator<int>(例えば、我々はおそらく別のを持っ無駄にしないだろう_state_i_current安全は新しいを作成するよりも、そうではなく行うにはこの場合には)のトリック(ただし、悪くない、それ自体を再利用オブジェクトは良いです)、そして非常に複雑なyield-usingメソッドを処理するために拡張可能です。

そしてもちろん

foreach(var a in b)
{
  DoSomething(a);
}

と同じです:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

次に、生成されたものMoveNext()が繰り返し呼び出されます。

asyncケースはかなり同じ原理であるが、余分な複雑さのビットを持ちます。次のような別の回答コードの例を再利用するには:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

次のようなコードを生成します。

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

より複雑ですが、非常によく似た基本原理です。主な追加の複雑さは、現在GetAwaiter()使用されていることです。任意の時間が場合はawaiter.IsCompletedチェックされ、それを返しtrue、タスクので、awaitEDはすでにこの方法は州内を移動し続ける(それは同期返すことができます例えば例)を完了されますが、そうでない場合はawaiterへのコールバックとしての地位を設定します。

コールバックをトリガーするもの(非同期I / O完了、スレッドで実行中のタスクの完了など)と、特定のスレッドへのマーシャリングまたはスレッドプールスレッドでの実行に必要な要件の点で、それがどうなるかは、待機者に依存します。 、元の呼び出しのどのコンテキストが必要であるか、必要でないかなど。それが何であれ、そのウェイターがを呼び出しMoveNext、次の作業(次のまでawait)を続行するか、終了して戻ります。その場合、Task実装しているが完了します。


時間をかけて自分の翻訳をロールバックしましたか?O_O uao。
CoffeDeveloper 2017

4
@DarioOO最初にかなり迅速にできること。多くの翻訳yieldを手作業で行うことで、そうすることのメリットがある場合(通常は最適化としてですが、開始点がコンパイラによって生成されたものに近いことを確認したい)したがって、悪い仮定によって最適化が解除されることはありません)。2番目の方法は最初に別の回答で使用されましたが、当時は自分の知識にいくつかのギャップがありました。
ジョンハンナ

13

ここにはすでにすばらしい答えがたくさんあります。メンタルモデルを形成するのに役立ついくつかの視点を共有します。

まず、asyncメソッドはコンパイラーによっていくつかの部分に分割されます。await式は骨折ポイントです。(これは、単純なメソッドの場合は簡単に想像できます。ループと例外処理を備えたより複雑なメソッドも、より複雑なステートマシンを追加することで分割されます)。

第二awaitに、かなり単純なシーケンスに変換されます。私はLucianの説明が好きです。つまり、「待機がすでに完了している場合は、結果を取得してこのメ​​ソッドの実行を継続します。それ以外の場合は、このメソッドの状態を保存して戻ります」。(私は私のasyncイントロで非常に類似した用語を使用しています)。

待機時間に達すると、ランタイムはどのコードを次に実行する必要があるかをどのようにして知るのでしょうか。

メソッドの残りの部分は、待機可能なコールバックとして存在します(タスクの場合、これらのコールバックは継続です)。awaitableが完了すると、コールバックが呼び出されます。

呼び出しスタックは保存および復元されないことに注意してください。コールバックは直接呼び出されます。オーバーラップしたI / Oの場合、それらはスレッドプールから直接呼び出されます。

これらのコールバックは、メソッドを直接実行し続けるか、別の場所で実行するようにスケジュールすることができます(たとえば、awaitキャプチャされたUI SynchronizationContextとI / Oがスレッドプールで完了した場合)。

いつ中断したところから再開できるか、どのようにしてそれをどこで覚えているか?

それはすべてただのコールバックです。awaitableが完了すると、コールバックと、asyncすでに持っていたメソッドが呼び出されます。awaitが再開されます。コールバックはそのメソッドの中央にジャンプし、スコープ内にローカル変数があります。

コールバックは特定のスレッドで実行され、コールスタックは復元されませ

現在のコールスタックはどうなりますか、どういうわけか保存されますか?呼び出しメソッドが待機する前に他のメソッド呼び出しを行うとどうなりますか?スタックが上書きされないのはなぜですか?そして、例外が発生してスタックが解放された場合、ランタイムはどのようにしてこれらすべてを処理しますか?

コールスタックはそもそも保存されません。必要ありません。

同期コードを使用すると、すべての呼び出し元を含む呼び出しスタックができ、ランタイムはそれを使用してどこに戻るかを認識します。

非同期コードを使用すると、一連のコールバックポインターが発生する可能性があります-タスクを終了するI / O操作をルートとし、タスクasyncを終了するasyncメソッドを再開でき、タスクを終了するメソッドを再開できます。

だから、同期コードをA呼び出しB、呼び出しC、あなたのコールスタックは次のようになります。

A:B:C

一方、非同期コードはコールバック(ポインター)を使用します。

A <- B <- C <- (I/O operation)

収量に達した場合、ランタイムは、物事をピックアップする必要があるポイントをどのように追跡しますか?イテレータの状態はどのように保存されますか?

現在、かなり非効率的です。:)

他のラムダと同じように機能します-変数の有効期間が延長され、スタック上に存在する状態オブジェクトに参照が配置されます。深いレベルの詳細すべてに最適なリソースは、Jon SkeetのEduAsyncシリーズです。


7

yieldawait、両方がフロー制御を処理している間、2つの完全に異なるものです。そのため、個別に取り組みます。

の目標は、yield遅延シーケンスの構築を容易にすることです。yieldステートメントを含む列挙子ループを作成すると、コンパイラは目に見えない大量の新しいコードを生成します。実際には、まったく新しいクラスが生成されます。このクラスには、ループの状態を追跡するメンバーと、IEnumerableの実装が含まれているので、呼び出すたびにMoveNext、そのループをもう一度ステップ実行できます。したがって、次のようなforeachループを実行すると、

foreach(var item in mything.items()) {
    dosomething(item);
}

生成されたコードは次のようになります。

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

mything.items()の実装の内部には、ループの1つの「ステップ」を実行して戻るステートマシンコードの束があります。そのため、単純なループのようにソースで記述しますが、内部では単純なループではありません。だからコンパイラの策略。自分自身を見たい場合は、ILDASMまたはILSpyまたは同様のツールを引き出して、生成されたILがどのように見えるかを確認してください。それは有益であるべきです。

asyncそしてawait、他方では、魚の全体の他のやかんです。Awaitは、抽象的には同期プリミティブです。これは、システムに「これが完了するまで続行できない」と伝える方法です。ただし、お気付きのように、常にスレッドが関与しているわけではありません。

されて関与することは、同期コンテキストと呼ばれるものです。いつもぶらぶらしているものがあります。彼らの同期コンテキストの仕事は、待機中のタスクとその継続をスケジュールすることです。

と言うawait thisThing()と、いくつかのことが起こります。非同期メソッドでは、コンパイラーは実際にはメソッドを小さなチャンクに分割します。各チャンクは「待機前」セクションと「待機後」(または継続)セクションです。awaitが実行されると、待機中のタスク次の継続(つまり、残りの関数)が同期コンテキストに渡されます。コンテキストはタスクのスケジュールを処理し、終了すると、コンテキストを実行して、必要な戻り値を渡します。

同期コンテキストは、何かをスケジュールする限り、何でも自由に実行できます。スレッドプールを使用できます。タスクごとにスレッドを作成できます。それらを同期的に実行できます。さまざまな環境(ASP.NETとWPF)では、環境に最適なものに基づいてさまざまなことを実行するさまざまな同期コンテキスト実装が提供されます。

(ボーナス:何.ConfigurateAwait(false)が起こるのか疑問に思ったことはありませんか?これは、現在の同期コンテキスト(通常はプロジェクトタイプに基づく-WPFとASP.NETなど)を使用せず、代わりにスレッドプールを使用するデフォルトのコンテキストを使用するようにシステムに指示しています。

繰り返しになりますが、これは多くのコンパイラーの手口です。生成されたコードを見ると複雑ですが、それが何をしているかを確認できるはずです。これらの種類の変換は困難ですが、確定的で数学的であるため、コンパイラーが変換を実行するのは素晴らしいことです。

PSデフォルトの同期コンテキストの存在には1つの例外があります-コンソールアプリにはデフォルトの同期コンテキストがありません。詳細については、Stephen Toubのブログを参照してください。これは、上の情報を探すには絶好の場所だasyncawait一般に呼ばれることがあります。


1
「デフォルトの同期コンテキストを使用せず、代わりにスレッドプールを使用するデフォルトのコンテキストを使用するようにシステムに指示しています」これで何を意味するのかを明確にできますか?「デフォルトを使用せず、デフォルトを使用する」
Kroltan

3
申し訳ありませんが、用語を混同しています。投稿を修正します。基本的には、現在の環境にデフォルトのものを使用せず、.NETにデフォルトのもの(つまり、スレッドプール)を使用します。
Chris Tavares

非常にシンプルで、理解できました。私の投票を獲得しました:)
Ehsan Sajjad

4

通常、私はCILを見ることをお勧めしますが、これらの場合、それは混乱です。

これらの2つの言語構造は動作は似ていますが、実装方法が少し異なります。基本的に、これはコンパイラーマジックの単なる構文上の砂糖であり、アセンブリレベルでクレイジー/安全ではありません。それらを簡単に見てみましょう。

yield古くて単純なステートメントであり、基本的なステートマシンの構文上の砂糖です。メソッドがを返すIEnumerable<T>か、をIEnumerator<T>含む場合がありますyield。これにより、メソッドがステートマシンファクトリに変換されます。注意すべきことの1つは、yield内部にメソッドが存在する場合、呼び出した時点ではメソッドのコードが実行されないことです。その理由は、記述したコードがIEnumerator<T>.MoveNextメソッドに転置され、メソッドが現在の状態をチェックしてコードの正しい部分を実行するためです。yield return x;次に、に似たものに変換されますthis.Current = x; return true;

何らかのリフレクションを行うと、構築されたステートマシンとそのフィールド(少なくとも1つは州とローカル)を簡単に検査できます。フィールドを変更すると、リセットすることもできます。

awaitタイプライブラリからのサポートが少し必要で、動作が少し異なります。TaskまたはTask<T>引数を取り、タスクが完了した場合はその値になるか、を介して継続を登録しTask.GetAwaiter().OnCompletedます。async/ awaitシステムの完全な実装は説明に時間がかかりすぎますが、それも神秘的ではありません。また、ステートマシンを作成し、継続に沿ってOnCompletedに渡します。タスクが完了した場合、その結果を継続に使用します。awaiterの実装は、継続を呼び出す方法を決定します。通常は、呼び出しスレッドの同期コンテキストを使用します。

両方とも yieldawaitは、その出現に基づいてメソッドを分割し、ステートマシンを形成する必要があります。マシンの各ブランチはメソッドの各部分を表します。

スタックやスレッドなどの「下位レベル」の用語でこれらの概念について考えるべきではありません。これらは抽象化であり、それらの内部の仕組みはCLRからのサポートを必要とせず、マジックを実行するのはコンパイラーだけです。これは、ランタイムがサポートされているLuaのコルーチンや、単なる黒魔術であるCのlongjmpとは大きく異なります。


5
補足タスクawaitを実行する必要はありません。何でもかまいません。必要のない方法と少し似ていますが、で十分です。INotifyCompletion GetAwaiter()foreachIEnumerableIEnumerator GetEnumerator()
IllidanS4がモニカに2017
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.