In che modo cedere e attendere implementare il flusso di controllo in .NET?


105

Da quanto ho capito il yield parola chiave, se utilizzata dall'interno di un blocco iteratore, restituisce il flusso di controllo al codice chiamante e quando l'iteratore viene chiamato di nuovo, riprende da dove era stato interrotto.

Inoltre, awaitnon solo attende il chiamato, ma restituisce il controllo al chiamante, solo per riprendere da dove era stato interrotto quando il chiamanteawaits il metodo.

In altre parole, non c'è filo e la "concorrenza" di async e await è un'illusione causata da un flusso di controllo intelligente, i cui dettagli sono nascosti dalla sintassi.

Ora, sono un ex programmatore di assembly e ho molta familiarità con i puntatori di istruzioni, gli stack, ecc. E ho capito come funzionano i normali flussi di controllo (subroutine, ricorsione, cicli, rami). Ma questi nuovi costrutti ... non li capisco.

Quando awaitviene raggiunto un, come fa il runtime a sapere quale parte di codice dovrebbe essere eseguita dopo? Come fa a sapere quando può riprendere da dove era stato interrotto e come ricorda dove? Cosa succede allo stack di chiamate corrente, viene salvato in qualche modo? E se il metodo chiamante effettua chiamate ad altri metodi prima di essoawait s-- perché lo stack non viene sovrascritto? E come diavolo avrebbe fatto il runtime a funzionare in tutto questo nel caso di un'eccezione e uno stack si svolgesse?

Quando yieldviene raggiunto, come fa il runtime a tenere traccia del punto in cui le cose dovrebbero essere raccolte? Come viene preservato lo stato dell'iteratore?


4
Si può dare un'occhiata al codice generato nel TryRoslyn compilatore on-line
Xanatos

1
Potresti voler controllare le serie di articoli Eduasync di Jon Skeet.
Leonid Vasilev,

Lettura interessante correlata: stackoverflow.com/questions/37419572/…
Jason C

Risposte:


115

Risponderò alle tue domande specifiche di seguito, ma probabilmente faresti bene a leggere semplicemente i miei ampi articoli su come abbiamo progettato il rendimento e l'attesa.

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/

Alcuni di questi articoli non sono aggiornati ora; il codice generato è diverso in molti modi. Ma questi ti daranno sicuramente l'idea di come funziona.

Inoltre, se non capisci come vengono generati i lambda come classi di chiusura, capiscilo prima . Non farai testa o croce asincrono se non hai lambda giù.

Quando viene raggiunto un wait, come fa il runtime a sapere quale parte di codice dovrebbe essere eseguita dopo?

await viene generato come:

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

Fondamentalmente è così. Aspettare è solo un fantastico ritorno.

Come fa a sapere quando può riprendere da dove era stato interrotto e come ricorda dove?

Bene, come fai a farlo senza aspettare? Quando il metodo pippo chiama la barra del metodo, in qualche modo ricordiamo come tornare al centro di pippo, con tutte le parti locali dell'attivazione di pippo intatte, indipendentemente da cosa fa la barra.

Sai come si fa in assembler. Un record di attivazione per foo viene inserito nella pila; contiene i valori dei locali. Al punto della chiamata, l'indirizzo del mittente in foo viene inserito nello stack. Quando la barra è terminata, il puntatore dello stack e il puntatore dell'istruzione vengono reimpostati dove devono essere e foo continua dal punto in cui era stato interrotto.

La continuazione di un'attesa è esattamente la stessa, tranne per il fatto che il record viene messo nell'heap per l'ovvia ragione che la sequenza di attivazioni non forma uno stack .

Il delegato che attende fornisce come continuazione dell'attività contiene (1) un numero che è l'input di una tabella di ricerca che fornisce il puntatore all'istruzione che è necessario eseguire successivamente e (2) tutti i valori di locali e temporanei.

