C#5非同期CTP:EndAwait呼び出しの前に、生成されたコードで内部「状態」が0に設定されるのはなぜですか?


195

昨日、新しいC#の「非同期」機能について、特に生成されたコードがどのように見えるか、およびthe GetAwaiter()/ BeginAwait()/ EndAwait()呼び出しについて詳しく説明しました。

C#コンパイラーによって生成された状態マシンを詳細に調べたところ、理解できない2つの側面がありました。

  • 生成されたクラスにDispose()メソッドと$__disposing変数が含まれているのに、それらが使用されていないように見える(クラスがを実装していないIDisposable)理由。
  • 通常、0が「これが初期エントリポイントであること」を意味するように見えるときに、内部state変数が0に設定される理由EndAwait()

最初の点は、非同期メソッド内でもっと興味深いことを実行することで解決できると思いますが、誰か他の情報があれば、聞いて喜んでいます。ただし、この質問は2番目の点についての詳細です。

非常にシンプルなサンプルコードを次に示します。

using System.Threading.Tasks;

class Test
{
    static async Task<int> Sum(Task<int> t1, Task<int> t2)
    {
        return await t1 + await t2;
    }
}

...そしてこれがMoveNext()、ステートマシンを実装するメソッドのために生成されるコードです。これはReflectorから直接コピーされます-私は言葉にできない変数名を修正していません:

public void MoveNext()
{
    try
    {
        this.$__doFinallyBodies = true;
        switch (this.<>1__state)
        {
            case 1:
                break;

            case 2:
                goto Label_00DA;

            case -1:
                return;

            default:
                this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
                this.<>1__state = 1;
                this.$__doFinallyBodies = false;
                if (this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate))
                {
                    return;
                }
                this.$__doFinallyBodies = true;
                break;
        }
        this.<>1__state = 0;
        this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();
        this.<a2>t__$await4 = this.t2.GetAwaiter<int>();
        this.<>1__state = 2;
        this.$__doFinallyBodies = false;
        if (this.<a2>t__$await4.BeginAwait(this.MoveNextDelegate))
        {
            return;
        }
        this.$__doFinallyBodies = true;
    Label_00DA:
        this.<>1__state = 0;
        this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
        this.<>1__state = -1;
        this.$builder.SetResult(this.<1>t__$await1 + this.<2>t__$await3);
    }
    catch (Exception exception)
    {
        this.<>1__state = -1;
        this.$builder.SetException(exception);
    }
}

長いですが、この質問の重要な行は次のとおりです。

// End of awaiting t1
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

// End of awaiting t2
this.<>1__state = 0;
this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();

どちらの場合も、次に明らかに観察される前に、後で状態が再び変更されます...では、なぜそれをまったく0に設定するのでしょうか。MoveNext()この時点で(直接またはを介してDispose)再度呼び出された場合、効果的にasyncメソッドが再び開始されます。これは、私が知る限り、完全に不適切です... 呼び出されMoveNext() ない場合、状態の変化は関係ありません。

これは、非同期の反復子ブロック生成コードを再利用するコンパイラの副作用であり、より明確な説明があるかもしれませんか?

重要な免責事項

明らかに、これは単なるCTPコンパイラです。私は、最終リリースの前に、そしておそらく次のCTPリリースの前にさえ、物事が変化することを完全に期待しています。この質問は、これがC#コンパイラまたはそのようなものの欠陥であると主張しようとするものでは決してありません。私が見逃したこのための微妙な理由があるかどうかを考え出そうとしているだけです:)


7
VBコンパイラは同様の状態マシンを生成します(それが予期されているかどうかはわかりませんが、VBには以前にイテレータブロックがありませんでした)
Damien_The_Unbeliever

1
@Rune:MoveNextDelegateは、MoveNextを参照する単なるデリゲートフィールドです。毎回ウェイターに渡される新しいアクションを作成しないようにキャッシュされていると思います。
Jon Skeet、2011

5
答えは次のとおりです。これはCTPです。チームにとって重要なのは、これを実現し、言語設計を検証したことです。そして、彼らは驚くほど速くそうしました。出荷された実装(MoveNextではなく、コンパイラーの)は大幅に異なると期待する必要があります。私はエリックかルシアンが、ここに深いものは何もないという線に沿って答えを返すと思います。ほとんどの場合重要ではなく、誰も気づいていない動作/バグだけです。それはCTPだからです。
Chris Burrows、

