web-dev-qa-db-ja.com

yieldおよびawaitは、.NETで制御フローをどのように実装しますか?

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

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

言い換えれば、-- スレッドはありません であり、asyncとawaitの「並行性」は、巧妙な制御フローによって引き起こされる幻想であり、その詳細は構文によって隠されています。

今、私は元アセンブリプログラマーであり、命令ポインター、スタックなどに精通しており、通常の制御フロー(サブルーチン、再帰、ループ、分岐)の仕組みを理解しています。しかし、これらの新しいコンストラクト-私はそれらを取得しません。

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

yieldに達すると、ランタイムはどのように物を拾うべきかを追跡しますか?イテレータの状態はどのように保存されますか?

102
John Wu

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

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/

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

また、ラムダがクロージャークラスとしてどのように生成されるか理解していない場合は、firstであることを理解してください。ラムダがダウンしていない場合、非同期の先頭または末尾を作成しません。

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

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

それは基本的にそれです。待つことは、ただの空想的な帰りです。

中断した場所からいつ再開できるかをどのように認識し、どこで記憶するのですか?

さて、どのようにwithout待ちますか?メソッドfooがメソッドbarを呼び出すとき、どうにかしてfooの真ん中に戻る方法を覚えています。barが何をしても、fooのアクティブ化のすべてのローカルはそのままです。

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

待機の継続はまったく同じです。ただし、アクティベーションのシーケンスがスタックを形成しないという明らかな理由でレコードがヒープに置かれます

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

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

現在の呼び出しスタックはどうなりますか、どういうわけか保存されますか?

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

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

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

呼び出し元のメソッドが待機する前に他のメソッドを呼び出した場合、どうしてスタックが上書きされないのでしょうか?

これらのメソッド呼び出しは戻るため、それらのアクティベーションレコードは待機の時点でスタック上にありません。

そして、例外とスタックのアンワインドの場合に、ランタイムはこのすべてをどのように処理しますか?

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

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

Yieldに達した場合、ランタイムはどのように物事を拾うべきかを追跡しますか?イテレータの状態はどのように保存されますか?

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

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

109
Eric Lippert

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

私たちが持っていると言う:

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

これは、次のようにbitコンパイルされます:

_// 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--メソッドを扱うために拡張可能です。

そしてもちろん

_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_がチェックされると、タスクtrueedが既に完了しているため(たとえば、同期的に戻ることができる場合)、awaitが返されます。その後、メソッドは状態を移動し続けます。ウェイターへのコールバックとして。

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

36
Jon Hanna

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

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

次に、awaitはかなり単純なシーケンスに変換されます。私は Lucianの説明 が好きで、言葉で言うと、「待機可能がすでに完了している場合、結果を取得してこのメ​​ソッドの実行を続けます。そうでなければ、このメソッドの状態を保存して戻ります」。 ( async intro で非常によく似た用語を使用しています)。

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

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

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

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

中断した場所からいつ再開できるかをどのように認識し、どこで記憶するのですか?

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

コールバックは、特定のスレッドを実行するnotであり、nothaveコールスタックが復元されました。

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

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

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

非同期コードを使用すると、タスクを終了するI/O操作に根ざし、タスクを終了するasyncメソッドを再開し、asyncそのタスクなどを終了するメソッド.

したがって、同期コードA呼び出しB呼び出しCでは、呼び出しスタックは次のようになります。

A:B:C

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

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

Yieldに達した場合、ランタイムはどのように物事を拾うべきかを追跡しますか?イテレータの状態はどのように保存されますか?

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

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

12
Stephen Cleary

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()の実装内には、ループの「ステップ」を実行して戻る一連のステートマシンコードがあります。したがって、単純なループのようにソースに記述している間は、内部では単純なループではありません。コンパイラの策略。自分自身を見たい場合は、ILDASMまたはILSpyまたは同様のツールを引き出して、生成されたILがどのように見えるかを確認してください。有益なはずです。

一方、asyncawaitは、まったく別の魚のやかんです。 Awaitは、抽象的には同期プリミティブです。これは、「これが完了するまで続行できません」とシステムに伝える方法です。しかし、あなたが指摘したように、常にスレッドが関与しているわけではありません。

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

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

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

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

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

追伸デフォルトの同期コンテキストの存在には1つの例外があります-コンソールアプリにはデフォルトの同期コンテキストがありません。詳細については Stephen Toubのブログ を参照してください。一般的にasyncawaitに関する情報を探すのに最適な場所です。

7
Chris Tavares

通常、私は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に渡します。タスクが完了すると、その結果を継続で使用します。ウェイターの実装は、継続を呼び出す方法を決定します。通常、呼び出しスレッドの同期コンテキストを使用します。

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

スタック、スレッドなどの「下位レベル」の用語でこれらの概念を考えるべきではありません。これらは抽象化であり、その内部の動作はCLRからのサポートを必要としません。魔法をかけるのはコンパイラだけです。これは、ランタイムのサポートを備えたLuaのコルーチンや、単なる黒魔術であるCのlongjmpとは大きく異なります。

4
IllidanS4