¿Cómo rendimiento y espera implementar el flujo de control en .NET?

105

Según entiendo la yieldpalabra clave, si se usa desde el interior de un bloque de iterador, devuelve el flujo de control al código de llamada, y cuando se vuelve a llamar al iterador, continúa donde lo dejó.

Además, awaitno solo espera a la persona que llama, sino que devuelve el control a la persona que llama, solo para continuar donde lo dejó cuando la persona que llama awaitsel método.

En otras palabras, no hay hilo , y la "concurrencia" de async y await es una ilusión causada por un flujo de control inteligente, cuyos detalles están ocultos por la sintaxis.

Ahora, soy un ex programador en ensamblador y estoy muy familiarizado con punteros de instrucción, pilas, etc. y entiendo cómo funcionan los flujos normales de control (subrutina, recursividad, bucles, ramas). Pero estos nuevos constructos ... no los entiendo.

Cuando awaitse alcanza an , ¿cómo sabe el tiempo de ejecución qué parte de código debe ejecutarse a continuación? ¿Cómo sabe cuándo puede reanudarse donde lo dejó y cómo recuerda dónde? ¿Qué sucede con la pila de llamadas actual? ¿Se guarda de alguna manera? ¿Qué pasa si el método de llamada hace otras llamadas al método antes de que awaitsea? ¿Y cómo diablos funcionaría el tiempo de ejecución a través de todo esto en el caso de una excepción y una pila desenrollada?

Cuando yieldse alcanza, ¿cómo realiza el tiempo de ejecución un seguimiento del punto donde se deben recoger las cosas? ¿Cómo se conserva el estado del iterador?

John Wu
fuente
4
Usted puede tomar un vistazo al código generado en el TryRoslyn compilador en línea
Xánatos
1
Es posible que desee consultar la serie de artículos Eduasync de Jon Skeet.
Leonid Vasilev
Lectura interesante relacionada: stackoverflow.com/questions/37419572/…
Jason C

Respuestas:

115

Responderé a sus preguntas específicas a continuación, pero probablemente haría bien en simplemente leer mis extensos artículos sobre cómo diseñamos rendimiento y espera.

https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/

https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/

https://blogs.msdn.microsoft.com/ericlippert/tag/async/

Algunos de estos artículos están desactualizados ahora; el código generado es diferente en muchos sentidos. Pero estos sin duda le darán una idea de cómo funciona.

Además, si no comprende cómo se generan las lambdas como clases de cierre, comprenda eso primero . No obtendrá ni cara ni cola de async si no tiene lambdas abajo.

Cuando se alcanza una espera, ¿cómo sabe el tiempo de ejecución qué fragmento de código debe ejecutarse a continuación?

await se genera como:

if (the task is not completed)
  assign a delegate which executes the remainder of the method as the continuation of the task
  return to the caller
else
  execute the remainder of the method now

Eso es básicamente todo. Esperar es solo un regreso elegante.

¿Cómo sabe cuándo puede reanudarse donde lo dejó y cómo recuerda dónde?

Bueno, ¿cómo haces eso sin esperar? Cuando el método foo llama a la barra de métodos, de alguna manera recordamos cómo volver al medio de foo, con todos los locales de la activación de foo intactos, sin importar lo que haga la barra.

Ya sabes cómo se hace en ensamblador. Se inserta un registro de activación para foo en la pila; contiene los valores de los lugareños. En el momento de la llamada, la dirección de retorno en foo se inserta en la pila. Cuando se termina la barra, el puntero de pila y el puntero de instrucción se restablecen a donde deben estar y foo continúa desde donde lo dejó.

La continuación de una espera es exactamente la misma, excepto que el registro se coloca en el montón por la razón obvia de que la secuencia de activaciones no forma una pila .

El delegado que aguarda da como continuación a la tarea contiene (1) un número que es la entrada a una tabla de búsqueda que da el puntero de instrucción que necesita ejecutar a continuación, y (2) todos los valores de locales y temporales.

Hay algo de equipo adicional allí; por ejemplo, en .NET es ilegal bifurcarse en el medio de un bloque try, por lo que no puede simplemente pegar la dirección del código dentro de un bloque try en la tabla. Pero estos son detalles contables. Conceptualmente, el registro de activación simplemente se traslada al montón.

¿Qué sucede con la pila de llamadas actual? ¿Se guarda de alguna manera?

La información relevante en el registro de activación actual nunca se coloca en la pila en primer lugar; se asigna del montón desde el principio. (Bueno, los parámetros formales se pasan en la pila o en los registros normalmente y luego se copian en una ubicación del montón cuando comienza el método).

