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

Como entiendo la palabra clave yield , si se usa desde dentro de un bloque iterador, devuelve flujo de control al código de llamada, y cuando se llama nuevamente al iterador, continúa donde lo dejó.

Además, await no solo espera al destinatario, sino que devuelve el control a la persona que llama, solo para continuar donde lo dejó cuando la persona que llama awaits el método.

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

Ahora, soy un antiguo progtwigdor de ensambles y estoy muy familiarizado con los indicadores de instrucciones, las stacks, etc., y entiendo cómo funcionan los flujos de control normales (subrutinas, recursiones, bucles, twigs). Pero estos nuevos constructos– No los entiendo.

Cuando se llega a una await , ¿cómo sabe el tiempo de ejecución qué pieza 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 stack de llamadas actual, de alguna manera se guarda? ¿Qué ocurre si el método de llamada realiza otras llamadas al método antes de que se await s– ¿por qué no sobrescribe la stack? ¿Y cómo diablos el tiempo de ejecución se abriría camino a través de todo esto en el caso de una excepción y una stack de desenrollar?

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

Contestaré a sus preguntas específicas a continuación, pero probablemente sea mejor que simplemente lea mis extensos artículos sobre cómo diseñamos el rendimiento y lo esperamos.

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 ciertamente le darán la idea de cómo funciona.

Además, si no comprende cómo se generan lambdas como clases de cierre, comprenda eso primero . No podrás ver cara o cruz si no tienes lambdas.

Cuando se llega a una espera, ¿cómo sabe el tiempo de ejecución qué pieza 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 eso. Aguardar es solo una vuelta de lujo.

¿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 centro de foo, con todos los locales de la activación de foo intactos, sin importar qué barra haga.

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

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 stack .

El delegado que espera da como continuación a la tarea contiene (1) un número que es la entrada a una tabla de búsqueda que proporciona 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 ramificarse en el medio de un bloque de prueba, por lo que no puede simplemente insertar la dirección del código dentro de un bloque de prueba en la tabla. Pero estos son detalles de contabilidad. Conceptualmente, el registro de activación simplemente se mueve al montón.

¿Qué sucede con la stack de llamadas actual, de alguna manera se guarda?

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

Los registros de activación de los llamantes no se almacenan; la espera probablemente regresará a ellos, recuerda, por lo que se tratarán normalmente.

Tenga en cuenta que esta es una diferencia fundamental entre el estilo de paso de continuación simplificado de await y las estructuras reales de call-with-current-continution que ve en lenguajes como Scheme. En esos idiomas, la continuación completa, incluida la continuación de regreso a las personas que llaman, es capturada por call-cc .

¿Qué sucede si el método de llamada realiza otras llamadas a métodos antes de que se agote? ¿Por qué no sobrescribe la stack?

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

¿Y cómo diablos el tiempo de ejecución se abriría camino a través de todo esto en el caso de una excepción y una stack de desenrollar?

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 excepción correcta fue un gran dolor, déjame decirte.

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

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

Y de nuevo, hay un montón de herramientas en un bloque de iteradores para garantizar que las excepciones se manejen correctamente.

yield es el más fácil de los dos, así que vamos a examinarlo.

Digamos que tenemos:

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

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

 // Deliberately use name that isn't valid C# to not clash with anything private class  : IEnumerator, IEnumerable { private int _i; private int _current; private int _state; private int _initialThreadId = CurrentManagedThreadId; public IEnumerator GetEnumerator() { // Use self if never ran and same thread (so safe) // otherwise create a new object. if (_state != 0 || _initialThreadId != CurrentManagedThreadId) { return new (); } _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 e IEnumerator (p. Ej., Probablemente no desperdiciemos tener un _state , _i y _current en este caso), pero no está mal (el truco de la reutilización) si es seguro hacerlo en lugar de crear un nuevo objeto es bueno), y extensible para tratar métodos de yield 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); } } 

Luego se llama repetidamente al MoveNext() generado MoveNext() .

