C # 5 async CTP: Warum wird der interne "Status" im generierten Code vor dem EndAwait-Aufruf auf 0 gesetzt?

195

Gestern hielt ich einen Vortrag über die neue C # "Async" -Funktion, insbesondere über das Aussehen des generierten Codes und the GetAwaiter()/ BeginAwait()/ EndAwait()Aufrufe.

Wir haben uns die vom C # -Compiler generierte Zustandsmaschine genauer angesehen, und es gab zwei Aspekte, die wir nicht verstehen konnten:

  • Warum die generierte Klasse eine Dispose()Methode und eine $__disposingVariable enthält, die scheinbar nie verwendet werden (und die Klasse nicht implementiert IDisposable).
  • Warum die interne stateVariable vor jedem Aufruf von auf EndAwait()0 gesetzt wird, wenn 0 normalerweise "dies ist der erste Einstiegspunkt" bedeutet.

Ich vermute, der erste Punkt könnte durch etwas Interessanteres innerhalb der asynchronen Methode beantwortet werden, obwohl ich mich freuen würde, wenn jemand weitere Informationen hätte. Bei dieser Frage geht es jedoch eher um den zweiten Punkt.

Hier ist ein sehr einfacher Beispielcode:

using System.Threading.Tasks;

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

... und hier ist der Code, der für die MoveNext()Methode generiert wird, die die Zustandsmaschine implementiert. Dies wird direkt von Reflector kopiert - ich habe die unaussprechlichen Variablennamen nicht korrigiert:

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);
    }
}

Es ist lang, aber die wichtigen Zeilen für diese Frage sind folgende:

// 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();

In beiden Fällen wird der Status danach erneut geändert, bevor er das nächste Mal offensichtlich beobachtet wird. Warum also überhaupt auf 0 setzen? Wenn MoveNext()an dieser Stelle erneut aufgerufen würde (entweder direkt oder über Dispose), würde die asynchrone Methode effektiv erneut gestartet, was meines Erachtens völlig unangemessen wäre. Wenn und MoveNext() wird nicht aufgerufen, ist die Zustandsänderung irrelevant.

Ist dies einfach ein Nebeneffekt des Compilers, der den Code zur Generierung von Iteratorblöcken für Async wiederverwendet, wo er möglicherweise eine offensichtlichere Erklärung hat?

Wichtiger Haftungsausschluss

Offensichtlich ist dies nur ein CTP-Compiler. Ich gehe davon aus, dass sich die Dinge vor der endgültigen Veröffentlichung ändern werden - und möglicherweise sogar vor der nächsten CTP-Veröffentlichung. Diese Frage versucht in keiner Weise zu behaupten, dass dies ein Fehler im C # -Compiler oder ähnliches ist. Ich versuche nur herauszufinden, ob es einen subtilen Grund dafür gibt, den ich verpasst habe :)

Jon Skeet
quelle
7
Der VB-Compiler erzeugt eine ähnliche Zustandsmaschine (weiß nicht, ob das erwartet wird oder nicht, aber VB hatte vorher keine Iteratorblöcke)
Damien_The_Unbeliever
1
@Rune: MoveNextDelegate ist nur ein Delegatenfeld, das auf MoveNext verweist. Ich glaube, es wird zwischengespeichert, um zu vermeiden, dass jedes Mal eine neue Aktion erstellt wird, die an den Kellner weitergegeben wird.
Jon Skeet
5
Ich denke, die Antwort lautet: Dies ist eine CTP. Das oberste Gebot für das Team war, dies herauszubringen und das Sprachdesign zu validieren. Und das so erstaunlich schnell. Sie sollten erwarten, dass sich die ausgelieferte Implementierung (der Compiler, nicht MoveNext) erheblich unterscheidet. Ich denke, Eric oder Lucian kommen mit einer Antwort zurück, dass es hier nichts Tiefes gibt, nur ein Verhalten / Fehler, der in den meisten Fällen keine Rolle spielt und das niemand bemerkt hat. Weil es ein CTP ist.
Chris Burrows
2
@Stilgar: Ich habe gerade mit ildasm nachgefragt, und das macht es wirklich.
Jon Skeet
3
@ JonSkeet: Beachten Sie, wie niemand die Antworten positiv bewertet. 99% von uns können nicht wirklich sagen, ob die Antwort überhaupt richtig klingt.
the_drow

Antworten:

71

Okay, ich habe endlich eine echte Antwort. Ich habe es irgendwie selbst herausgefunden, aber erst nachdem Lucian Wischik vom VB-Teil des Teams bestätigt hatte, dass es wirklich einen guten Grund dafür gibt. Vielen Dank an ihn - und bitte besuchen Sie seinen Blog , der rockt.