C'è qualche marcia in più lì dentro; per esempio, in .NET è illegale diramare nel mezzo di un blocco try, quindi non puoi semplicemente inserire l'indirizzo del codice all'interno di un blocco try nella tabella. Ma questi sono dettagli contabili. Concettualmente, il record di attivazione viene semplicemente spostato nell'heap.

Cosa succede allo stack di chiamate corrente, viene salvato in qualche modo?

Le informazioni rilevanti nel record di attivazione corrente non vengono mai messe in pila in primo luogo; viene allocato fuori dall'heap sin dall'inizio. (Bene, i parametri formali vengono passati normalmente sullo stack o nei registri e quindi copiati in una posizione di heap quando il metodo inizia.)

I record di attivazione dei chiamanti non vengono memorizzati; probabilmente l'attesa tornerà da loro, ricordate, quindi saranno trattati normalmente.

Si noti che questa è una differenza sostanziale tra lo stile di continuation crossing semplificato di await e le vere strutture di continuazione di chiamata con corrente che si vedono in linguaggi come Scheme. In quelle lingue l'intera continuazione, inclusa la continuazione nei chiamanti, viene catturata da call-cc .

E se il metodo chiamante effettua chiamate ad altri metodi prima che attenda, perché lo stack non viene sovrascritto?

Quelle chiamate al metodo vengono restituite e quindi i loro record di attivazione non sono più nello stack al momento dell'attesa.

E come diavolo avrebbe fatto il runtime a funzionare in tutto questo nel caso di un'eccezione e uno stack si svolgesse?

In caso di un'eccezione non rilevata, l'eccezione viene rilevata, archiviata all'interno dell'attività e rilanciata quando viene recuperato il risultato dell'attività.

Ricordi tutta quella contabilità che ho menzionato prima? Ottenere la giusta semantica delle eccezioni è stato un enorme problema, lascia che te lo dica.

Quando viene raggiunto il rendimento, in che modo il runtime tiene traccia del punto in cui le cose dovrebbero essere raccolte? Come viene preservato lo stato dell'iteratore?

Stessa strada. Lo stato delle locals viene spostato nell'heap e un numero che rappresenta l'istruzione alla quale MoveNextdovrebbe riprendere la prossima volta che viene chiamato viene memorizzato insieme alle locals.

E ancora, c'è un sacco di attrezzi in un blocco iteratore per assicurarsi che le eccezioni siano gestite correttamente.


1
a causa del background degli autori delle domande (assembler et al), forse vale la pena ricordare che entrambi questi costrutti non sono possibili senza la memoria gestita. senza che la memoria gestita tenti di coordinare la durata di una chiusura, ti farebbe sicuramente inciampare sui tuoi bootstrap.
Jim

Tutti i collegamenti alle pagine non sono stati trovati (404)
Digital3D

Tutti i tuoi articoli non sono disponibili ora. Potresti ripubblicarli?
Michał Turczyn il

1
@ MichałTurczyn: sono ancora su Internet; Microsoft continua a spostarsi dove si trova l'archivio del blog. Li migrerò gradualmente dappertutto sul mio sito personale e cercherò di aggiornare questi collegamenti quando avrò tempo.
Eric Lippert,

38

yield è il più facile dei due, quindi esaminiamolo.

Diciamo che abbiamo:

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

Questo viene compilato un po ' come se avessimo scritto:

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

Quindi, non efficiente come un'implementazione scritta a mano di IEnumerable<int>e IEnumerator<int>(ad es. Probabilmente non sprecheremmo un separato _state, _ie _currentin questo caso) ma non male (il trucco di riutilizzare se stesso quando è sicuro farlo piuttosto che creare un nuovo object è buono) ed estensibile per gestire yieldmetodi di utilizzo molto complicati .

E ovviamente da allora

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

Equivale a:

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

Quindi il generato MoveNext()viene ripetutamente chiamato.

Il asynccaso è più o meno lo stesso principio, ma con un po 'di complessità in più. Per riutilizzare un esempio da un altro codice di risposta come:

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

Produce codice come:

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

