CTP asincrono C # 5: perché lo "stato" interno è impostato su 0 nel codice generato prima della chiamata EndAwait?


195

Ieri ho tenuto un discorso sulla nuova funzione "asincrona" di C #, in particolare approfondendo l'aspetto del codice generato e the GetAwaiter()/ BeginAwait()/ EndAwait()chiamate.

Abbiamo esaminato in dettaglio la macchina a stati generata dal compilatore C # e c'erano due aspetti che non potevamo capire:

  • Perché la classe generata contiene un Dispose()metodo e una $__disposingvariabile, che non sembrano mai essere utilizzati (e la classe non implementa IDisposable).
  • Perché la statevariabile interna è impostata su 0 prima di qualsiasi chiamata a EndAwait(), quando 0 normalmente significa "questo è il punto di ingresso iniziale".

Sospetto che il primo punto possa essere risolto facendo qualcosa di più interessante nel metodo asincrono, sebbene se qualcuno avesse ulteriori informazioni sarei felice di ascoltarlo. Questa domanda riguarda più il secondo punto, tuttavia.

Ecco un semplice esempio di codice di esempio:

using System.Threading.Tasks;

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

... ed ecco il codice che viene generato per il MoveNext()metodo che implementa la macchina a stati. Questo viene copiato direttamente da Reflector - Non ho corretto i nomi delle variabili indicibili:

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

È lungo, ma le linee importanti per questa domanda sono queste:

// 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 entrambi i casi lo stato viene nuovamente modificato in seguito prima che venga ovviamente osservato, quindi perché impostarlo su 0? Se MoveNext()venisse richiamato nuovamente a questo punto (direttamente o tramite Dispose), si riavvierebbe effettivamente il metodo asincrono, il che sarebbe del tutto inappropriato per quanto posso dire ... se e MoveNext() non viene chiamato, il cambiamento di stato è irrilevante.

Questo è semplicemente un effetto collaterale del riutilizzo del codice di generazione del blocco iteratore del compilatore per asincrono, dove potrebbe avere una spiegazione più ovvia?

Disclaimer importante

Ovviamente questo è solo un compilatore CTP. Mi aspetto pienamente che le cose cambino prima della versione finale e forse anche prima della prossima versione CTP. Questa domanda non sta affatto cercando di affermare che si tratta di un difetto nel compilatore C # o qualcosa del genere. Sto solo cercando di capire se c'è una ragione sottile per questo che mi sono perso :)


7
Il compilatore VB produce una macchina a stati simile (non so se è previsto o meno, ma VB non aveva blocchi iteratori prima)
Damien_The_Unbeliever

1
@Rune: MoveNextDelegate è solo un campo delegato che fa riferimento a MoveNext. Credo che sia memorizzato nella cache per evitare di creare una nuova azione da trasmettere al cameriere ogni volta.
Jon Skeet,

5
Penso che la risposta sia: questo è un CTP. Il massimo ordine per il team è stato ottenere questo risultato e il design della lingua convalidato. E lo hanno fatto incredibilmente rapidamente. Dovresti aspettarti che l'implementazione fornita (dei compilatori, non di MoveNext) differisca in modo significativo. Penso che Eric o Lucian tornino con una risposta sulla falsariga che qui non c'è nulla di profondo, solo un comportamento / bug che non importa nella maggior parte dei casi e nessuno se ne è accorto. Perché è un CTP.
Chris Burrows,

2
@Stilgar: ho appena verificato con ildasm e lo sta facendo davvero.
Jon Skeet,

3
@JonSkeet: nota come nessuno ha votato per le risposte. Il 99% di noi non sa davvero se la risposta suona bene.
the_drow

Risposte:


71

Bene, finalmente ho una vera risposta. L'ho risolto da solo, ma solo dopo che Lucian Wischik della parte VB del team ha confermato che c'è davvero una buona ragione per farlo. Mille grazie a lui - e per favore visita il suo blog , che è incredibile.

Il valore 0 qui è speciale solo perché non è uno stato valido in cui potresti trovarti poco prima del awaitcaso normale. In particolare, non è uno stato che la macchina a stati potrebbe finire per testare altrove. Credo che l'utilizzo di qualsiasi valore non positivo funzionerebbe altrettanto bene: -1 non viene utilizzato per questo in quanto è logicamente errato, poiché -1 normalmente significa "finito". Potrei sostenere che al momento stiamo dando un ulteriore significato allo stato 0, ma alla fine non importa davvero. Il punto di questa domanda era scoprire perché lo stato è stato impostato affatto.

Il valore è rilevante se l'attesa termina in un'eccezione rilevata. Possiamo finire per tornare di nuovo alla stessa dichiarazione di attesa, ma non dobbiamo trovarci nello stato che significa "Sto per tornare da quell'attesa", poiché altrimenti tutti i tipi di codice verrebbero saltati. È più semplice mostrarlo con un esempio. Nota che ora sto usando il secondo CTP, quindi il codice generato è leggermente diverso da quello nella domanda.

Ecco il metodo asincrono:

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

Concettualmente, il SimpleAwaitablepuò essere qualsiasi aspettabile - forse un compito, forse qualcos'altro. Ai fini dei miei test, restituisce sempre false per IsCompletede genera un'eccezione GetResult.

Ecco il codice generato per 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);
}

Mi sono dovuto spostare Label_ContinuationPointper renderlo un codice valido, altrimenti non gotorientra nell'ambito di applicazione della dichiarazione, ma ciò non influisce sulla risposta.