2
@スティルガー:私はildasmでチェックしたところ、実際にこれを実行しています。
Jon Skeet、2011

3
@JonSkeet:誰も答えを支持しないことに注意してください。私たちの99%は、答えが正しいように聞こえるかどうかさえ本当にわかりません。
the_drow

回答:


71

さて、私は最終的に本当の答えを持っています。私はそれを自分で解決しましたが、チームのVBのメンバーであるLucian Wischikが本当に正当な理由があることを確認した後でのみです。彼に感謝します-そして彼のブログをご覧ください。

ここでの値0 は、通常の場合の直前の状態ではないため、特別なものですawait。特に、ステートマシンが他の場所でテストを行う可能性がある状態ではありません。正でない値を使用しても同様に機能すると私は信じています。-1は通常「終了」を意味するため、論理的に正しくないため、-1は使用されません。現時点では、状態0に特別な意味を与えていると言えるかもしれませんが、結局それは問題ではありません。この質問の要点は、なぜ国家が設定されているのかを明らかにすることでした。

キャッチされる例外で待機が終了する場合、この値は適切です。最終的に同じawaitステートメントに戻ってしまう可能性がありますが、それ以外の場合はすべての種類のコードがスキップされるため、「その待機から戻ってくるところです」という意味の状態であってはなりません。これを例で示すのが最も簡単です。ここでは2番目のCTPを使用しているため、生成されたコードは質問のコードと少し異なります。

ここに非同期メソッドがあります:

static async Task<int> FooAsync()
{
    var t = new SimpleAwaitable();

    for (int i = 0; i < 3; i++)
    {
        try
        {
            Console.WriteLine("In Try");
            return await t;
        }                
        catch (Exception)
        {
            Console.WriteLine("Trying again...");
        }
    }
    return 0;
}

概念的には、SimpleAwaitableはどんなものであってもかまいません-多分タスク、多分何か。私のテストでは、に対して常にfalseを返しIsCompleted、で例外をスローしますGetResult

生成されたコードはMoveNext次のとおりです。

public void MoveNext()
{
    int returnValue;
    try
    {
        int num3 = state;
        if (num3 == 1)
        {
            goto Label_ContinuationPoint;
        }
        if (state == -1)
        {
            return;
        }
        t = new SimpleAwaitable();
        i = 0;
      Label_ContinuationPoint:
        while (i < 3)
        {
            // Label_ContinuationPoint: should be here
            try
            {
                num3 = state;
                if (num3 != 1)
                {
                    Console.WriteLine("In Try");
                    awaiter = t.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        awaiter.OnCompleted(MoveNextDelegate);
                        return;
                    }
                }
                else
                {
                    state = 0;
                }
                int result = awaiter.GetResult();
                awaiter = null;
                returnValue = result;
                goto Label_ReturnStatement;
            }
            catch (Exception)
            {
                Console.WriteLine("Trying again...");
            }
            i++;
        }
        returnValue = 0;
    }
    catch (Exception exception)
    {
        state = -1;
        Builder.SetException(exception);
        return;
    }
  Label_ReturnStatement:
    state = -1;
    Builder.SetResult(returnValue);
}

私はLabel_ContinuationPointそれを有効なコードにするために移動する必要がありました-それ以外の場合はgotoステートメントのスコープ内にありません-それは答えに影響しません。

GetResult例外をスローしたときに何が起こるかを考えてください。catchブロックを通過し、をインクリメントしてiから、もう一度ループします(iまだ3未満であると想定しています)。GetResult呼び出し前の状態のままですが、tryブロックの内部に入ると、「In Try」と出力して再度呼び出す必要GetAwaiterがあります。状態が1でない場合にのみ、これを実行します。state = 0割り当ては、それが既存のawaiterを使用してスキップしますConsole.WriteLineコールを。

これはかなり厄介なコードのコードですが、チームが考慮しなければならないことを示すためだけのものです。私はこれを実装する責任がないことを嬉しく思います:)


8
@Shekhar_Pro:はい、後藤です。自動生成されたステートマシンに多数のgotoステートメントが表示されることを期待する必要があります:)
Jon Skeet

12
@Shekhar_Pro:手動で作成したコード内では、コードが読みにくくなり、コードが読みにくくなるためです。しかし、自動生成されたコードを読み取る人はいませんが、私のような愚か者がコードを逆コンパイルするのは例外です:)
Jon Skeet