Los registros de activación de las personas que llaman no se almacenan; la espera probablemente volverá a ellos, recuerde, por lo que se tratará con normalidad.

Tenga en cuenta que esta es una diferencia importante entre el estilo de paso de continuación simplificado de await y las estructuras de llamada con continuación actual que se ven en lenguajes como Scheme. En esos idiomas, call-cc captura toda la continuación, incluida la continuación hacia las personas que llaman .

¿Qué pasa si el método de llamada realiza otras llamadas antes de esperar? ¿Por qué no se sobrescribe la pila?

Esas llamadas a métodos regresan, por lo que sus registros de activación ya no están en la pila en el momento de la espera.

¿Y cómo diablos funcionaría el tiempo de ejecución a través de todo esto en el caso de una excepción y una pila desenrollada?

En el caso de una excepción no detectada, la excepción se captura, se almacena dentro de la tarea y se vuelve a lanzar cuando se obtiene el resultado de la tarea.

¿Recuerdas toda esa contabilidad que mencioné antes? Obtener la semántica de excepciones correctamente fue un gran dolor, déjame decirte.

Cuando se alcanza el rendimiento, ¿cómo realiza el tiempo de ejecución un seguimiento del punto donde se deben recoger las cosas? ¿Cómo se conserva el estado del iterador?

Mismo camino. El estado de los locales se traslada al montón y MoveNextse almacena junto con los locales un número que representa la instrucción en la que debe reanudarse la próxima vez que se llame.

Y nuevamente, hay un montón de equipo en un bloque de iteradores para asegurarse de que las excepciones se manejen correctamente.

Eric Lippert
fuente
1
Debido a los antecedentes de los autores de la pregunta (ensamblador et al), posiblemente vale la pena mencionar que estos dos constructos no son posibles sin memoria administrada. sin la memoria administrada, intentar coordinar la vida útil de un cierre definitivamente lo haría tropezar con sus botas.
Jim
No se encuentra el enlace de todas las páginas (404)
Digital3D
Todos tus artículos no están disponibles ahora. ¿Podrías volver a publicarlos?
Michał Turczyn
1
@ MichałTurczyn: Todavía están en Internet; Microsoft sigue moviéndose donde está el archivo del blog. Los migraré gradualmente a mi sitio personal e intentaré actualizar estos enlaces cuando tenga tiempo.
Eric Lippert
38

yield es el más fácil de los dos, así que examinémoslo.

Digamos que tenemos:

public IEnumerable<int> CountToTen()
{
  for (int i = 1; i <= 10; ++i)
  {
    yield return i;
  }
}

Esto se compila un poco como si hubiéramos escrito:

// Deliberately use name that isn't valid C# to not clash with anything
private class <CountToTen> : IEnumerator<int>, IEnumerable<int>
{
    private int _i;
    private int _current;
    private int _state;
    private int _initialThreadId = CurrentManagedThreadId;

    public IEnumerator<CountToTen> GetEnumerator()
    {
        // Use self if never ran and same thread (so safe)
        // otherwise create a new object.
        if (_state != 0 || _initialThreadId != CurrentManagedThreadId)
        {
            return new <CountToTen>();
        }

        _state = 1;
        return this;
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public int Current => _current;

    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        switch(_state)
        {
            case 1:
                _i = 1;
                _current = i;
                _state = 2;
                return true;
            case 2:
                ++_i;
                if (_i <= 10)
                {
                    _current = _i;
                    return true;
                }
                break;
        }
        _state = -1;
        return false;
    }

    public void Dispose()
    {
      // if the yield-using method had a `using` it would
      // be translated into something happening here.
    }

    public void Reset()
    {
        throw new NotSupportedException();
    }
}

Por lo tanto, no es tan eficiente como una implementación escrita a mano de IEnumerable<int>y IEnumerator<int>(por ejemplo, probablemente no desperdiciaríamos tener un _state, _iy _currenten este caso) pero no está mal (el truco de reutilizarse cuando es seguro hacerlo en lugar de crear un nuevo object es bueno) y extensible para tratar con yieldmétodos de uso muy complicados .

Y por supuesto desde

foreach(var a in b)
{
  DoSomething(a);
}

Es lo mismo que:

using(var en = b.GetEnumerator())
{
  while(en.MoveNext())
  {
     var a = en.Current;
     DoSomething(a);
  }
}

Entonces el generado MoveNext()se llama repetidamente.

El asynccaso es prácticamente el mismo principio, pero con un poco de complejidad adicional. Para reutilizar un ejemplo de otro código de respuesta como:

