Si async-await no crea ningún subproceso adicional, ¿cómo hace que las aplicaciones respondan?

Una y otra vez, veo que dice que usar asyncawait no crea ningún subproceso adicional. Eso no tiene sentido porque las únicas formas en que una computadora puede parecer que está haciendo más de una cosa a la vez es

  • En realidad, hacer más de 1 cosa a la vez (ejecutar en paralelo, haciendo uso de múltiples procesadores)
  • Simularlo al progtwigr tareas y alternar entre ellas (haga un poco de A, un poco de B, un poco de A, etc.)

Entonces, si asyncawait no hace ninguno de esos, ¿cómo puede hacer que una aplicación responda? Si solo hay 1 hilo, llamar a cualquier método significa esperar a que el método se complete antes de hacer cualquier otra cosa, y los métodos dentro de ese método tienen que esperar el resultado antes de continuar, y así sucesivamente.

En realidad, async / await no es tan mágico. El tema completo es bastante amplio, pero para una respuesta rápida pero completa a su pregunta creo que podemos manejar.

Vamos a abordar un simple evento de clic de botón en una aplicación de Windows Forms:

 public async void button1_Click(object sender, EventArgs e) { Console.WriteLine("before awaiting"); await GetSomethingAsync(); Console.WriteLine("after awaiting"); } 

Voy a hablar explícitamente de lo que sea que GetSomethingAsync esté volviendo por ahora. Digamos que esto es algo que se completará después de, digamos, 2 segundos.

En un mundo tradicional, no asincrónico, su manejador de eventos de clic de botón se vería así:

 public void button1_Click(object sender, EventArgs e) { Console.WriteLine("before waiting"); DoSomethingThatTakes2Seconds(); Console.WriteLine("after waiting"); } 

Cuando hace clic en el botón del formulario, la aplicación parecerá congelarse durante aproximadamente 2 segundos, mientras esperamos que este método se complete. Lo que ocurre es que la “bomba de mensajes”, básicamente un bucle, está bloqueada.

Este ciclo continuamente pregunta a Windows “¿Alguien ha hecho algo, como mover el mouse, hacer clic en algo? ¿Tengo que pintar algo? Si es así, ¡dígame!” y luego procesa ese “algo”. Este ciclo recibió un mensaje que el usuario hizo clic en “button1” (o el tipo equivalente de mensaje de Windows), y terminó llamando a nuestro método button1_Click anterior. Hasta que este método regrese, este ciclo ahora está atascado esperando. Esto demora 2 segundos y durante este, no se procesan mensajes.

La mayoría de las cosas que tienen que ver con Windows se hacen usando mensajes, lo que significa que si el bucle de mensaje deja de bombear mensajes, aunque sea por un segundo, el usuario lo notará rápidamente. Por ejemplo, si mueve el bloc de notas o cualquier otro progtwig encima de su propio progtwig, y ​​luego lo aleja nuevamente, se envía una ráfaga de mensajes de pintura a su progtwig indicando qué región de la ventana se volvió a ver de repente. Si el bucle de mensaje que procesa estos mensajes está esperando algo, bloqueado, entonces no se realiza ninguna pintura.

Entonces, si en el primer ejemplo, async/await no crea nuevos hilos, ¿cómo lo hace?

Bueno, lo que sucede es que tu método se divide en dos. Este es uno de esos tipos de temas generales, así que no voy a entrar en demasiados detalles, pero basta con decir que el método se divide en estas dos cosas:

  1. Todo el código previo a await , incluida la llamada a GetSomethingAsync
  2. Todo el código siguiente await

Ilustración:

 code... code... code... await X(); ... code... code... code... 

Reorganizado:

 code... code... code... var x = X(); await X; code... code... code... ^ ^ ^ ^ +---- portion 1 -------------------+ +---- portion 2 ------+ 