El caso async es más o menos el mismo principio, pero con un poco de complejidad extra. Para reutilizar un ejemplo de otra respuesta Código 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(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 tiene un principio básico muy similar. La principal complicación adicional es que ahora se está utilizando GetAwaiter() . Si se comprueba el tiempo de awaiter.IsCompleted , se devuelve true porque la tarea que se await ya se ha completado (por ejemplo, casos en los que podría regresar sincrónicamente), el método sigue moviéndose a través de los estados, pero se configura como una callback al awaiter.

Lo que sucede con eso depende del receptor, en términos de lo que desencadena la callback (por ejemplo, realización de E / S asincrónica, una tarea que se ejecuta en un hilo que finaliza) y qué requisitos existen para coordinar un hilo en particular o ejecutarse en un hilo de subprocesamiento , qué contexto de la llamada original puede o no ser necesario, etc. Sea lo que sea, aunque algo en ese estado llamará a MoveNext y continuará con la próxima pieza de trabajo (hasta la próxima await ) o terminará y regresará, en cuyo caso la Task que está implementando se completará.

Ya hay un montón de buenas respuestas aquí; Voy a compartir algunos puntos de vista que pueden ayudar a formar un modelo mental.

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

Segundo, await se 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 vuelva”. (Utilizo una terminología muy similar en mi introducción async ).

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

El rest del método existe como una callback para el que está a la espera (en el caso de las tareas, estas devoluciones de llamada son continuaciones). Cuando lo agotable se completa, invoca sus devoluciones de llamada.

Tenga en cuenta que la stack de llamadas no se guarda y 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 progtwigr su ejecución en otro lugar (por ejemplo, si el await capturó un UI SynchronizationContext y la E / S completada 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 cualquier método async que ya lo haya await se reanuda. La callback salta al medio de ese método y tiene sus variables locales en el scope.

Las devoluciones de llamada no ejecutan un hilo en particular, y no tienen su stack de llamadas restaurada.

¿Qué sucede con la stack de llamadas actual, de alguna manera se guarda? ¿Qué sucede si el método de llamada realiza otras llamadas a métodos antes de que se agote? ¿Por qué no sobrescribe la stack? ¿Y cómo diablos el tiempo de ejecución se abriría camino a través de todo esto en el caso de una excepción y una stack de desenrollar?

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

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

Con el código asíncrono, puede terminar con un montón de punteros de callback, enraizados en alguna operación de E / S que finaliza su tarea, que puede reanudar un método async que finaliza su tarea, que puede reanudar un método async que finaliza su tarea, etc. .

Entonces, con el código síncrono A llama B llama a C , su stack de llamadas puede verse así:

 A:B:C 

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

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

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

Actualmente, bastante ineficientemente. 🙂

Funciona como cualquier otra lambda: las duraciones variables se extienden y las referencias se colocan en un objeto de estado que se encuentra en la stack. El mejor recurso para todos los detalles de nivel profundo es la serie EduAsync de Jon Skeet .

yield y await son, mientras que ambos se ocupan del control de flujo, dos cosas completamente diferentes. Así que los abordaré por separado.

El objective del yield es facilitar la construcción de secuencias perezosas. Cuando escribe un bucle de enumerador con una statement de yield , el comstackdor 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 ciclo, y una implementación de IEnumerable para que cada vez que llame a MoveNext lo haga una vez más a través de ese ciclo. Entonces cuando haces un ciclo foreach como este:

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

el código generado se ve algo así como:

 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 escribes en la fuente como un simple bucle, debajo del capó no es un simple bucle. Así que el engaño del comstackdor. Si quiere verse a sí mismo, saque ILDASM o ILSpy o herramientas similares y vea cómo se ve la IL generada. Debería ser instructivo.

async y await , por otro lado, son una olla entera de pescado. Aguardar es, en abstracto, una primitiva de sincronización. Es una manera de decirle al sistema “No puedo continuar hasta que esto esté hecho”. Pero, como ha notado, 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 progtwigr las tareas que se esperan y sus continuaciones.

Cuando dices await thisThing() , suceden un par de cosas. En un método asíncrono, el comstackdor realmente corta el método en trozos más pequeños, cada fragmento 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 está esperando y la continuación siguiente, en otras palabras, el rest de la función, se pasa al contexto de sincronización. El contexto se ocupa de progtwigr 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 hilos. Podría crear un hilo por tarea. Podría ejecutarlos sincrónicamente. Los diferentes entornos (ASP.NET vs. WPF) proporcionan diferentes implementaciones de contexto de sincronización que hacen cosas diferentes basadas en lo que es mejor para sus entornos.

(Bono: ¿alguna vez se preguntó qué hace .ConfigurateAwait(false) ? Le está diciendo 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 hilos).

Entonces, de nuevo, son muchos trucos de comstackción. Si miras el código generado es complicado, pero deberías 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 comstackdor las haga por nosotros.

PD Hay una excepción a la existencia de contextos de sincronización predeterminados: las aplicaciones de la 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 sobre async y await en general.

Normalmente, recomiendo mirar el CIL, pero en el caso de estos, es un desastre.

Estos dos constructos de lenguaje son similares en funcionamiento, pero implementados de forma un poco diferente. Básicamente, es solo un azúcar sintáctico para una magia de comstackción, no hay nada loco / inseguro en el nivel de ensamblaje. Veámoslos brevemente.

yield es una statement más antigua y más simple, y es un azúcar sintáctico para una máquina de estado básica. Un método que devuelve IEnumerable o IEnumerator puede contener un yield , que luego transforma el método en una fábrica de máquina de estado. Una cosa que debes notar es que no se ejecuta ningún código en el método en el momento en que lo llamas, si hay un yield dentro. La razón es que el código que escribe se transloca al IEnumerator.MoveNext , 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 a esto. this.Current = x; return true; this.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.

await requiere un poco de soporte de la biblioteca de tipos, y funciona de forma algo diferente. Toma un argumento Task o Task , 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 / await llevaría demasiado tiempo explicarlo, pero tampoco es tan místico. También crea una máquina de estado y la pasa a lo largo de la continuación a OnCompleted . Si la tarea está completa, entonces usa su resultado en la continuación. La implementación del awaiter decide cómo invocar la continuación. Por lo general, utiliza el contexto de sincronización del hilo llamante.

Ambos yield y await tienen que dividir el método según su ocurrencia para formar una máquina de estado, con cada twig de la máquina representando cada parte del método.

No debe pensar en estos conceptos en términos de “nivel inferior” como stacks, hilos, etc. Estas son abstracciones, y su funcionamiento interno no requiere ningún soporte del CLR, es solo el comstackdor el que hace la magia. Esto es muy diferente de las corutinas de Lua, que sí tienen el soporte del tiempo de ejecución, o el longjmp de C, que es solo magia negra.