Der Wert 0 ist hier nur etwas Besonderes, da es sich nicht um einen gültigen Zustand handelt, in dem Sie sich im awaitNormalfall möglicherweise kurz vor dem befinden . Insbesondere ist es kein Zustand, auf den die Zustandsmaschine möglicherweise an anderer Stelle testet. Ich glaube, dass die Verwendung eines nicht positiven Werts genauso gut funktionieren würde: -1 wird dafür nicht verwendet, da es logisch falsch ist, da -1 normalerweise "fertig" bedeutet. Ich könnte argumentieren, dass wir im Moment 0 eine zusätzliche Bedeutung geben, aber letztendlich spielt es keine Rolle. Bei dieser Frage ging es darum herauszufinden, warum der Staat überhaupt eingestellt wird.

Der Wert ist relevant, wenn das Warten in einer Ausnahme endet, die abgefangen wird. Wir können am Ende wieder auf dieselbe Warteerklärung zurückkommen, aber wir dürfen uns nicht in dem Zustand befinden, der bedeutet "Ich bin gerade dabei, von dieser Wartezeit zurückzukommen", da sonst alle Arten von Code übersprungen würden. Es ist am einfachsten, dies anhand eines Beispiels zu zeigen. Beachten Sie, dass ich jetzt das zweite CTP verwende, sodass sich der generierte Code geringfügig von dem in der Frage unterscheidet.

Hier ist die asynchrone Methode:

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;
}

Konzeptionell SimpleAwaitablekann das alles erwarten - vielleicht eine Aufgabe, vielleicht etwas anderes. Für die Zwecke meiner Tests wird immer false für zurückgegeben IsCompletedund eine Ausnahme ausgelöst GetResult.

Hier ist der generierte Code für 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);
}

Ich musste umziehen Label_ContinuationPoint, um einen gültigen Code zu erhalten - ansonsten ist er nicht im Umfang der gotoAnweisung enthalten -, aber das hat keinen Einfluss auf die Antwort.

Überlegen Sie, was passiert, wenn GetResultdie Ausnahme ausgelöst wird. Wir gehen durch den Catch-Block, erhöhen ihn iund drehen ihn dann erneut (vorausgesetzt, er iist immer noch kleiner als 3). Wir sind immer noch in dem Zustand, in dem wir uns vor dem GetResultAnruf befanden ... aber wenn wir in den tryBlock kommen, müssen wir "In Try" drucken und GetAwaitererneut anrufen ... und das tun wir nur, wenn der Zustand nicht 1 ist. Ohne Bei der state = 0Zuweisung wird der vorhandene Kellner verwendet und der Console.WriteLineAnruf übersprungen .

Es ist ein ziemlich umständliches Stück Code, das durchgearbeitet werden muss, aber das zeigt nur, worüber das Team nachdenken muss. Ich bin froh, dass ich nicht dafür verantwortlich bin :)

Jon Skeet
quelle
8
@ Shekhar_Pro: Ja, es ist ein Goto. Sie sollten erwarten , viele goto-Anweisungen in automatisch generierten Zustandsmaschinen zu sehen :)
Jon Skeet
12
@ Shekhar_Pro: Innerhalb von manuell geschriebenem Code ist es - weil es den Code schwer zu lesen und zu folgen macht. Niemand liest automatisch generierten Code, außer Dummköpfen wie mir, die ihn dekompilieren :)
Jon Skeet
Was passiert also , wenn wir nach einer Ausnahme wieder warten? Wir fangen wieder von vorne an?
Konfigurator
1
@configurator: Es ruft GetAwaiter auf dem Wartbaren auf, was ich erwarten würde.
Jon Skeet
gotos erschweren nicht immer das Lesen von Code. In der Tat ist es manchmal sogar sinnvoll, sie zu verwenden (Sakrileg zu sagen, ich weiß). Beispielsweise müssen Sie manchmal mehrere verschachtelte Schleifen unterbrechen. Ich habe die weniger verwendete Funktion von goto (und die hässlichere Verwendung von IMO) darin, switch-Anweisungen zu kaskadieren. Ich erinnere mich an einen Tag und eine Zeit, in denen gotos eine Hauptgrundlage für einige Programmiersprachen waren, und aus diesem Grund ist mir völlig klar, warum die bloße Erwähnung von goto Entwickler erschaudern lässt. Sie können Dinge hässlich machen, wenn sie schlecht verwendet werden.
Ben Lesh
5