Básicamente, el método se ejecuta así:

  1. Ejecuta todo hasta await
  2. Llama al método GetSomethingAsync , que hace lo suyo, y devuelve algo que completará 2 segundos en el futuro

    Hasta ahora, todavía estamos dentro de la llamada original a button1_Click, que ocurre en el hilo principal, llamado desde el bucle de mensajes. Si el código que lleva a await tarda mucho tiempo, la IU todavía se congelará. En nuestro ejemplo, no tanto

  3. Lo que la palabra clave await , junto con alguna inteligente magia de comstackción, hace es básicamente algo así como “Ok, ya sabes, voy a regresar simplemente del controlador de eventos click click aquí. Cuando (como en, lo que estarás esperando) para completar, házmelo saber porque aún me queda algo de código para ejecutar “.

    En realidad, permitirá que la clase SynchronizationContext sepa que está hecho, lo que, dependiendo del contexto de sincronización actual que esté en juego en este momento, se pondrá en cola para su ejecución. La clase de contexto utilizada en un progtwig de Windows Forms la pondrá en cola utilizando la cola en la que se está ejecutando el ciclo de mensajes.

  4. Por lo tanto, vuelve al ciclo de mensajes, que ahora es libre de continuar transmitiendo mensajes, como mover la ventana, cambiar su tamaño o hacer clic en otros botones.

    Para el usuario, la IU ahora responde de nuevo, procesa otros clics de botones, redimensiona el tamaño y, lo que es más importante, vuelve a dibujar , por lo que no parece congelarse.

  5. 2 segundos más tarde, lo que estamos esperando completa y lo que sucede ahora es que (bueno, el contexto de sincronización) coloca un mensaje en la cola que está mirando el ciclo de mensajes, diciendo “Oye, tengo más código para usted para ejecutar “, y este código es todo el código después de la espera.
  6. Cuando el bucle de mensaje llegue a ese mensaje, básicamente “volverá a ingresar” ese método donde lo dejó, justo después de await y continuará ejecutando el rest del método. Tenga en cuenta que este código se llama nuevamente desde el bucle de mensajes, por lo que si este código hace algo prolongado sin utilizar async/await correctamente, bloqueará nuevamente el bucle de mensajes.

