Skip to content
← Tous les articles
· Delta1 Labs Décompilation.NETAnalyse approfondie

Décompiler les itérateurs async : quand await et yield partagent une machine à états

Une méthode async IAsyncEnumerable mêle await et yield return — le compilateur génère donc une seule machine à états qui est à la fois une machine à états async et un itérateur. Voici la forme vers laquelle elle est abaissée, comment le champ d'état pilote à la fois les reprises d'await et les points de yield, et comment un décompilateur reconnaît le motif pour reconstruire await foreach.

.NET a deux réécritures de compilateur célèbres : async/await devient une machine à états, et yield return devient un itérateur. Les itérateurs async — async IAsyncEnumerable<T> avec await foreach du côté consommateur — sont ce qui se passe quand vous utilisez les deux dans une méthode. Le compilateur ne peut pas choisir une réécriture ; il génère une seule machine à états qui est à la fois une machine à états async et un itérateur. Comprendre cette fusion est la clé pour lire un await foreach décompilé, et cela relie les motifs de la machine à états async et de l’itérateur yield que vous connaissez peut-être déjà.

La méthode qui attend et produit

Voici un itérateur async d”école — il attend des E/S et produit des résultats au fur et à mesure :

public async IAsyncEnumerable<int> ReadBatchesAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int page = 0; ; page++)
    {
        var batch = await FetchPageAsync(page, ct);   // suspend on a Task
        if (batch.Count == 0)
            yield break;
        foreach (var item in batch)
            yield return item;                        // suspend on the consumer
    }
}

Deux suspensions différentes vivent dans une méthode. await se gare jusqu”à ce qu”une tâche se termine ; yield return se gare jusqu”à ce que le consommateur demande l”élément suivant. Une méthode async simple n”a que la première ; un itérateur simple n”a que la seconde. Un itérateur async doit gérer les deux — et il le fait avec un champ d”état et une boucle de répartition.

La machine à états fusionnée

Décompilée sans reconnaissance, ReadBatchesAsync est un stub qui construit une struct générée et la retourne. Cette struct — par convention <ReadBatchesAsync>d__0 — implémente toute la surface async et itérateur :

[CompilerGenerated]
private sealed class <ReadBatchesAsync>d__0 :
    IAsyncStateMachine,              // async machinery
    IAsyncEnumerable<int>,           // can hand out an enumerator
    IAsyncEnumerator<int>,           // IS the enumerator
    IValueTaskSource<bool>, IValueTaskSource   // backs the ValueTasks
{
    public int <>1__state;                               // drives BOTH await and yield resumes
    public AsyncIteratorMethodBuilder <>t__builder;      // runs MoveNext, coordinates completion
    public ManualResetValueTaskSourceCore<bool> <>v__promise; // the ValueTask<bool> the consumer awaits
    private CancellationToken <>3__ct;
    private int <>2__current;                            // the value Current returns
    public int <page>5__1;                               // hoisted local
    private TaskAwaiter<Batch> <>u__1;                   // a parked awaiter

    int IAsyncEnumerator<int>.Current => <>2__current;
    ValueTask<bool> IAsyncEnumerator<int>.MoveNextAsync() { /* kicks the builder → MoveNext */ }
    void IAsyncStateMachine.MoveNext() { /* the rewritten body — the dispatch loop */ }
    ValueTask IAsyncEnumerator<int>.DisposeAsync() { /* ... */ }
    IAsyncEnumerator<int> IAsyncEnumerable<int>.GetAsyncEnumerator(CancellationToken ct) { /* ... */ }
}

Notez l”ensemble de champs : <>1__state du monde de l”itérateur, <>t__builder et un TaskAwaiter garé du monde async, <>2__current pour la valeur produite, et un ManualResetValueTaskSourceCore<bool> qui adosse le ValueTask<bool> que chaque MoveNextAsync retourne. Ce seul type porte la machinerie des deux réécritures.

Un champ d’état, deux types de suspension

Le cœur, c”est MoveNext, et ce qu”il faut comprendre est que <>1__state encode des points de reprise pour les deux, await et yield. Une forme simplifiée :

