Según entiendo la yield
palabra 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, await
no 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 awaits
el 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 await
se 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 await
sea? ¿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 yield
se 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?
fuente
Respuestas:
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.
await
se genera como:Eso es básicamente todo. Esperar es solo un regreso elegante.
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.
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 .
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.
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.
Mismo camino. El estado de los locales se traslada al montón y
MoveNext
se 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.
fuente
yield
es el más fácil de los dos, así que examinémoslo.Digamos que tenemos:
Esto se compila un poco como si hubiéramos escrito:
Por lo tanto, no es tan eficiente como una implementación escrita a mano de
IEnumerable<int>
yIEnumerator<int>
(por ejemplo, probablemente no desperdiciaríamos tener un_state
,_i
y_current
en 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 conyield
métodos de uso muy complicados .Y por supuesto desde
Es lo mismo que:
Entonces el generado
MoveNext()
se llama repetidamente.El
async
caso es prácticamente el mismo principio, pero con un poco de complejidad adicional. Para reutilizar un ejemplo de otro código de respuesta como:Produce código como:
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 tiempoawaiter.IsCompleted
, regresatrue
porque la tareaawait
ed 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
MoveNext
y continuará con el siguiente trabajo (hasta el siguienteawait
) o terminará y regresará, en cuyo caso elTask
que está implementando se completará.fuente
yield
a 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.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,
async
el compilador divide un método en varias partes; lasawait
expresiones 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,
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 regrese". (Utilizo terminología muy similar en miasync
introducció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
await
capturó una IUSynchronizationContext
y se completó la E / S en el grupo de subprocesos).Todo son solo devoluciones de llamada. Cuando se completa un awaitable, invoca sus devoluciones de llamada y se reanuda cualquier
async
método que ya lo hayaawait
editado. 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.
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
async
método que finaliza su tarea, que puede reanudar unasync
método que finaliza su tarea, etc.Así, con el código sincrónico
A
de llamadasB
de llamadasC
, su pila de llamadas puede tener este aspecto:mientras que el código asincrónico usa devoluciones de llamada (punteros):
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 .
fuente
yield
yawait
son, aunque ambos se ocupan del control de flujo, dos cosas completamente diferentes. Así que los abordaré por separado.El objetivo de
yield
es facilitar la creación de secuencias perezosas. Cuando escribe un bucle de enumerador con unayield
declaració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 llamesMoveNext
pase una vez más por ese bucle. Entonces, cuando haces un bucle foreach como este:el código generado se parece a:
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.
async
yawait
, 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
async
yawait
en general.fuente
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.
yield
es 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 devuelveIEnumerable<T>
oIEnumerator<T>
puede contener unyield
, 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 unyield
archivo inside. La razón es que el código que escribe se transloca alIEnumerator<T>.MoveNext
mé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.
await
requiere un poco de soporte de la biblioteca de tipos y funciona de manera algo diferente. Toma un argumentoTask
oTask<T>
, luego da como resultado su valor si la tarea se completa, o registra una continuación a través deTask.GetAwaiter().OnCompleted
. La implementación completa del sistemaasync
/await
tomarí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
yield
yawait
tienen 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.
fuente
await
no es necesario realizar una tarea . Cualquier cosa conINotifyCompletion GetAwaiter()
es suficiente. Un poco similar a cómoforeach
no necesitaIEnumerable
, cualquier cosa conIEnumerator GetEnumerator()
es suficiente.