Aquí hay muchas partes móviles, así que aquí hay algunos enlaces a más información. Iba a decir “si lo necesitas”, pero este tema es bastante amplio y es bastante importante conocer algunas de esas partes móviles . Invariablemente vas a entender que async / await sigue siendo un concepto con goteras. Algunas de las limitaciones y problemas subyacentes aún se filtran en el código circundante, y si no lo hacen, por lo general terminan teniendo que depurar una aplicación que se rompe aleatoriamente por una razón que aparentemente no es buena.

  • Progtwigción asincrónica con Async y Await (C # y Visual Basic)
  • Clase SynchronizationContext
  • Stephen Cleary – ¡No hay hilo que valga la pena leer!
  • Canal 9 – Mads Torgersen: Dentro de C # Async vale la pena verlo!

OK, ¿y si GetSomethingAsync un hilo que se completará en 2 segundos? Sí, entonces obviamente hay un nuevo hilo en juego. Este hilo, sin embargo, no se debe a la asincronía de este método, sino porque el progtwigdor de este método eligió un hilo para implementar el código asíncrono. Casi todas las E / S asíncronas no usan un hilo, usan cosas diferentes. async/await por sí mismos no activan nuevos hilos, pero obviamente las “cosas que esperamos” pueden implementarse usando hilos.

Hay muchas cosas en .NET que no necesariamente giran un hilo por sí mismas pero que aún son asíncronas:

  • Solicitudes web (y muchas otras cosas relacionadas con la red que toman tiempo)
  • Lectura y escritura de archivos asíncronos
  • y muchos más, una buena señal es si la clase / interfaz en cuestión tiene métodos llamados SomethingSomethingAsync o BeginSomething y EndSomething y hay un IAsyncResult involucrado.

Usualmente estas cosas no usan un hilo debajo del capó.


OK, ¿entonces quieres algo de ese “tema amplio”?

Bueno, vamos a preguntarle a Try Roslyn sobre nuestro botón clic:

Prueba Roslyn

No voy a vincular en toda la clase generada aquí, pero es bastante sangriento.

la única forma en que una computadora puede parecer que está haciendo más de una cosa a la vez es (1) En realidad, hacer más de una cosa a la vez, (2) simularla progtwigndo tareas y alternando entre ellas. Entonces, si async-await no hace ninguno de esos

No es eso lo que espera ninguno de esos. Recuerde, el propósito de await no es hacer que el código sincrónico sea mágicamente asíncrono . Es para permitir el uso de las mismas técnicas que usamos para escribir código sincrónico cuando llamamos al código asíncrono . Esperar es hacer que el código que usa operaciones de latencia alta parezca un código que usa operaciones de baja latencia . Esas operaciones de alta latencia pueden estar en subprocesos, pueden estar en hardware de propósito especial, pueden desgarrar su trabajo en pequeños pedazos y colocarlo en la cola de mensajes para su procesamiento por el subproceso de interfaz de usuario más tarde. Están haciendo algo para lograr la asincronía, pero son ellos quienes lo están haciendo. Esperar solo le permite aprovechar esa asincronía.

Además, creo que te está faltando una tercera opción. Nosotros los ancianos, los niños de hoy con su música rap deberían salir de mi césped, etc. Recordar el mundo de Windows a principios de los 90. No había máquinas multi-CPU y ningún progtwigdor de hilos. Querías ejecutar dos aplicaciones de Windows al mismo tiempo, tenías que ceder . La multitarea fue cooperativa . El sistema operativo le dice a un proceso que se ejecuta, y si se comporta mal, impide que se atiendan todos los demás procesos. Funciona hasta que rinde, y de alguna manera tiene que saber cómo continuar donde lo dejó la próxima vez que el sistema operativo le devuelva el control . El código asíncrono de un solo subproceso es muy similar, con “esperar” en lugar de “rendimiento”. Esperar significa “Voy a recordar dónde lo dejé aquí, y dejar que alguien más corra por un tiempo, llámame cuando la tarea que estoy esperando esté completa, y retomaré donde lo dejé”. Creo que se puede ver cómo eso hace que las aplicaciones sean más receptivas, tal como lo hizo en Windows 3 días.

Llamar a cualquier método significa esperar que el método se complete

Existe la llave que te estás perdiendo. Un método puede regresar antes de que se complete su trabajo . Esa es la esencia de la asincronía allí mismo. Un método devuelve, devuelve una tarea que significa “este trabajo está en curso; dígame qué hacer cuando esté completo”. El trabajo del método no está hecho, aunque haya regresado .

Antes del operador de espera, tenía que escribir un código que parecía espagueti enhebrado a través de queso suizo para tratar con el hecho de que tenemos trabajo pendiente después de la finalización, pero con el retorno y la finalización desincronizados . Aguardar le permite escribir código que se ve como el retorno y la finalización se sincronizan, sin que realmente se sincronicen.

Lo explico en su totalidad en mi blog. No hay hilo .

En resumen, los sistemas de E / S modernos hacen un uso intensivo de DMA (Direct Memory Access). Existen procesadores dedicados especiales en tarjetas de red, tarjetas de video, controladores HDD, puertos serie / paralelo, etc. Estos procesadores tienen acceso directo al bus de memoria y manejan la lectura / escritura de manera completamente independiente de la CPU. La CPU solo necesita notificar al dispositivo de la ubicación en la memoria que contiene los datos, y luego puede hacer lo propio hasta que el dispositivo genere una interrupción notificando a la CPU que la lectura / escritura está completa.

Una vez que la operación está en vuelo, no hay trabajo para la CPU y, por lo tanto, no hay subprocesos.

Estoy muy contento de que alguien haya hecho esta pregunta, porque durante mucho tiempo también creí que los hilos eran necesarios para la concurrencia. Cuando vi por primera vez los bucles de eventos , pensé que eran una mentira. Pensé para mí mismo que “no hay forma de que este código pueda ser concurrente si se ejecuta en un solo hilo”. Tenga en cuenta que esto es después de que ya había pasado por la lucha de entender la diferencia entre concurrencia y paralelismo.

Después de investigar por mi cuenta, finalmente encontré la pieza que faltaba: select() . Específicamente, multiplexación IO, implementada por varios kernels con diferentes nombres: select() , poll() , epoll() , kqueue() . Estas son llamadas al sistema que, si bien los detalles de la implementación son diferentes, le permiten pasar un conjunto de descriptores de archivos para mirar. Luego puede hacer otra llamada que bloquee hasta que cambie uno de los descriptores de archivos observados.

Por lo tanto, uno puede esperar en un conjunto de eventos de IO (el bucle de evento principal), manejar el primer evento que se completa y luego devolver el control al bucle de evento. Enjuague y repita.

¿Como funciona esto? Bueno, la respuesta corta es que es kernel y magia a nivel de hardware. Hay muchos componentes en una computadora además de la CPU, y estos componentes pueden funcionar en paralelo. El kernel puede controlar estos dispositivos y comunicarse directamente con ellos para recibir ciertas señales.

Estas llamadas al sistema de multiplexación IO son el bloque de creación fundamental de los bucles de eventos de un solo subproceso como node.js o Tornado. Cuando await una función, estás viendo un evento determinado (la finalización de esa función) y luego cedes el control al ciclo del evento principal. Cuando finaliza el evento que está viendo, la función (eventualmente) comienza desde donde se quedó. Las funciones que le permiten suspender y reanudar el cálculo de esta manera se llaman corutinas .

await y async uso de Tareas no Hilos.

El marco tiene un conjunto de hilos listos para ejecutar algún trabajo en forma de objetos Tarea ; enviar una tarea al grupo significa seleccionar un hilo 1 , ya existente , para llamar al método de acción de la tarea.
Crear una tarea es cuestión de crear un nuevo objeto, mucho más rápido que crear un nuevo hilo.

Dada una Tarea es posible adjuntarle una Continuación , se trata de un nuevo objeto Tarea que se ejecutará una vez que finalice el hilo.

Como async/await use Task s, no crean un nuevo hilo.


Si bien la técnica de progtwigción de interrupción se usa ampliamente en todos los SO modernos, no creo que sean relevantes aquí.
Puede hacer que dos tareas enlazadas con CPU se ejecuten en paralelo (intercaladas en realidad) en una única CPU utilizando aysnc/await .
Eso no podría explicarse simplemente con el hecho de que el sistema operativo admite cola IORP .


La última vez que revisé el comstackdor transformó los métodos de async en DFA , el trabajo se divide en pasos, cada uno termina con una instrucción de await .
La await comienza su tarea y se adjunta una continuación para ejecutar el siguiente paso.

Como ejemplo de concepto, aquí hay un ejemplo de pseudo-código.
Las cosas se simplifican en aras de la claridad y porque no recuerdo todos los detalles exactamente.

 method: instr1 instr2 await task1 instr3 instr4 await task2 instr5 return value 

Se transforma en algo como esto

 int state = 0; Task nextStep() { switch (state) { case 0: instr1; instr2; state = 1; task1.addContinuation(nextStep()); task1.start(); return task1; case 1: instr3; instr4; state = 2; task2.addContinuation(nextStep()); task2.start(); return task2; case 2: instr5; state = 0; task3 = new Task(); task3.setResult(value); task3.setCompleted(); return task3; } } method: nextStep(); 

1 En realidad, un grupo puede tener su política de creación de tareas.

No voy a competir con Eric Lippert o Lasse V. Karlsen, y otros, solo me gustaría llamar la atención sobre otra faceta de esta pregunta, que creo que no fue mencionada explícitamente.

Usar await por sí mismo no hace que tu aplicación responda mágicamente. Si haces lo que hagas en el método que estás esperando de los bloques de subprocesos de la interfaz de usuario, seguirá bloqueando tu interfaz de usuario del mismo modo que lo haría la versión no disponible .

Tienes que escribir tu método de espera específicamente para que genere un nuevo hilo o use algo como un puerto de finalización (que devolverá la ejecución en el hilo actual y llamará a algo más para continuar siempre que se indique el puerto de finalización). Pero esta parte está bien explicada en otras respuestas.

Así es como veo todo esto, puede que no sea súper técnicamente preciso, pero me ayuda, al menos :).