private async Task LoopAsync()
{
    int count = 0;
    while(count < 5)
    {
       await SomeNetworkCallAsync();
       count++;
    }
}

Produce código como:

private struct LoopAsyncStateMachine : IAsyncStateMachine
{
  public int _state;
  public AsyncTaskMethodBuilder _builder;
  public TestAsync _this;
  public int _count;
  private TaskAwaiter _awaiter;
  void IAsyncStateMachine.MoveNext()
  {
    try
    {
      if (_state != 0)
      {
        _count = 0;
        goto afterSetup;
      }
      TaskAwaiter awaiter = _awaiter;
      _awaiter = default(TaskAwaiter);
      _state = -1;
    loopBack:
      awaiter.GetResult();
      awaiter = default(TaskAwaiter);
      _count++;
    afterSetup:
      if (_count < 5)
      {
        awaiter = _this.SomeNetworkCallAsync().GetAwaiter();
        if (!awaiter.IsCompleted)
        {
          _state = 0;
          _awaiter = awaiter;
          _builder.AwaitUnsafeOnCompleted<TaskAwaiter, TestAsync.LoopAsyncStateMachine>(ref awaiter, ref this);
          return;
        }
        goto loopBack;
      }
      _state = -2;
      _builder.SetResult();
    }
    catch (Exception exception)
    {
      _state = -2;
      _builder.SetException(exception);
      return;
    }
  }
  [DebuggerHidden]
  void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine param0)
  {
    _builder.SetStateMachine(param0);
  }
}

public Task LoopAsync()
{
  LoopAsyncStateMachine stateMachine = new LoopAsyncStateMachine();
  stateMachine._this = this;
  AsyncTaskMethodBuilder builder = AsyncTaskMethodBuilder.Create();
  stateMachine._builder = builder;
  stateMachine._state = -1;
  builder.Start(ref stateMachine);
  return builder.Task;
}

Es más complicado, pero un principio básico muy similar. La principal complicación adicional es que ahora GetAwaiter()se está utilizando. Si se marca algún tiempo awaiter.IsCompleted, regresa trueporque la tarea awaited ya está completa (por ejemplo, casos en los que podría regresar sincrónicamente), entonces el método sigue moviéndose a través de estados, pero de lo contrario, se configura como una devolución de llamada al espera.

Lo que sucede con eso depende del espera, en términos de qué desencadena la devolución de llamada (por ejemplo, finalización de E / S asíncrona, una tarea que se ejecuta en un subproceso que se completa) y qué requisitos existen para la clasificación a un subproceso en particular o la ejecución en un subproceso de grupo de subprocesos , qué contexto de la llamada original puede o no ser necesario y así sucesivamente. Sea lo que sea, algo en ese awaiter llamará al MoveNexty continuará con el siguiente trabajo (hasta el siguiente await) o terminará y regresará, en cuyo caso el Taskque está implementando se completará.

Jon Hanna
fuente
¿Te tomaste el tiempo para hacer tu propia traducción? O_O uao.
CoffeDeveloper
4
@DarioOO El primero que puedo hacer con bastante rapidez, habiendo hecho muchas traducciones de yielda enrollado a mano cuando hay un beneficio al hacerlo (generalmente como una optimización, pero queriendo asegurarme de que el punto de partida esté cerca del generado por el compilador por lo que nada se des-optimiza a través de malas suposiciones). El segundo se usó por primera vez en otra respuesta y había un par de lagunas en mi propio conocimiento en ese momento, por lo que me beneficié de completarlas mientras proporcionaba esa respuesta descompilando el código a mano.
Jon Hanna
13

Aquí ya hay un montón de excelentes respuestas; Solo voy a compartir algunos puntos de vista que pueden ayudar a formar un modelo mental.

Primero, asyncel compilador divide un método en varias partes; las awaitexpresiones son los puntos de fractura. (Esto es fácil de concebir para métodos simples; los métodos más complejos con bucles y manejo de excepciones también se rompen, con la adición de una máquina de estado más compleja).

En segundo lugar, awaitse traduce en una secuencia bastante simple; Me gusta la descripción de Lucian , que en palabras es más o menos "si el awaitable ya está completo, obtenga el resultado y continúe ejecutando este método; de lo contrario, guarde el estado de este método y regrese". (Utilizo terminología muy similar en mi asyncintroducción ).

Cuando se alcanza una espera, ¿cómo sabe el tiempo de ejecución qué fragmento de código debe ejecutarse a continuación?

El resto del método existe como una devolución de llamada para la que se espera (en el caso de las tareas, estas devoluciones de llamada son continuaciones). Cuando se completa el awaitable, invoca sus devoluciones de llamada.

