Skip to content
← Todas las publicaciones
· Delta1 Labs Decompilación.NETAnálisis a fondo

Decompilar iteradores async: cuando await y yield comparten una máquina de estado

Un método async IAsyncEnumerable mezcla await y yield return — así que el compilador genera una única máquina de estado que es a la vez una máquina de estado async y un iterador. Aquí tienes la forma a la que se reduce, cómo el campo de estado impulsa tanto las reanudaciones de await como los puntos de yield, y cómo un decompilador reconoce el patrón para reconstruir await foreach.

.NET tiene dos reescrituras del compilador famosas: async/await se convierte en una máquina de estado, y yield return se convierte en un iterador. Los iteradores async — async IAsyncEnumerable<T> con await foreach en el lado consumidor — son lo que ocurre cuando usas ambos en un método. El compilador no puede elegir una reescritura; genera una única máquina de estado que es a la vez una máquina de estado async y un iterador. Entender esa fusión es la clave para leer un await foreach decompilado, y enlaza los patrones de la máquina de estado async y el iterador yield que quizá ya conozcas.

El método que espera y produce

Aquí hay un iterador async de manual — espera E/S y produce resultados según llegan:

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

Dos suspensiones distintas viven en un método. await se aparca hasta que una tarea se completa; yield return se aparca hasta que el consumidor pide el siguiente elemento. Un método async sencillo solo tiene la primera; un iterador sencillo solo tiene la segunda. Un iterador async tiene que manejar ambas — y lo hace con un campo de estado y un bucle de despacho.

La máquina de estado fusionada

Decompilado sin reconocimiento, ReadBatchesAsync es un stub que construye una struct generada y la devuelve. Esa struct — convencionalmente <ReadBatchesAsync>d__0 — implementa toda la superficie async e iterador:

[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) { /* ... */ }
}

Fíjate en el conjunto de campos: <>1__state del mundo del iterador, <>t__builder y un TaskAwaiter aparcado del mundo async, <>2__current para el valor producido, y un ManualResetValueTaskSourceCore<bool> que respalda el ValueTask<bool> que cada MoveNextAsync devuelve. Este único tipo lleva la maquinaria de ambas reescrituras.

Un campo de estado, dos tipos de suspensión

El corazón es MoveNext, y lo que hay que entender es que <>1__state codifica puntos de reanudación para ambos, await y yield. Una forma simplificada:

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

Lee los dos estilos de suspensión en paralelo. En un await, el método almacena el awaiter, fija un estado, registra una continuación con el builder, y retorna — será reentrado cuando la tarea se complete. En un yield return, fija Current, fija un estado distinto, completa la promise con true, y retorna — será reentrado cuando el consumidor llame a MoveNextAsync de nuevo. El mismo MoveNext, el mismo campo de estado, dos razones para salir y dos formas de volver. Caerse del final completa la promise con false, que es cómo await foreach aprende que la secuencia terminó — el equivalente, en iterador async, de un iterador sencillo devolviendo false desde MoveNext.

MoveNextswitch estadoawait → aparca en builderstate=0; reanuda al acabar tareayield return → Currentstate=1; ValueTask=truereentrarsiguiente MoveNextfinfalse

Cómo lo reconstruye un decompilador

La reconstrucción es reconocimiento de patrones sobre la huella fusionada. Un tipo anidado [CompilerGenerated] que implementa tanto IAsyncStateMachine como IAsyncEnumerator<T>, con un campo AsyncIteratorMethodBuilder y una promise ManualResetValueTaskSourceCore<bool>, es inequívocamente un iterador async — no puede ser un método async sencillo (sin superficie de enumerador) ni un iterador sencillo (sin builder/awaiter). Dado eso, el decompilador:

  1. Des-eleva los campos (<page>5__1 → local page, <>3__ct → el parámetro [EnumeratorCancellation]) y recupera <>2__current como el valor producido.
  2. Pliega el despacho de vuelta en un cuerpo lineal, clasificando cada punto de reanudación por cómo se aparcó: un estado alcanzado mediante AwaitUnsafeOnCompleted se vuelve un await; un estado alcanzado mediante promise.SetResult(true) se vuelve un yield return; el SetResult(false) terminal se vuelve el yield break implícito.
  3. En el lado del consumidor, reconoce la forma GetAsyncEnumerator / while (await MoveNextAsync()) / Current / finally { await DisposeAsync() } y la reconstruye como await foreach.

El resultado es tu método ReadBatchesAsync y un await foreach limpio en el sitio de llamada — no un enumerador de 150 líneas. Glass.NET hace esta reconstrucción por defecto y te deja bajar al <ReadBatchesAsync>d__0 crudo cuando necesitas ver la maquinaria real.

Por qué vale la pena ver la máquina

El código reconstruido es casi siempre lo que quieres, pero la máquina de estado fusionada explica comportamientos que despistan. Ejecución diferida y perezosa: como un iterador sencillo, nada corre hasta el primer MoveNextAsync, así que una excepción «en» el método aflora solo cuando empiezas el await foreach. Plomería de la cancelación: el token [EnumeratorCancellation] se vuelve un campo enhebrado en cada await, que es por qué olvidar ese atributo descarta silenciosamente el token que WithCancellation pasa — visible de inmediato en cuanto ves el cableado del campo. Y consumo único: el enumerador es la instancia de la máquina de estado, así que iterarlo dos veces reutiliza una máquina agotada. Cuando un stream async se porta mal, reconocer el par IAsyncStateMachine-más-IAsyncEnumerator y leer los dos estilos de suspensión en MoveNext convierte «¿por qué hizo eso mi stream?» en algo que puedes simplemente leer del tipo generado.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.