Básicamente, hay dos tipos de procesamiento (computación) que ocurren en una máquina:

  • procesamiento que ocurre en la CPU
  • procesamiento que ocurre en otros procesadores (GPU, tarjeta de red, etc.), llamémoslos IO.

Entonces, cuando escribimos un fragmento de código fuente, después de la comstackción, dependiendo del objeto que usemos (y esto es muy importante), el procesamiento estará vinculado a la CPU , o enlazado a IO , y de hecho, puede vincularse a una combinación de ambos.

Algunos ejemplos:

  • si utilizo el método Write del objeto FileStream (que es un Stream), el procesamiento será, por ejemplo, 1% vinculado a la CPU y 99% IO vinculado.
  • si utilizo el método Write del objeto NetworkStream (que es un Stream), el procesamiento será, digamos, 1% vinculado a la CPU y 99% IO obligado.
  • si uso el método Write del objeto Memorystream (que es un Stream), el procesamiento estará 100% vinculado a la CPU.

Entonces, como puede ver, desde un punto de vista de progtwigdor orientado a objetos, aunque siempre estoy accediendo a un objeto Stream , lo que sucede debajo puede depender mucho del tipo final del objeto.

Ahora, para optimizar las cosas, a veces es útil poder ejecutar código en paralelo (tenga en cuenta que no uso la palabra asíncrona) si es posible y / o necesario.