Pensa a cosa succede quando GetResultgenera la sua eccezione. Esamineremo il blocco catch, incrementeremo i, quindi ricominciamo da capo (supponendo che isia ancora inferiore a 3). Siamo ancora in qualunque stato che eravamo prima della GetResultchiamata ... ma quando arriviamo all'interno del tryblocco che deve stampare "In Prova" e chiamare GetAwaiterdi nuovo ... e noi provvederemo a farlo solo se lo stato non è 1. Fatto l' state = 0incarico, utilizzerà l'attendente esistente e salterà la Console.WriteLinechiamata.

È un codice abbastanza tortuoso da elaborare, ma questo dimostra solo i tipi di cose a cui il team deve pensare. Sono contento di non essere responsabile dell'implementazione di questo :)


8
@Shekhar_Pro: Sì, è un goto. Dovresti aspettarti di vedere molte dichiarazioni goto in macchine a stati generate automaticamente :)
Jon Skeet,

12
@Shekhar_Pro: all'interno del codice scritto manualmente, lo è, perché rende il codice difficile da leggere e seguire. Nessuno legge il codice generato automaticamente, tranne gli sciocchi come me che lo decompilano :)
Jon Skeet,

Così che cosa fa accadere quando ci aspettiamo di nuovo dopo un'eccezione? Ricominciamo tutto da capo?
configuratore

1
@configurator: chiama GetAwaiter sull'attendibile, che è quello che mi aspetto che faccia.
Jon Skeet,

le foto non sempre rendono il codice più difficile da leggere. In effetti, a volte hanno persino senso usare (sacrilegio da dire, lo so). Ad esempio, a volte potrebbe essere necessario interrompere più loop nidificati. La funzione meno usata di goto (e dell'uso più brutto dell'IMO) è quella di causare la cascata delle istruzioni switch. Da un punto di vista separato, ricordo un giorno e un'età in cui i goto erano alla base di alcuni linguaggi di programmazione e per questo motivo mi rendo pienamente conto del fatto che la semplice menzione di goto fa rabbrividire gli sviluppatori. Possono rendere le cose brutte se usate male.
Ben Lesh,

5

se fosse mantenuto su 1 (primo caso) si riceverà una chiamata EndAwaitsenza una chiamata a BeginAwait. Se viene mantenuto a 2 (secondo caso) otterrai lo stesso risultato solo sull'altro cameriere.

Immagino che chiamare BeginAwait ritorni falso se è già stato avviato (una supposizione dalla mia parte) e mantiene il valore originale da restituire a EndAwait. In tal caso, funzionerebbe correttamente, mentre se lo imposti su -1 potresti avere un non inizializzato this.<1>t__$await1per il primo caso.

Ciò presuppone tuttavia che BeginAwaiter non avvierà effettivamente l'azione su nessuna chiamata dopo la prima e che in questi casi restituirà false. L'inizio sarebbe ovviamente inaccettabile poiché potrebbe avere effetti collaterali o semplicemente dare un risultato diverso. Presuppone inoltre che EndAwaiter restituirà sempre lo stesso valore, indipendentemente da quante volte viene chiamato e che può essere chiamato quando BeginAwait restituisce false (come da presupposto sopra)

Sembrerebbe essere una guardia contro le condizioni di gara Se inseriamo le dichiarazioni in cui movenext è chiamato da un thread diverso dopo lo stato = 0 nelle domande sembrerebbe qualcosa di simile al seguito

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

Se i presupposti di cui sopra sono corretti, c'è qualche lavoro non necessario fatto come ottenere sawiater e riassegnare lo stesso valore a <1> t __ $ await1. Se lo stato fosse mantenuto a 1, l'ultima parte sarebbe invece:

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

inoltre, se fosse impostato su 2, la macchina a stati supponerebbe che avesse già ottenuto il valore della prima azione che sarebbe falso e una variabile (potenzialmente) non assegnata verrebbe utilizzata per calcolare il risultato


Tieni presente che lo stato non viene effettivamente utilizzato tra l'assegnazione a 0 e l'assegnazione a un valore più significativo. Se si intende proteggere dalle condizioni di gara, mi aspetterei che un altro valore indichi che, ad esempio -2, con un controllo all'inizio di MoveNext per rilevare un uso inappropriato. Tieni presente che comunque una singola istanza non dovrebbe mai essere effettivamente utilizzata da due thread alla volta - ha lo scopo di dare l'illusione di una singola chiamata di metodo sincrono che riesce a "mettere in pausa" ogni tanto.
Jon Skeet,

@Jon Sono d'accordo che non dovrebbe essere un problema con una condizione di gara nel caso asincrono ma potrebbe essere in blocco di iterazione e potrebbe essere lasciato in
sospeso

@Tony: penso che aspetterò fino al prossimo CTP o beta, e verificherò quel comportamento.
Jon Skeet,

1

Potrebbe essere qualcosa a che fare con le chiamate asincrone in pila / nidificate? ..

vale a dire:

async Task m1()
{
    await m2;
}

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

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

Il delegato di Movenext viene chiamato più volte in questa situazione?

Solo un punt davvero?


Ci sarebbero tre diverse classi generate in quel caso. MoveNext()verrebbe chiamato una volta su ciascuno di essi.
Jon Skeet,

0

Spiegazione degli stati attuali:

stati possibili:

  • 0 Inizializzato (penso di sì) o in attesa della fine dell'operazione
  • > 0 ha appena chiamato MoveNext, scegliendo lo stato successivo
  • -1 terminato

È possibile che questa implementazione voglia solo assicurare che se un altro Call to MoveNext da qualunque luogo (durante l'attesa) rivalutasse nuovamente l'intera catena di stati dall'inizio, per rivalutare risultati che potrebbero essere nel frattempo già obsoleti?


Ma perché dovrebbe iniziare dall'inizio? Questo è quasi certamente non quello che ci si vuole realmente accadere - che ci si vuole un'eccezione sollevata, perché niente altro dovrebbe essere chiamata MoveNext.
Jon Skeet,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.