È più complicato, ma un principio di base molto simile. La principale complicazione extra è che ora GetAwaiter()viene utilizzata. Se un qualsiasi momento awaiter.IsCompletedè selezionato, ritorna trueperché l'attività awaited è già completata (ad esempio, i casi in cui potrebbe tornare in modo sincrono), il metodo continua a spostarsi attraverso gli stati, ma per il resto si imposta come callback per il awaiter.

Quello che succede dipende da chi attende, in termini di ciò che attiva il callback (es. Completamento I / O asincrono, completamento di un'attività in esecuzione su un thread) e quali requisiti ci sono per il marshalling su un thread particolare o l'esecuzione su un thread pool di thread , quale contesto dalla chiamata originale può o non può essere necessario e così via. Qualunque cosa sia, qualcosa in quel attendente chiamerà MoveNexte continuerà con il lavoro successivo (fino a quello successivo await) o finirà e tornerà, nel qual caso Taskciò che sta implementando sarà completato.


Ti sei preso il tempo per eseguire la tua traduzione? O_O uao.
CoffeDeveloper

4
@DarioOO Il primo che riesco a fare abbastanza velocemente, avendo fatto molte traduzioni da yielda manualmente quando c'è un vantaggio nel farlo (generalmente come ottimizzazione, ma volendo assicurarmi che il punto di partenza sia vicino al compilatore generato quindi niente viene deottimizzato da cattive ipotesi). Il secondo è stato usato per la prima volta in un'altra risposta e c'erano un paio di lacune nella mia conoscenza in quel momento, quindi ho tratto vantaggio dal riempirle fornendo quella risposta decompilando manualmente il codice.
Jon Hanna

13

Ci sono già un sacco di ottime risposte qui; Condividerò solo alcuni punti di vista che possono aiutare a formare un modello mentale.

In primo luogo, un asyncmetodo viene suddiviso in più parti dal compilatore; le awaitespressioni sono i punti di frattura. (Questo è facile da concepire per metodi semplici; anche metodi più complessi con loop e gestione delle eccezioni vengono scomposti, con l'aggiunta di una macchina a stati più complessa).

Secondo, awaitsi traduce in una sequenza abbastanza semplice; Mi piace la descrizione di Lucian , che in parole è praticamente "se l'attesa è già completa, ottieni il risultato e continua ad eseguire questo metodo; altrimenti, salva lo stato di questo metodo e torna". (Uso una terminologia molto simile nella mia asyncintroduzione ).

Quando viene raggiunto un wait, come fa il runtime a sapere quale parte di codice dovrebbe essere eseguita dopo?

Il resto del metodo esiste come callback per quello awaitable (nel caso delle attività, questi callback sono continuazioni). Quando l'attesa è completa, richiama i suoi callback.

Notare che lo stack di chiamate non viene salvato e ripristinato; i callback vengono richiamati direttamente. Nel caso di I / O sovrapposti, vengono richiamati direttamente dal pool di thread.

Quei callback possono continuare a eseguire il metodo direttamente, oppure possono programmarne l'esecuzione altrove (ad esempio, se la awaitUI catturata SynchronizationContexte l'I / O sono state completate sul pool di thread).

Come fa a sapere quando può riprendere da dove era stato interrotto e come ricorda dove?

Sono solo richiami. Quando un awaitable viene completato, richiama i suoi callback e qualsiasi asyncmetodo che lo aveva già modificato awaitviene ripreso. Il callback salta nel mezzo di quel metodo e ha le sue variabili locali nell'ambito.

I callback non vengono eseguiti in un thread particolare e non è stato ripristinato lo stack di chiamate.

Cosa succede allo stack di chiamate corrente, viene salvato in qualche modo? E se il metodo chiamante effettua chiamate ad altri metodi prima che attenda, perché lo stack non viene sovrascritto? E come diavolo avrebbe fatto il runtime a funzionare in tutto questo nel caso di un'eccezione e uno stack si svolgesse?

Il callstack non viene salvato in primo luogo; non è necessario.

Con il codice sincrono, puoi ritrovarti con uno stack di chiamate che include tutti i tuoi chiamanti e il runtime sa dove tornare usando quello.

Con il codice asincrono, puoi ritrovarti con un mucchio di puntatori di callback - radicati in un'operazione di I / O che termina la sua attività, che può riprendere un asyncmetodo che termina la sua attività, che può riprendere un asyncmetodo che termina la sua attività, ecc.

Così, con sincrona il codice Adi chiamata Bdi chiamata C, il vostro stack può apparire come segue:

A:B:C

mentre il codice asincrono utilizza callback (puntatori):

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

Quando viene raggiunto il rendimento, in che modo il runtime tiene traccia del punto in cui le cose dovrebbero essere raccolte? Come viene preservato lo stato dell'iteratore?

Attualmente, piuttosto inefficiente. :)