Wenn es auf 1 gehalten würde (erster Fall), würden Sie einen Anruf erhalten, EndAwaitohne einen Anruf bei BeginAwait. Wenn es bei 2 gehalten wird (zweiter Fall), erhalten Sie das gleiche Ergebnis nur für den anderen Kellner.

Ich vermute, dass das Aufrufen von BeginAwait false zurückgibt, wenn es bereits gestartet wurde (eine Vermutung von meiner Seite) und den ursprünglichen Wert beibehält, der beim EndAwait zurückgegeben werden soll. Wenn dies der Fall ist, würde es korrekt funktionieren, während Sie, wenn Sie es auf -1 setzen, möglicherweise eine nicht initialisierte this.<1>t__$await1für den ersten Fall haben.

Dies setzt jedoch voraus, dass BeginAwaiter die Aktion bei keinem Aufruf nach dem ersten tatsächlich startet und in diesen Fällen false zurückgibt. Das Starten wäre natürlich inakzeptabel, da es Nebenwirkungen haben oder einfach zu einem anderen Ergebnis führen könnte. Es wird auch davon ausgegangen, dass der EndAwaiter immer den gleichen Wert zurückgibt, egal wie oft er aufgerufen wird, und dies kann aufgerufen werden, wenn BeginAwait false zurückgibt (gemäß der obigen Annahme).

Es scheint ein Schutz gegen die Rennbedingungen zu sein. Wenn wir die Aussagen einfügen, bei denen movenext von einem anderen Thread nach dem Status = 0 in Fragen aufgerufen wird, sieht es ungefähr so ​​aus wie unten

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();

Wenn die obigen Annahmen korrekt sind, werden einige nicht benötigte Arbeiten ausgeführt, z. B. "Sawiater holen" und denselben Wert <1> t __ $ await1 neu zuweisen. Wenn der Zustand auf 1 gehalten würde, wäre der letzte Teil stattdessen:

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

Wenn es auf 2 gesetzt wäre, würde die Zustandsmaschine annehmen, dass sie bereits den Wert der ersten Aktion erhalten hat, die nicht wahr ist, und eine (möglicherweise) nicht zugewiesene Variable würde verwendet, um das Ergebnis zu berechnen

Rune FS
quelle
Beachten Sie, dass der Status zwischen der Zuweisung zu 0 und der Zuweisung zu einem aussagekräftigeren Wert nicht tatsächlich verwendet wird. Wenn es vor Rennbedingungen schützen soll, würde ich erwarten, dass ein anderer Wert dies anzeigt, z. B. -2, mit einer Überprüfung zu Beginn von MoveNext, um eine unangemessene Verwendung festzustellen. Denken Sie daran, dass eine einzelne Instanz sowieso nie von zwei Threads gleichzeitig verwendet werden sollte - dies soll die Illusion eines einzelnen synchronen Methodenaufrufs vermitteln, der es schafft, von Zeit zu Zeit zu "pausieren".
Jon Skeet
@ Jon Ich bin damit einverstanden, dass es kein Problem mit einer Rennbedingung im asynchronen Fall sein sollte, sondern sich im Iterationsblock befinden und übrig bleiben könnte
Rune FS
@ Tony: Ich denke, ich werde warten, bis die nächste CTP oder Beta herauskommt, und dieses Verhalten überprüfen.
Jon Skeet
1

Könnte es etwas mit gestapelten / verschachtelten asynchronen Aufrufen zu tun haben?

dh:

async Task m1()
{
    await m2;
}

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

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

Wird der movenext-Delegat in dieser Situation mehrmals angerufen?

Nur ein Kahn wirklich?

GaryMcAllister
quelle
In diesem Fall würde es drei verschiedene generierte Klassen geben. MoveNext()würde bei jedem von ihnen einmal angerufen werden.
Jon Skeet
0

Erklärung der tatsächlichen Zustände:

mögliche Zustände:

  • 0 Initialisiert (glaube ich) oder Warten auf das Ende des Betriebs
  • > 0 heißt gerade MoveNext und wählt den nächsten Status
  • -1 beendet

Ist es möglich, dass diese Implementierung nur sicherstellen möchte, dass bei einem erneuten Aufruf von MoveNext von jedem Ort aus (während des Wartens) die gesamte Zustandskette von Anfang an erneut bewertet wird, um Ergebnisse neu zu bewerten, die in der Zwischenzeit möglicherweise bereits veraltet sind?

Fixagon
quelle
Aber warum sollte es von vorne beginnen wollen? Das ist mit ziemlicher Sicherheit nicht , was man eigentlich passieren soll - würden Sie wollen eine Ausnahme ausgelöst, weil nichts anderes sollte Movenext anrufen.
Jon Skeet