Algunos ejemplos:

  • En una aplicación de escritorio, quiero imprimir un documento, pero no quiero esperar.
  • Mi servidor web atiende a muchos clientes al mismo tiempo, cada uno obteniendo sus páginas en paralelo (sin serializar).

Antes de async / await, esencialmente teníamos dos soluciones para esto:

  • Hilos . Fue relativamente fácil de usar, con las clases Thread y ThreadPool. Los hilos solo están vinculados a la CPU .
  • El “viejo” modelo de progtwigción asíncrona Begin / End / AsyncCallback . Es solo un modelo, no te dice si estarás atado a CPU o IO. Si echas un vistazo a las clases Socket o FileStream, está vinculado a IO, lo cual es genial, pero rara vez lo usamos.

El async / await es solo un modelo de progtwigción común, basado en el concepto de Tarea . Es un poco más fácil de usar que los subprocesos o agrupaciones de subprocesos para las tareas vinculadas con la CPU, y es mucho más fácil de usar que el antiguo modelo Begin / End. Undercovers, sin embargo, es “simplemente” una función súper sofisticada y completa en ambos.

Entonces, la verdadera victoria es principalmente en tareas de IO Bound , tareas que no usan la CPU, pero async / await sigue siendo solo un modelo de progtwigción, no ayuda a determinar cómo o dónde el procesamiento ocurrirá al final.

Significa que no es porque una clase tenga un método “DoSomethingAsync” devolviendo un objeto Task que pueda presuponer que estará vinculado a la CPU (lo que significa que puede ser bastante inútil , especialmente si no tiene un parámetro de token de cancelación), o IO Bound (lo que significa que probablemente sea necesario ), o una combinación de ambos (dado que el modelo es bastante viral, los beneficios potenciales y vinculantes pueden ser, al final, supercompactos y no tan obvios).

Así que, volviendo a mis ejemplos, hacer mis operaciones de escritura usando async / await en MemoryStream permanecerá vinculado a la CPU (probablemente no me beneficie), aunque seguramente me beneficiaré con los archivos y las transmisiones de red.

Resumiendo otras respuestas:

Async / await se crea principalmente para tareas vinculadas a IO, ya que al usarlas, se puede evitar el locking de la secuencia de llamada.

En el caso de tareas vinculadas IO, el principal beneficio de esto es evitar el locking de la interfaz de usuario. Para los subprocesos no relacionados con la interfaz de usuario, uno podría tener beneficios de rendimiento.

Async no crea su propio hilo. El hilo del método de llamada se usa para ejecutar el método async hasta que encuentre un awaitable. El mismo subproceso luego continúa ejecutando el rest del método de llamada más allá de la llamada al método asincrónico. Dentro del método asíncrono llamado, después de regresar de lo que se puede esperar, la continuación se puede ejecutar en un subproceso del grupo de subprocesos: el único lugar en el que una secuencia independiente entra en la imagen.

En realidad, las cadenas de async await son máquinas de estado generadas por el comstackdor CLR.

async await sin embargo, utiliza hilos que TPL está utilizando el grupo de subprocesos para ejecutar tareas.

La razón por la cual la aplicación no está bloqueada es porque la máquina de estado puede decidir qué co-rutina ejecutar, repetir, verificar y decidir de nuevo.

Otras lecturas:

¿Qué genera asincronizar y esperar?

Async espera y Generated StateMachine

Asincrónico C # y F # (III.): ¿Cómo funciona? – Tomás Petricek

Editar :

Bueno. Parece que mi elaboración es incorrecta. Sin embargo, tengo que señalar que las máquinas de estado son activos importantes para la async await . Incluso si acepta E / S asíncrona, aún necesita un ayudante para verificar si la operación está completa, por lo tanto, todavía necesitamos una máquina de estados y determinar qué rutina se puede ejecutar de forma asíncrona.