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.
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:
- Des-eleva los campos (
<page>5__1→ localpage,<>3__ct→ el parámetro[EnumeratorCancellation]) y recupera<>2__currentcomo el valor producido. - Pliega el despacho de vuelta en un cuerpo lineal, clasificando cada punto de reanudación por cómo se aparcó: un estado alcanzado mediante
AwaitUnsafeOnCompletedse vuelve unawait; un estado alcanzado mediantepromise.SetResult(true)se vuelve unyield return; elSetResult(false)terminal se vuelve elyield breakimplícito. - En el lado del consumidor, reconoce la forma
GetAsyncEnumerator/while (await MoveNextAsync())/Current/finally { await DisposeAsync() }y la reconstruye comoawait 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.