void IAsyncStateMachine.MoveNext()
{
    try
    {
        switch (<>1__state)
        {
            case 0:  goto resume_after_await;   // woke up because the awaited task finished
            case 1:  goto resume_after_yield;   // woke up because the consumer asked for the next item
            default: <page>5__1 = 0; break;     // first entry
        }

        // ... run the loop body ...
        // await FetchPageAsync(page, ct):
        var awaiter = FetchPageAsync(<page>5__1, <>3__ct).GetAwaiter();
        if (!awaiter.IsCompleted)
        {
            <>1__state = 0;                      // park HERE; resume at case 0
            <>u__1 = awaiter;
            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
            return;                              // give the thread back
        }
        // resume_after_await: value is ready, keep going...

        // yield return item:
        <>2__current = item;                     // publish the value
        <>1__state = 1;                          // park HERE; resume at case 1
        <>v__promise.SetResult(true);            // tell the consumer "got one"
        return;                                  // give control back to await foreach
        // resume_after_yield: consumer called MoveNextAsync again, continue the loop...
    }
    catch (Exception ex) { /* fault the promise */ }
    // fell off the end → <>v__promise.SetResult(false)  (that's yield break / done)
}

Lisez les deux styles de suspension côte à côte. À un await, la méthode stocke l”awaiter, fixe un état, enregistre une continuation auprès du builder, et retourne — elle sera réentrée quand la tâche se terminera. À un yield return, elle fixe Current, fixe un état différent, complète la promise avec true, et retourne — elle sera réentrée quand le consommateur rappellera MoveNextAsync. Le même MoveNext, le même champ d”état, deux raisons de partir et deux façons de revenir. Tomber de la fin complète la promise avec false, ce qui est la façon dont await foreach apprend que la séquence est terminée — l”équivalent, pour un itérateur async, d”un itérateur simple retournant false depuis MoveNext.

MoveNextswitch étatawait → gare sur builderstate=0; reprend à la fin de tâcheyield return → Currentstate=1; ValueTask=trueréentrerMoveNext suivantfinfalse

Comment un décompilateur le reconstruit

La reconstruction est de la reconnaissance de motif sur l”empreinte fusionnée. Un type imbriqué [CompilerGenerated] qui implémente à la fois IAsyncStateMachine et IAsyncEnumerator<T>, avec un champ AsyncIteratorMethodBuilder et une promise ManualResetValueTaskSourceCore<bool>, est sans ambiguïté un itérateur async — il ne peut pas être une méthode async simple (pas de surface d”énumérateur) ni un itérateur simple (pas de builder/awaiter). Cela posé, le décompilateur :

  1. Dé-hisse les champs (<page>5__1 → locale page, <>3__ct → le paramètre [EnumeratorCancellation]) et récupère <>2__current comme la valeur produite.
  2. Replie la répartition en un corps linéaire, classant chaque point de reprise selon la façon dont il a été garé : un état atteint via AwaitUnsafeOnCompleted devient un await ; un état atteint via promise.SetResult(true) devient un yield return ; le SetResult(false) terminal devient le yield break implicite.
  3. Côté consommateur, reconnaît la forme GetAsyncEnumerator / while (await MoveNextAsync()) / Current / finally { await DisposeAsync() } et la reconstruit en await foreach.

Le résultat est votre méthode ReadBatchesAsync et un await foreach propre au site d”appel — pas un énumérateur de 150 lignes. Glass.NET fait cette reconstruction par défaut et vous laisse descendre au <ReadBatchesAsync>d__0 brut quand vous devez voir la machinerie réelle.

Pourquoi cela vaut la peine de voir la machine

Le code reconstruit est presque toujours ce que vous voulez, mais la machine à états fusionnée explique des comportements qui déroutent. Exécution différée et paresseuse : comme un itérateur simple, rien ne tourne avant le premier MoveNextAsync, donc une exception « dans » la méthode ne surgit qu”une fois le await foreach commencé. Plomberie de l”annulation : le token [EnumeratorCancellation] devient un champ tissé dans chaque await, c”est pourquoi oublier cet attribut jette silencieusement le token que WithCancellation passe — visible aussitôt que vous voyez le câblage du champ. Et consommation unique : l”énumérateur est l”instance de la machine à états, donc l”itérer deux fois réutilise une machine épuisée. Quand un flux async se comporte mal, reconnaître la paire IAsyncStateMachine-plus-IAsyncEnumerator et lire les deux styles de suspension dans MoveNext transforme « pourquoi mon flux a-t-il fait ça ? » en quelque chose que vous pouvez simplement lire sur le type généré.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.