c# - promises - como funciona async await javascript
¿Por qué Enumerator.MoveNext no funciona como lo esperaba cuando se usa con y async-await? (2)
Me gustaría enumerar a través de una List<int>
y llamar a un método asíncrono.
Si hago esto de esta manera:
public async Task NotWorking() {
var list = new List<int> {1, 2, 3};
using (var enumerator = list.GetEnumerator()) {
Trace.WriteLine(enumerator.MoveNext());
Trace.WriteLine(enumerator.Current);
await Task.Delay(100);
}
}
el resultado es:
True
0
pero espero que sea:
True
1
Si quito el using
o la await Task.Delay(100)
:
public void Working1() {
var list = new List<int> {1, 2, 3};
using (var enumerator = list.GetEnumerator()) {
Trace.WriteLine(enumerator.MoveNext());
Trace.WriteLine(enumerator.Current);
}
}
public async Task Working2() {
var list = new List<int> {1, 2, 3};
var enumerator = list.GetEnumerator();
Trace.WriteLine(enumerator.MoveNext());
Trace.WriteLine(enumerator.Current);
await Task.Delay(100);
}
La salida es la esperada:
True
1
¿Alguien puede explicarme ese comportamiento?
Aquí está el corto de este problema. Una explicación más larga sigue.
-
List<T>.GetEnumerator()
devuelve una estructura, un tipo de valor. - Esta estructura es mutable ( siempre una receta para el desastre )
- Cuando el
using () {}
está presente, la estructura se almacena en un campo en la clase subyacente generada para manejar la parte deawait
. - Al llamar a
.MoveNext()
través de este campo, se carga una copia del valor del campo desde el objeto subyacente, por lo que es como si nunca se.Current
llamado aMoveNext
cuando se lee el código.Current
Como Marc mencionó en los comentarios, ahora que conoce el problema, una simple "solución" es volver a escribir el código para encuadrar explícitamente la estructura, esto asegurará que la estructura mutable sea la misma que se usa en todas partes en este código, en lugar de Copias nuevas siendo mutadas por todo el lugar.
using (IEnumerator<int> enumerator = list.GetEnumerator()) {
Entonces, lo que realmente pasa aquí.
La naturaleza async
/ a la await
de un método hace algunas cosas a un método. Específicamente, todo el método se eleva a una nueva clase generada y se convierte en una máquina de estado.
En cualquier lugar que vea a la await
, el método es una especie de "división", por lo que el método debe ejecutarse de la siguiente manera:
- Llamar parte inicial, hasta la primera espera.
- La siguiente parte tendrá que ser manejada por un
MoveNext
similar a unIEnumerator
- La siguiente parte, si corresponde, y todas las partes subsiguientes, son manejadas por esta parte
MoveNext
Este método MoveNext
se genera en esta clase y el código del método original se coloca dentro de ella, poco a poco para que se ajuste a los diversos puntos de secuencia en el método.
Como tal, cualquier variable local del método debe sobrevivir de una llamada a este método MoveNext
a la siguiente, y se "elevan" a esta clase como campos privados.
La clase en el ejemplo puede entonces ser reescrita de manera muy simple a algo como esto:
public class <NotWorking>d__1
{
private int <>1__state;
// .. more things
private List<int>.Enumerator enumerator;
public void MoveNext()
{
switch (<>1__state)
{
case 0:
var list = new List<int> {1, 2, 3};
enumerator = list.GetEnumerator();
<>1__state = 1;
break;
case 1:
var dummy1 = enumerator;
Trace.WriteLine(dummy1.MoveNext());
var dummy2 = enumerator;
Trace.WriteLine(dummy2.Current);
<>1__state = 2;
break;
Este código no se encuentra cerca del código correcto , pero lo suficientemente cerca para este propósito.
El problema aquí es el segundo caso. Por alguna razón, el código generado lee este campo como una copia, y no como una referencia al campo. Como tal, la llamada a .MoveNext()
se realiza en esta copia. El valor del campo original se deja como está, por lo que cuando se lee .Current
, se devuelve el valor predeterminado original, que en este caso es 0
.
Así que echemos un vistazo a la IL generada de este método. Ejecuté el método original (solo cambiando Trace
to Debug
) en LINQPad ya que tiene la capacidad de volcar el IL generado.
No publicaré el código completo de IL aquí, pero encontremos el uso del enumerador:
Aquí está var enumerator = list.GetEnumerator()
:
IL_005E: ldfld UserQuery+<NotWorking>d__1.<list>5__2
IL_0063: callvirt System.Collections.Generic.List<System.Int32>.GetEnumerator
IL_0068: stfld UserQuery+<NotWorking>d__1.<enumerator>5__3
Y aquí está la llamada a MoveNext
:
IL_007F: ldarg.0
IL_0080: ldfld UserQuery+<NotWorking>d__1.<enumerator>5__3
IL_0085: stloc.3 // CS$0$0001
IL_0086: ldloca.s 03 // CS$0$0001
IL_0088: call System.Collections.Generic.List<System.Int32>+Enumerator.MoveNext
IL_008D: box System.Boolean
IL_0092: call System.Diagnostics.Debug.WriteLine
ldfld
aquí lee el valor del campo y empuja el valor en la pila. Luego, esta copia se almacena en una variable local del método .MoveNext()
, y esta variable local luego se muta a través de una llamada a .MoveNext()
.
Dado que el resultado final, ahora en esta variable local, es más nuevo almacenado nuevamente en el campo, el campo se deja como está.
Aquí hay un ejemplo diferente que hace que el problema sea "más claro" en el sentido de que el enumerador es una estructura que está oculta para nosotros:
async void Main()
{
await NotWorking();
}
public async Task NotWorking()
{
using (var evil = new EvilStruct())
{
await Task.Delay(100);
evil.Mutate();
Debug.WriteLine(evil.Value);
}
}
public struct EvilStruct : IDisposable
{
public int Value;
public void Mutate()
{
Value++;
}
public void Dispose()
{
}
}
Esto también dará salida a 0
.
Parece un error en el compilador anterior, posiblemente causado por alguna interferencia de las transformaciones de código realizadas en el uso y async.
El envío del compilador con VS2015 parece obtener esto correctamente.