C # 5 CTP asíncrono: ¿por qué el "estado" interno se establece en 0 en el código generado antes de la llamada EndAwait?

195

Ayer estaba dando una charla sobre la nueva función "asíncrona" de C #, en particular profundizando en el aspecto del código generado y the GetAwaiter()/ BeginAwait()/ EndAwait()llamadas.

Observamos con cierto detalle la máquina de estado generada por el compilador de C #, y había dos aspectos que no podíamos entender:

  • Por qué la clase generada contiene un Dispose()método y una $__disposingvariable, que nunca parecen usarse (y la clase no se implementa IDisposable).
  • Por qué la statevariable interna se establece en 0 antes de cualquier llamada a EndAwait(), cuando 0 normalmente parece significar "este es el punto de entrada inicial".

Sospecho que el primer punto podría responderse haciendo algo más interesante dentro del método asíncrono, aunque si alguien tiene más información, me alegraría escucharlo. Sin embargo, esta pregunta es más sobre el segundo punto.

Aquí hay una pieza muy simple de código de muestra:

using System.Threading.Tasks;

class Test
{
    static async Task<int> Sum(Task<int> t1, Task<int> t2)
    {
        return await t1 + await t2;
    }
}

... y aquí está el código que se genera para el MoveNext()método que implementa la máquina de estados. Esto se copia directamente desde Reflector. No he arreglado los nombres de variables indescriptibles:

public void MoveNext()
{
    try
    {
        this.$__doFinallyBodies = true;
        switch (this.<>1__state)
        {
            case 1:
                break;

            case 2:
                goto Label_00DA;

            case -1:
                return;

            default:
                this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
                this.<>1__state = 1;
                this.$__doFinallyBodies = false;
                if (this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate))
                {
                    return;
                }
                this.$__doFinallyBodies = true;
                break;
        }
        this.<>1__state = 0;
        this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();
        this.<a2>t__$await4 = this.t2.GetAwaiter<int>();
        this.<>1__state = 2;
        this.$__doFinallyBodies = false;
        if (this.<a2>t__$await4.BeginAwait(this.MoveNextDelegate))
        {
            return;
        }
        this.$__doFinallyBodies = true;
    Label_00DA:
        this.<>1__state = 0;
        this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();
        this.<>1__state = -1;
        this.$builder.SetResult(this.<1>t__$await1 + this.<2>t__$await3);
    }
    catch (Exception exception)
    {
        this.<>1__state = -1;
        this.$builder.SetException(exception);
    }
}

Es largo, pero las líneas importantes para esta pregunta son estas:

// End of awaiting t1
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

// End of awaiting t2
this.<>1__state = 0;
this.<2>t__$await3 = this.<a2>t__$await4.EndAwait();

En ambos casos, el estado cambia nuevamente antes de que se observe de manera obvia ... entonces, ¿por qué establecerlo en 0? Si MoveNext()se volviera a llamar en este punto (ya sea directamente o por medio Dispose), volvería a iniciar efectivamente el método asíncrono, lo que sería totalmente inapropiado por lo que puedo decir ... si MoveNext() se llama y no se llama, el cambio de estado es irrelevante.

¿Es esto simplemente un efecto secundario de que el compilador reutiliza el código de generación de bloque de iterador para asíncrono, donde puede tener una explicación más obvia?

Descargo de responsabilidad importante

Obviamente esto es solo un compilador CTP. Espero que las cosas cambien antes de la versión final, y posiblemente incluso antes de la próxima versión de CTP. Esta pregunta de ninguna manera trata de afirmar que esto es una falla en el compilador de C # o algo así. Solo estoy tratando de averiguar si hay una razón sutil para esto que me haya perdido :)

Jon Skeet
fuente
77
El compilador de VB produce una máquina de estado similar (no sé si eso se esperaba o no, pero VB no tenía bloques iteradores antes)
Damien_The_Unbeliever
1
@Rune: MoveNextDelegate es solo un campo de delegado que se refiere a MoveNext. Creo que está almacenado en caché para evitar crear una nueva Acción para pasar al camarero cada vez.
Jon Skeet
55
Creo que la respuesta es: este es un CTP. La parte más importante para el equipo fue conseguir esto y validar el diseño del lenguaje. Y lo hicieron increíblemente rápido. Debe esperar que la implementación enviada (de los compiladores, no MoveNext) difiera significativamente. Creo que Eric o Lucian volverán con una respuesta similar a la de que aquí no hay nada profundo, solo un comportamiento / error que no importa en la mayoría de los casos y nadie se dio cuenta. Porque es un CTP.
Chris Burrows
2
@Stilgar: Acabo de comprobar con ildasm, y realmente está haciendo esto.
Jon Skeet
3
@ JonSkeet: Observe cómo nadie vota las respuestas. El 99% de nosotros realmente no podemos decir si la respuesta suena correcta.
the_drow