Tenga en cuenta que la pila de llamadas no se guarda ni se restaura; las devoluciones de llamada se invocan directamente. En el caso de E / S superpuestas, se invocan directamente desde el grupo de subprocesos.

Esas devoluciones de llamada pueden continuar ejecutando el método directamente, o pueden programarlo para que se ejecute en otro lugar (por ejemplo, si se awaitcapturó una IU SynchronizationContexty se completó la E / S en el grupo de subprocesos).

¿Cómo sabe cuándo puede reanudarse donde lo dejó y cómo recuerda dónde?

Todo son solo devoluciones de llamada. Cuando se completa un awaitable, invoca sus devoluciones de llamada y se reanuda cualquier asyncmétodo que ya lo haya awaiteditado. La devolución de llamada salta al medio de ese método y tiene sus variables locales dentro del alcance.

Las devoluciones de llamada no se ejecutan en un hilo en particular y no se restaura su pila de llamadas.

¿Qué sucede con la pila de llamadas actual? ¿Se guarda de alguna manera? ¿Qué pasa si el método de llamada realiza otras llamadas antes de esperar? ¿Por qué no se sobrescribe la pila? ¿Y cómo diablos funcionaría el tiempo de ejecución a través de todo esto en el caso de una excepción y una pila desenrollada?

La pila de llamadas no se guarda en primer lugar; no es necesario.

Con el código síncrono, puede terminar con una pila de llamadas que incluye a todas las personas que llaman, y el tiempo de ejecución sabe dónde regresar usando eso.

Con el código asincrónico, puede terminar con un montón de punteros de devolución de llamada: arraigados en alguna operación de E / S que finaliza su tarea, que puede reanudar un asyncmétodo que finaliza su tarea, que puede reanudar un asyncmétodo que finaliza su tarea, etc.

Así, con el código sincrónico Ade llamadas Bde llamadas C, su pila de llamadas puede tener este aspecto:

A:B:C

mientras que el código asincrónico usa devoluciones de llamada (punteros):

A <- B <- C <- (I/O operation)

Cuando se alcanza el rendimiento, ¿cómo realiza el tiempo de ejecución un seguimiento del punto donde se deben recoger las cosas? ¿Cómo se conserva el estado del iterador?

Actualmente, de manera bastante ineficiente. :)

Funciona como cualquier otra lambda: la vida útil de las variables se extiende y las referencias se colocan en un objeto de estado que vive en la pila. El mejor recurso para todos los detalles de nivel profundo es la serie EduAsync de Jon Skeet .

Stephen Cleary
fuente
7

yieldy awaitson, aunque ambos se ocupan del control de flujo, dos cosas completamente diferentes. Así que los abordaré por separado.

El objetivo de yieldes facilitar la creación de secuencias perezosas. Cuando escribe un bucle de enumerador con una yielddeclaración, el compilador genera una tonelada de código nuevo que no ve. Debajo del capó, en realidad genera una clase completamente nueva. La clase contiene miembros que rastrean el estado del bucle y una implementación de IEnumerable para que cada vez que la llames MoveNextpase una vez más por ese bucle. Entonces, cuando haces un bucle foreach como este:

foreach(var item in mything.items()) {
    dosomething(item);
}

el código generado se parece a:

var i = mything.items();
while(i.MoveNext()) {
    dosomething(i.Current);
}

Dentro de la implementación de mything.items () hay un montón de código de máquina de estado que hará un "paso" del ciclo y luego regresará. Entonces, mientras lo escribe en la fuente como un bucle simple, bajo el capó no es un bucle simple. Así que el compilador engaña. Si quiere verse a sí mismo, saque ILDASM o ILSpy o herramientas similares y vea cómo se ve el IL generado. Debería ser instructivo.

asyncy await, por otro lado, son otra olla de pescado. Await es, en abstracto, una primitiva de sincronización. Es una forma de decirle al sistema "No puedo continuar hasta que esto esté hecho". Pero, como notó, no siempre hay un hilo involucrado.

Lo que está involucrado es algo llamado contexto de sincronización. Siempre hay uno dando vueltas. El trabajo del contexto de sincronización es programar las tareas que se esperan y sus continuaciones.

Cuando dices await thisThing(), suceden un par de cosas. En un método asincrónico, el compilador realmente divide el método en partes más pequeñas, cada parte es una sección "antes de una espera" y una sección "después de una espera" (o continuación). Cuando se ejecuta await, la tarea que se espera y la siguiente continuación, es decir, el resto de la función, se pasa al contexto de sincronización. El contexto se encarga de programar la tarea y, cuando termina, el contexto ejecuta la continuación, pasando el valor de retorno que desee.