Funziona come qualsiasi altra lambda: le durate delle variabili vengono estese e i riferimenti vengono inseriti in un oggetto di stato che risiede nello stack. La migliore risorsa per tutti i dettagli di livello profondo è la serie EduAsync di Jon Skeet .


7

yielde awaitsono, pur trattando entrambi il controllo del flusso, due cose completamente diverse. Quindi li affronterò separatamente.

L'obiettivo di yieldè semplificare la creazione di sequenze pigre. Quando scrivi un ciclo enumeratore con yieldun'istruzione al suo interno, il compilatore genera un sacco di nuovo codice che non vedi. Sotto il cofano, in realtà genera una classe completamente nuova. La classe contiene membri che tengono traccia dello stato del ciclo e un'implementazione di IEnumerable in modo che ogni volta che lo chiami MoveNextpassi ancora una volta attraverso quel ciclo. Quindi, quando esegui un ciclo foreach come questo:

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

il codice generato ha un aspetto simile a:

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

All'interno dell'implementazione di mything.items () c'è un mucchio di codice macchina a stati che farà un "passo" del ciclo e poi tornerà. Quindi mentre lo scrivi nella sorgente come un semplice loop, sotto il cofano non è un semplice loop. Quindi l'inganno del compilatore. Se vuoi vedere te stesso, estrai ILDASM o ILSpy o strumenti simili e guarda come appare l'IL generato. Dovrebbe essere istruttivo.

asynce await, d'altra parte, sono un altro paio di maniche. Await è, in astratto, una primitiva di sincronizzazione. È un modo per dire al sistema "Non posso continuare fino a quando non viene fatto". Ma, come hai notato, non è sempre coinvolto un filo conduttore.

Ciò che è coinvolto è qualcosa chiamato contesto di sincronizzazione. Ce n'è sempre uno in giro. Il compito del contesto di sincronizzazione è pianificare le attività attese e le loro continuazioni.

Quando dici await thisThing(), accadono un paio di cose. In un metodo asincrono, il compilatore suddivide effettivamente il metodo in blocchi più piccoli, ognuno dei quali è una sezione "prima di un atteso" e una sezione "dopo un atteso" (o continuazione). Quando l'attesa viene eseguita, l'attività in attesa e la continuazione successiva, in altre parole il resto della funzione, viene passata al contesto di sincronizzazione. Il contesto si occupa della pianificazione dell'attività e, una volta terminata, il contesto esegue la continuazione, passando il valore di ritorno desiderato.

Il contesto di sincronizzazione è libero di fare tutto ciò che vuole purché pianifichi le cose. Potrebbe utilizzare il pool di thread. Potrebbe creare un thread per attività. Potrebbe eseguirli in modo sincrono. Ambienti diversi (ASP.NET e WPF) forniscono diverse implementazioni del contesto di sincronizzazione che eseguono operazioni diverse in base a ciò che è meglio per i loro ambienti.

(Bonus: ti sei mai chiesto cosa .ConfigurateAwait(false)fa? Sta dicendo al sistema di non utilizzare il contesto di sincronizzazione corrente (di solito in base al tipo di progetto - WPF vs ASP.NET per esempio) e invece di utilizzare quello predefinito, che utilizza il pool di thread).