Respuestas:

71

Bien, finalmente tengo una respuesta real. Lo resolví por mi cuenta, pero solo después de que Lucian Wischik de la parte del equipo de VB confirmó que realmente hay una buena razón para ello. Muchas gracias a él, y por favor visite su blog , que es genial.

El valor 0 es aquí sólo es especial porque es no un estado válido, que es posible que en justo antes de la awaiten un caso normal. En particular, no es un estado que la máquina de estado pueda terminar probando en otro lugar. Creo que usar cualquier valor no positivo funcionaría igual de bien: -1 no se usa para esto ya que es lógicamente incorrecto, ya que -1 normalmente significa "terminado". Podría argumentar que estamos dando un significado adicional al estado 0 en este momento, pero en última instancia, realmente no importa. El punto de esta pregunta era descubrir por qué se está estableciendo el estado.

El valor es relevante si la espera finaliza en una excepción que se detecta. Podemos terminar volviendo a la misma declaración de espera nuevamente, pero no debemos estar en el estado que significa "Estoy a punto de volver de esa espera", ya que de lo contrario se omitiría todo tipo de código. Es más simple mostrar esto con un ejemplo. Tenga en cuenta que ahora estoy usando el segundo CTP, por lo que el código generado es ligeramente diferente al de la pregunta.

Aquí está el método asíncrono:

static async Task<int> FooAsync()
{
    var t = new SimpleAwaitable();

    for (int i = 0; i < 3; i++)
    {
        try
        {
            Console.WriteLine("In Try");
            return await t;
        }                
        catch (Exception)
        {
            Console.WriteLine("Trying again...");
        }
    }
    return 0;
}

Conceptualmente, SimpleAwaitablepuede ser cualquier espera, tal vez una tarea, tal vez otra cosa. A los fines de mis pruebas, siempre devuelve false para IsCompletedy arroja una excepción GetResult.

Aquí está el código generado para MoveNext :

public void MoveNext()
{
    int returnValue;
    try
    {
        int num3 = state;
        if (num3 == 1)
        {
            goto Label_ContinuationPoint;
        }
        if (state == -1)
        {
            return;
        }
        t = new SimpleAwaitable();
        i = 0;
      Label_ContinuationPoint:
        while (i < 3)
        {
            // Label_ContinuationPoint: should be here
            try
            {
                num3 = state;
                if (num3 != 1)
                {
                    Console.WriteLine("In Try");
                    awaiter = t.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        state = 1;
                        awaiter.OnCompleted(MoveNextDelegate);
                        return;
                    }
                }
                else
                {
                    state = 0;
                }
                int result = awaiter.GetResult();
                awaiter = null;
                returnValue = result;
                goto Label_ReturnStatement;
            }
            catch (Exception)
            {
                Console.WriteLine("Trying again...");
            }
            i++;
        }
        returnValue = 0;
    }
    catch (Exception exception)
    {
        state = -1;
        Builder.SetException(exception);
        return;
    }
  Label_ReturnStatement:
    state = -1;
    Builder.SetResult(returnValue);
}

Tuve que moverme Label_ContinuationPointpara que sea un código válido; de lo contrario, no está en el alcance de la gotodeclaración, pero eso no afecta la respuesta.

Piensa en lo que sucede cuando GetResultlanza su excepción. Pasaremos por el bloque de captura, incrementaremos iy luego volveremos a dar la vuelta (suponiendo ique todavía sea menor que 3). Todavía estamos en el estado que teníamos antes de la GetResultllamada ... pero cuando entramos en el trybloque debemos imprimir "In Try" y GetAwaitervolver a llamar ... y solo haremos eso si el estado no es 1. Sin elstate = 0 asignación, usará al camarero existente y saltará la Console.WriteLinellamada.

Es un código bastante tortuoso para trabajar, pero eso solo muestra el tipo de cosas en las que el equipo tiene que pensar. Me alegro de no ser responsable de implementar esto :)

Jon Skeet
fuente
8
@Shekhar_Pro: Sí, es un goto. Debería esperar ver muchas declaraciones goto en máquinas de estado autogeneradas :)
Jon Skeet
12
@Shekhar_Pro: dentro del código escrito manualmente, es porque hace que el código sea difícil de leer y seguir. Sin embargo, nadie lee el código autogenerado, excepto los tontos como yo que lo descompilan :)
Jon Skeet
Entonces, ¿qué no sucederá cuando estamos a la espera de nuevo después de una excepción? Comenzamos todo de nuevo?
configurador
1
@configurator: llama a GetAwaiter en espera, que es lo que esperaría que hiciera.
Jon Skeet
Los gotos no siempre hacen que el código sea más difícil de leer. De hecho, a veces incluso tienen sentido usar (sacrilegio para decir, lo sé). Por ejemplo, a veces puede que necesite romper múltiples bucles anidados. La característica menos utilizada de goto (y el uso más feo de la OMI) es provocar que las declaraciones de cambio se conecten en cascada. En una nota separada, recuerdo un día y una época en que los gotos eran la base principal de algunos lenguajes de programación y por eso me doy cuenta por qué la simple mención de goto hace que los desarrolladores se estremezcan. Pueden poner las cosas feas si se usan mal.
Ben Lesh
5