El contexto de sincronización es libre de hacer lo que quiera siempre que programe cosas. Podría usar el grupo de subprocesos. Podría crear un hilo por tarea. Podría ejecutarlos sincrónicamente. Los diferentes entornos (ASP.NET frente a WPF) proporcionan diferentes implementaciones de contexto de sincronización que hacen cosas diferentes según lo que sea mejor para sus entornos.

(Bonificación: ¿alguna vez se preguntó qué .ConfigurateAwait(false)hace? Le dice al sistema que no use el contexto de sincronización actual (generalmente basado en su tipo de proyecto - WPF vs ASP.NET por ejemplo) y en su lugar use el predeterminado, que usa el grupo de subprocesos).

Así que, de nuevo, es un montón de trucos del compilador. Si observa el código generado, es complicado, pero debería poder ver lo que está haciendo. Este tipo de transformaciones son difíciles, pero deterministas y matemáticas, por lo que es genial que el compilador las haga por nosotros.

PD: Hay una excepción a la existencia de contextos de sincronización predeterminados: las aplicaciones de consola no tienen un contexto de sincronización predeterminado. Consulte el blog de Stephen Toub para obtener mucha más información. Es un gran lugar para buscar información asyncy awaiten general.

Chris Tavares
fuente
1
"Le está diciendo al sistema que no use el contexto de sincronización predeterminado y en su lugar use el predeterminado, que usa el grupo de subprocesos" ¿Puede aclarar lo que quiere decir con esto? "no usar predeterminado, usar predeterminado"
Kroltan
3
Lo siento, confundí mi terminología, arreglaré la publicación. Básicamente, no use el predeterminado para el entorno en el que se encuentra, use el predeterminado para .NET (es decir, el grupo de subprocesos).
Chris Tavares
muy simple, pude entender, obtuviste mi voto :)
Ehsan Sajjad
4

Normalmente, recomendaría mirar el CIL, pero en el caso de estos, es un desastre.

Estas dos construcciones de lenguaje funcionan de manera similar, pero se implementan de manera un poco diferente. Básicamente, es solo un azúcar sintáctico para la magia de un compilador, no hay nada loco / inseguro a nivel de ensamblador. Veámoslos brevemente.

yieldes una declaración más antigua y simple, y es un azúcar sintáctico para una máquina de estado básica. Un método que devuelve IEnumerable<T>o IEnumerator<T>puede contener un yield, que luego transforma el método en una fábrica de máquinas de estado. Una cosa que debe notar es que no se ejecuta ningún código en el método en el momento en que lo llama, si hay un yieldarchivo inside. La razón es que el código que escribe se transloca al IEnumerator<T>.MoveNextmétodo, que verifica el estado en el que se encuentra y ejecuta la parte correcta del código. yield return x;luego se convierte en algo parecido athis.Current = x; return true;

Si reflexiona un poco, puede inspeccionar fácilmente la máquina de estado construida y sus campos (al menos uno para el estado y para los locales). Incluso puede restablecerlo si cambia los campos.

awaitrequiere un poco de soporte de la biblioteca de tipos y funciona de manera algo diferente. Toma un argumento Tasko Task<T>, luego da como resultado su valor si la tarea se completa, o registra una continuación a través de Task.GetAwaiter().OnCompleted. La implementación completa del sistema async/ awaittomaría demasiado tiempo en explicarse, pero tampoco es tan mística. También crea una máquina de estado y la pasa a lo largo de la continuación a OnCompleted . Si la tarea se completa, utiliza su resultado en la continuación. La implementación del awaiter decide cómo invocar la continuación. Normalmente utiliza el contexto de sincronización del hilo de llamada.

Ambos yieldy awaittienen que dividir el método en función de su ocurrencia para formar una máquina de estado, con cada rama de la máquina representando cada parte del método.

No debería pensar en estos conceptos en términos de "nivel inferior" como pilas, subprocesos, etc. Estas son abstracciones, y su funcionamiento interno no requiere ningún soporte de CLR, es solo el compilador el que hace la magia. Esto es tremendamente diferente de las corrutinas de Lua, que tienen el soporte del tiempo de ejecución, o del longjmp de C , que es solo magia negra.

IllidanS4 quiere que Monica vuelva
fuente
5
Nota al margen : awaitno es necesario realizar una tarea . Cualquier cosa con INotifyCompletion GetAwaiter()es suficiente. Un poco similar a cómo foreachno necesita IEnumerable, cualquier cosa con IEnumerator GetEnumerator()es suficiente.
IllidanS4 quiere a Monica de vuelta