Quindi, di nuovo, è un sacco di inganni del compilatore. Se guardi il codice generato è complicato ma dovresti essere in grado di vedere cosa sta facendo. Questi tipi di trasformazioni sono difficili, ma deterministiche e matematiche, motivo per cui è fantastico che il compilatore le esegua per noi.

PS Esiste un'eccezione all'esistenza di contesti di sincronizzazione predefiniti: le app della console non hanno un contesto di sincronizzazione predefinito. Controlla il blog di Stephen Toub per molte più informazioni. È un ottimo posto per cercare informazioni su asynce awaitin generale.


1
"Sta dicendo al sistema di non utilizzare il contesto di sincronizzazione predefinito e invece di utilizzare quello predefinito, che utilizza il pool di thread" puoi chiarire cosa intendi con questo? "non utilizzare il valore predefinito, utilizzare il valore predefinito"
Kroltan

3
Scusa, ho confuso la mia terminologia, aggiusterò il post. Fondamentalmente, non usare quello predefinito per l'ambiente in cui ti trovi, usa quello predefinito per .NET (cioè il pool di thread).
Chris Tavares

molto semplice, è stato in grado di capire, hai ottenuto il mio voto :)
Ehsan Sajjad

4

Normalmente, consiglierei di guardare il CIL, ma nel caso di questi è un disastro.

Questi due costrutti linguistici sono simili nel funzionamento, ma implementati in modo leggermente diverso. Fondamentalmente, è solo uno zucchero sintattico per una magia del compilatore, non c'è niente di pazzo / pericoloso a livello di assembly. Vediamoli brevemente.

yieldè un'affermazione più vecchia e più semplice, ed è uno zucchero sintattico per una macchina a stati di base. Un metodo che restituisce IEnumerable<T>o IEnumerator<T>può contenere un yield, che quindi trasforma il metodo in una fabbrica di macchine a stati. Una cosa che dovresti notare è che nessun codice nel metodo viene eseguito nel momento in cui lo chiami, se c'è un yieldinside. Il motivo è che il codice che scrivi viene traslocato nel IEnumerator<T>.MoveNextmetodo, che controlla lo stato in cui si trova ed esegue la parte corretta del codice. yield return x;viene quindi convertito in qualcosa di similethis.Current = x; return true;

Se fai qualche riflessione, puoi facilmente ispezionare la macchina a stati costruita e i suoi campi (almeno uno per lo stato e per i locali). Puoi anche ripristinarlo se modifichi i campi.

awaitrichiede un po 'di supporto dalla libreria dei tipi e funziona in modo leggermente diverso. Richiede un argomento Tasko Task<T>, quindi restituisce il suo valore se l'attività è completata o registra una continuazione tramite Task.GetAwaiter().OnCompleted. La piena implementazione del sistema async/ awaitrichiederebbe troppo tempo per essere spiegata, ma non è neanche così mistica. Crea anche una macchina a stati e la passa lungo la continuazione a OnCompleted . Se l'attività viene completata, utilizza il risultato nella continuazione. L'implementazione del awaiter decide come invocare la continuazione. In genere utilizza il contesto di sincronizzazione del thread chiamante.

Entrambi yielde awaitdevono suddividere il metodo in base alla loro occorrenza per formare una macchina a stati, con ogni ramo della macchina che rappresenta ogni parte del metodo.

Non dovresti pensare a questi concetti nei termini di "livello inferiore" come stack, thread, ecc. Queste sono astrazioni e il loro funzionamento interno non richiede alcun supporto da parte del CLR, è solo il compilatore che fa la magia. Questo è molto diverso dalle coroutine di Lua, che hanno il supporto del runtime, o dal longjmp di C , che è solo magia nera.


5
Nota a margine : awaitnon deve fare un compito . Qualunque cosa INotifyCompletion GetAwaiter()sia sufficiente. Un po 'simile a come foreachnon serve IEnumerable, niente IEnumerator GetEnumerator()è sufficiente.
IllidanS4 vuole che Monica torni il
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.