si se mantuvo en 1 (primer caso) recibiría una llamada EndAwaitsin una llamada a BeginAwait. Si se mantiene en 2 (segundo caso) obtendría el mismo resultado solo en el otro camarero.

Supongo que llamar a BeginAwait devuelve falso si ya se ha iniciado (una suposición desde mi lado) y mantiene el valor original para devolver en EndAwait. Si ese es el caso, funcionaría correctamente, mientras que si lo configura en -1, podría tener un no inicializado this.<1>t__$await1para el primer caso.

Sin embargo, esto supone que BeginAwaiter en realidad no iniciará la acción en ninguna llamada después de la primera y que devolverá falso en esos casos. Comenzar, por supuesto, sería inaceptable ya que podría tener efectos secundarios o simplemente dar un resultado diferente. También se supone que EndAwaiter siempre devolverá el mismo valor sin importar cuántas veces se llame y que se puede llamar cuando BeginAwait devuelve falso (según el supuesto anterior)

Parecería ser una protección contra las condiciones de la carrera Si alineamos las declaraciones donde movenext es llamado por un hilo diferente después del estado = 0 en las preguntas, se vería algo así como el siguiente

this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.<>1__state = 0;

//second thread
this.<a1>t__$await2 = this.t1.GetAwaiter<int>();
this.<>1__state = 1;
this.$__doFinallyBodies = false;
this.<a1>t__$await2.BeginAwait(this.MoveNextDelegate)
this.$__doFinallyBodies = true;
this.<>1__state = 0;
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

//other thread
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

Si las suposiciones anteriores son correctas, se realiza un trabajo innecesario, como obtener sawiater y reasignar el mismo valor a <1> t __ $ await1. Si el estado se mantuviera en 1, la última parte sería:

//second thread
//I suppose this un matched call to EndAwait will fail
this.<1>t__$await1 = this.<a1>t__$await2.EndAwait();

Además, si se estableció en 2, la máquina de estado supondría que ya había obtenido el valor de la primera acción que sería falso y que una variable (potencialmente) no asignada se utilizaría para calcular el resultado

Runa FS
fuente
Tenga en cuenta que el estado no se está utilizando realmente entre la asignación a 0 y la asignación a un valor más significativo. Si está destinado a protegerse contra las condiciones de la carrera, esperaría que otro valor indique que, por ejemplo, -2, con una verificación al comienzo de MoveNext para detectar un uso inapropiado. Tenga en cuenta que una sola instancia nunca debería ser utilizada por dos hilos a la vez de todos modos; está destinada a dar la ilusión de una sola llamada a un método sincrónico que logra "pausar" de vez en cuando.
Jon Skeet
@ Jon Estoy de acuerdo en que no debería ser un problema con una condición de carrera en el caso asíncrono, sino que podría estar en un bloque de iteración y podría ser un sobrante
Rune FS
@ Tony: Creo que esperaré hasta que salga el próximo CTP o beta, y comprobaré ese comportamiento.
Jon Skeet
1

¿Podría tener algo que ver con las llamadas asíncronas apiladas / anidadas? ..

es decir:

async Task m1()
{
    await m2;
}

async Task m2()
{
    await m3();
}

async Task m3()
{
Thread.Sleep(10000);
}

¿Se llama al delegado movenext varias veces en esta situación?

¿Solo un despeje?

GaryMcAllister
fuente
Habría tres clases diferentes generadas en ese caso. MoveNext()sería llamado una vez en cada uno de ellos.
Jon Skeet
0

Explicación de los estados actuales:

posibles estados:

  • 0 Inicializado (creo que sí) o esperando el final de la operación
  • > 0 acaba de llamarse MoveNext, eligiendo el siguiente estado
  • -1 terminó

¿Es posible que esta implementación solo quiera asegurar que si ocurre otro Llamado a MoveNext desde donde sea (mientras espera) reevaluará nuevamente toda la cadena de estado desde el principio, para reevaluar resultados que, mientras tanto, podrían estar desactualizados?

Fixagon
fuente
Pero, ¿por qué querría comenzar desde el principio? Es casi seguro que no es lo que realmente desearía que ocurriera: desearía que se lanzara una excepción, porque nada más debería llamar a MoveNext.
Jon Skeet