だから何をない、我々は例外の後、再び待つ際に起こりますか?もう一度やり直しますか?
コンフィギュレー

1
@configurator:awaitableでGetAwaiterを呼び出します。これは、私が期待していることです。
ジョンスキート、

gotosは常にコードを読みにくくするわけではありません。実際、時々彼らは使用するのが理にかなっています(言うまでもありませんが、私は知っています)。たとえば、ネストされた複数のループを解除する必要がある場合があります。goto(およびIMO醜い使用)のあまり使用されない機能は、switchステートメントをカスケードすることです。別に、私が後藤がいくつかのプログラミング言語の主な土台であった時代を覚えています。使い方を誤ると醜いものになってしまいます。
Ben Lesh

5

それが1(最初のケース)に保たれた場合、への呼び出しEndAwaitなしでへの呼び出しを取得しますBeginAwait。それが2(2番目のケース)に保たれている場合、もう一方のウェイターで同じ結果が得られます。

BeginAwaitの呼び出しが既に開始されている場合(私の側からの推測)、falseが返され、元の値が保持されてEndAwaitで返されると思います。その場合は正しく動作しますが、-1に設定するとthis.<1>t__$await1、最初のケースで初期化されていない可能性があります。

ただし、これは、BeginAwaiterが最初の呼び出しの後に実際にアクションを開始しないこと、およびそれらの場合にfalseを返すことを前提としています。開始は副作用があるか、単に異なる結果をもたらす可能性があるため、もちろん受け入れられません。また、EndAwaiterは、何回呼び出されても常に同じ値を返し、BeginAwaitがfalseを返したときに呼び出すことができると想定しています(上記の仮定のとおり)。

これは、競合状態に対する防御策のようです。state= 0の後にmovenextが別のスレッドによって呼び出されるステートメントをインライン化すると、問題は次のようになります。

this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.<>1__state = 0;

//second thread
this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.$__doFinallyBodies = true;
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

//other thread
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

上記の仮定が正しければ、sawiaterを取得して同じ値を<1> t __ $ await1に再割り当てするなど、不要な作業がいくつかあります。状態が1に保たれた場合、最後の部分は次のようになります。

//second thread
//I suppose this un matched call to EndAwait will fail
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

さらに、2に設定されている場合、ステートマシンは、最初のアクションの値がすでに真であると想定し、(潜在的に)割り当てられていない変数を使用して結果を計算します。


状態は、0への割り当てとより意味のある値への割り当ての間で実際には使用されていないことに注意してください。競合状態から保護することを意図している場合は、他の値、たとえば-2を示し、MoveNextの開始時にそれをチェックして不適切な使用を検出することを期待します。いずれにせよ、単一のインスタンスが一度に2つのスレッドによって実際に使用されることは決してないことに注意してください。これは、時々「一時停止」することができる単一の同期メソッド呼び出しのように見せるためです。
Jon Skeet

@ジョン私は、非同期の場合の競合状態の問題ではないことに同意しますが、反復ブロックにある可能性があり、残っている可能性があります
Rune FS

@Tony:次のCTPまたはベータ版が出るまで待って、その動作を確認すると思います。
ジョンスキート、

1

スタック/ネストされた非同期呼び出しと関係があるのでしょうか?

つまり:

async Task m1()
{
    await m2;
}

async Task m2()
{
    await m3();
}

async Task m3()
{
Thread.Sleep(10000);
}

movenextデリゲートはこの状況で複数回呼び出されますか?

本当にただのパント?


その場合、3つの異なる生成クラスがあります。MoveNext()それらのそれぞれで一度呼び出されます。
Jon Skeet、2011

0

実際の状態の説明:

可能な状態:

  • 0初期化済み(そう思う)または操作の終了を待機中
  • > 0はMoveNextと呼ばれ、次の状態を選択します
  • -1終了

この実装が、MoveNextへの別の呼び出しがどこからでも(待機中に)発生した場合に、状態チェーン全体を最初から再評価して、すでに古くなっている可能性のある結果を再評価することを保証することは可能ですか?


しかし、なぜ最初から始めたいのでしょうか?MoveNextを呼び出すものは他にないので、これはほとんど間違いなく実際に発生させたいことではありません。例外をスローする必要があります。
ジョンスキート、
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.