Nota
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
Programación asincrónica más fácil con el nuevo CTP de Visual Studio Async
Eric Lippert
Imagine cómo sería el mundo si las personas trabajaran igual que los programas informáticos:
void ServeBreakfast(Customer diner)
{
var order = ObtainOrder(diner);
var ingredients = ObtainIngredients(order);
var recipe = ObtainRecipe(order);
var meal = recipe.Prepare(ingredients);
diner.Give(meal);
}
Cada subrutina puede, desde luego, descomponerse todavía un poco más; preparar la comida puede implicar calentar sartenes, hacer omelets y tostar pan. Si los seres humanos realizáramos este tipo de tareas igual que programas informáticos típicos, escribiríamos todo cuidadosamente como secuencias de tareas jerárquicas en una lista de comprobación y nos aseguraríamos de manera obsesiva que cada trabajo se completara antes de embarcarnos en el siguiente.
Un enfoque basado en subrutinas parece razonable (no puede cocinar los huevos antes de recibir el pedido), pero de hecho pierde tiempo y hace que la aplicación parezca incapaz de responder. Pierde tiempo porque usted desea que el pan se esté tostando mientras los huevos se fríen, no después de que los huevos están listos y se enfrían. Parece incapaz de responder porque si llega otro cliente mientras el pedido actual aún se está cocinando, deseará tomar su pedido y no hacerlo esperar en la puerta hasta que el cliente actual se haya servido su desayuno. Un servidor que sigue ciegamente una lista de comprobación no tiene ninguna capacidad de responder de manera oportuna ante eventos inesperados.
Solución uno: Contratar más personal al generar más subprocesos
Preparar el desayuno de una persona es un ejemplo antojadizo, pero la realidad, desde luego, es todo menos eso. Cada vez que transfiere control a una subrutina de larga ejecución en el subproceso de UI, la UI se queda completamente sin capacidad de respuesta hasta que la subrutina finaliza. ¿Cómo puede ser de otro modo? Las aplicaciones responden a eventos de UI al ejecutar código en el subproceso de UI y ese subproceso está obsesivamente ocupado haciendo algo más. Solo cuando cada trabajo en su lista está listo vendrá a ocuparse de los comandos en cola del usuario frustrado. La solución usual a este problema es usar la simultaneidadpara hacer dos o más cosas “al mismo tiempo”. (Si los dos subprocesos están en dos procesadores independientes, podrían estar verdaderamente ejecutándose al mismo tiempo. En un mundo con más subprocesos que procesadores que dedicarse a ellos, el sistema operativo simulará la simultaneidad al mismo tiempo al programar periódicamente un intervalo de tiempo para que cada subproceso controle un procesador.)
Una solución simultánea podría ser crear un grupo de subprocesos y asignar cada cliente nuevo a un subproceso específico para controlar sus solicitudes. En nuestra analogía, podría contratar a un grupo de servidores. Cuando llega un nuevo comensal, se le asigna un servidor inactivo. Cada servidor entonces independientemente hace el trabajo de tomar el pedido, encontrar los ingredientes, cocinar la comida y servirla.
La dificultad con este enfoque es que los eventos de UI suelen llegar al mismo subproceso y esperan atención completa en ese subproceso. La mayoría de los componentes de UI crean solicitudes en el subproceso de UI y esperan recibir comunicación solo en ese subproceso. Es poco probable que dedicar un nuevo subproceso a cada tarea relacionada con UI funcione bien.
Para abordar este problema, podría tener un solo subproceso en primer plano escuchando eventos de UI que no hace nada salvo “tomar pedidos” y encargarlos a uno o más subprocesos de trabajadores en segundo plano. En esta analogía, hay un solo servidor que interactúa con los clientes y una cocina llena de cocineros que en realidad hacen el trabajo solicitado. El subproceso de UI y los subprocesos de trabajadores son entonces los responsables de coordinar sus comunicaciones. Los cocineros nunca hablan directamente con los comensales, pero de alguna manera la comida se sirve de todos modos.
Esto ciertamente resuelve el problema de “responder a eventos de UI de manera oportuna”, pero no resuelve la falta de eficacia, el código en ejecución en el subproceso de trabajadores sigue esperando de manera sincrónica que los huevos se cocinen por completo antes de poner el pan en el tostador. Ese problema se podría solucionar, a su vez, al agregar incluso más simultaneidad: Podría tener dos cocineros por pedido, uno para los huevos y uno para el pan. Pero eso podría ser bastante costoso. En definitiva, ¿cuántos cocineros va a necesitar y qué pasa cuando tienen que coordinar su trabajo?
Una simultaneidad de este tipo introduce muchas dificultades bien conocidas. Primero, los subprocesos son notoriamente pesados; un subproceso de manera predeterminada consume un millón de bytes de memoria virtual por su pila y muchos otros recursos de sistema. Segundo, los objetos de UI a menudo se “afinizan” al subproceso de UI y no se los puede llamar desde subprocesos de trabajadores; el subproceso de trabajadores y el subproceso de UI deben lograr algún acuerdo complejo sobre dónde el subproceso de UI puede enviar información necesaria de los elementos de UI al trabajador y este pueda enviar actualizaciones de vuelta al subproceso de UI, en lugar de a los elementos de UI directamente. Tales acuerdos son difíciles de codificar y son propensos a condiciones de carrera, interbloqueos y otros problemas de subprocesos. Tercero, muchas de las agradables ficciones en las que todos confiamos en el mundo del subproceso único (como las lecturas y escrituras de memoria que ocurren en una secuencia predecible y constante) ya no son confiables. Esto lleva a los peores tipos de errores difíciles de reproducir.
Es solo que parece equivocado tener que usar un gran martillo de simultaneidad basada en subprocesos para crear programas sencillos que puedan responder siempre y se ejecuten eficazmente. De alguna manera, las personas reales se las arreglan para solucionar problemas complejos mientras conservan su capacidad de respuesta a los eventos. En el mundo real, no tiene que asignar un servidor por mesa o dos cocineros por pedido para atender docenas de solicitudes de clientes pendientes al mismo tiempo. Solucionar el problema con subprocesos genera demasiados cocineros. Tiene que haber una mejor solución que no implique tanta simultaneidad.
Solución dos: Desarrollar síndrome de déficit atencional con DoEvents
Una “solución” común y no simultánea al problema de la incapacidad de respuesta de UI durante operaciones de ejecución larga es literalmente susurrar las palabras mágicas Application.DoEvents en torno a un programa hasta que el problema desaparezca. Aunque esta es ciertamente una solución pragmática, es una sin muy buena ingeniería:
void ServeBreakfast(Customer diner)
{
var order = ObtainOrder(diner);
Application.DoEvents();
var ingredients = ObtainIngredients(order);
Application.DoEvents();
var recipe = ObtainRecipe(order);
Application.DoEvents();
var meal = recipe.Prepare(ingredients);
Application.DoEvents();
diner.Give(meal);
}
Básicamente, usar DoEvents significa “ver si pasó algo interesante mientras estaba ocupado haciendo esa última cosa. Si pasó algo ante lo que debo reaccionar, recordad qué estaba haciendo recién, enfrentar la nueva situación y luego retomar donde quedé”. Hace que su programa se comporte como si tuviera síndrome de déficit atencional: cualquier cosa nueva que entre recibe atención de inmediato. Suena como una solución plausible para mejorar la capacidad de respuesta (y a veces incluso funciona) pero hay varios problemas con este enfoque.
Primero, DoEvents funciona mejor cuando el retraso lo causa un bucle que debe ejecutar muchas veces, pero cada ejecución de bucle individual es breve. Al revisar eventos pendientes cada cierto tiempo a través del bucle, puede mantener la capacidad de respuesta incluso si todo el bucle demora mucho en ejecutarse. Sin embargo, ese patrón generalmente no es la causa de un problema de capacidad de respuesta. Más a menudo, el problema lo causa una operación de ejecución inherentemente larga que demora mucho tiempo, como intentar obtener acceso de manera sincrónica a un archivo en una red de latencia alta. Quizás en nuestro ejemplo, la tarea de ejecución larga esté en preparar la comida y no hay lugar donde poner DoEvents que ayude. Quizás hay un lugar donde DoEvents ayudaría, pero está en un método para el cual no tiene código fuente.
Segundo, llamar a DoEvents provoca que el programa intente atender por completo todos los eventos más recientes antes de terminar el trabajo asociado con eventos anteriores. ¡Imagine si nadie pudiera recibir su comida hasta después de que cada cliente que llegó reciba la suya! Si siguen llegando más y más clientes, el primer cliente podría jamás recibir su comida y moriría de hambre. De hecho, podría suceder que ningún cliente recibiera su comida. La finalización del trabajo asociado con eventos anteriores puede postergarse arbitrariamente demasiado a futuro a medida que la atención de eventos más nuevos sigue interrumpiendo el trabajo que se realiza para eventos anteriores.
Tercero, DoEvents supone un peligro muy real de reingreso inesperado. Es decir, mientras se atiende un cliente, usted comprueba si han habido eventos de UI recientes interesantes y sin querer comienza a atender al mismo comensal nuevamente, aun cuando ya lo están atendiendo. La mayoría de los desarrolladores no diseña su código para detectar este tipo de reingreso; es posible terminar en algunos programas de estado muy raros cuando un algoritmo que nunca se diseñó como recursivo termina autollamándose inesperadamente a través de DoEvents.
En resumen, DoEvents debe usarse solo para reparar un problema de capacidad de respuesta en los casos más triviales; no es una buena solución para administrar la capacidad de respuesta de las UI en programas complejos.
Solución tres: Desarmar por completo la lista de comprobación con devoluciones de llamadas
La naturaleza no simultánea de la técnica DoEvents es atractiva, pero claramente no del todo la solución adecuada para un programa complejo. Una mejor idea es separar los elementos de la lista de comprobación en una serie de tareas breves, cada una de las cuales puede completarse lo suficientemente rápido de manera que la aplicación puede parecer que tiene capacidad de respuesta ante los eventos.
Esa idea no es nada nuevo, dividir un problema complejo en pequeñas partes es porqué tenemos subrutinas en primer lugar. El giro interesante es que en lugar de ejecutar rígidamente una lista de comprobación para determinar qué se ha hecho ya y qué debe hacerse a continuación, y solo devolver el control al autor de la llamada cuando todo se ha completado, a cada nueva tarea se le entrega una lista de trabajo que debe venir después de ella. El trabajo que debe venir después de finalizada una tarea determinada se denomina “continuación” de la tarea.
Cuando una tarea ha finalizado, puede mirar la continuación y terminarla precisamente allí. O podría programar que la continuación se ejecute más tarde. Si la continuación requiere información calculada por la tarea anterior, esta puede pasar esa información como un argumento a la llamada que invoca la continuación.
Con este enfoque, todo el cuerpo de trabajo está esencialmente separado en pequeñas piezas que se pueden ejecutar rápidamente. El sistema aparece con capacidad de respuesta porque los eventos pendientes se pueden detectar y controlar entre las ejecuciones de cualquiera de dos de las piezas pequeñas de trabajo. Pero como toda actividad asociada con esos nuevos eventos también se puede subdividir en pequeñas partes y poner en cola para ejecutarse más tarde, no tenemos el problema de “morir de hambre” por el cual nuevas tareas evitan que tareas antiguas se completen. Las nuevas tareas de ejecución larga no se asumen de inmediato, sino que se ponen en cola para su eventual procesamiento.
La idea es genial, pero no está muy claro cómo implementar semejante solución. La dificultad esencial es determinar cómo decir a cada unidad de pequeña de trabajo cuál es su continuación, es decir, qué trabajo debe hacerse a continuación.
En código asincrónico tradicional, esto se hace generalmente al registrar una función de devolución de llamada. Supongamos que tenemos una versión asincrónica de “Preparar” que toma una función de devolución de llamada que dice qué hacer a continuación, a saber, servir la comida:
void ServeBreakfast(Diner diner)
{
var order = ObtainOrder(diner);
var ingredients = ObtainIngredients(order);
var recipe = ObtainRecipe(order);
recipe.PrepareAsync(ingredients, meal =>
{
diner.Give(meal);
});
}
Ahora ServeBreakfast vuelve inmediatamente después de que PrepareAsync vuelve; el código que sea que haya llamado a ServeBreakfast entonces está libre para atender otros eventos que puedan ocurrir. PrepareAsync en sí no hace ningún trabajo “de verdad”; más bien, hace rápidamente lo que sea necesario para asegurar que la comida se preparará a futuro. Además, PrepareAsync también asegura que el método de devolución de llamada se invocará con la comida preparada como su argumento en algún momento después de que la tarea de preparación de la cómoda haya finalizado. De esta manera, la cena finalmente se servirá, aunque es posible que tenga que esperar brevemente si hay un evento que requiera atención entre el fin de la preparación y el hecho de servir la comida.
Observe que nada de esto implica necesariamente un segundo subproceso. Quizás PrepareAsync provoque que el trabajo de preparación de la comida se realice en un subproceso separado o quizás provoque que se ponga en cola una serie de tareas breves asociadas con la preparación de comida en el subproceso de UI que se han de ejecutar más tarde. En realidad no importa, todo lo que sabemos es que PrepareAsync de alguna manera garantiza dos cosas: que la comida se preparará de manera que no bloquee el subproceso de UI con una operación de alta latencia y que la devolución de llamada se invocará de alguna manera después de que el trabajo de preparar la comida solicitada haya terminado.
Pero supongamos que alguno de los métodos para obtener el pedido, obtener los ingredientes, obtener la receta o preparar la comida podría ser el que tiene lenta la UI. Podríamos solucionar este problema más grande si tuviéramos una versión asincrónica de cada uno de estos momentos. ¿Cómo luciría el programa resultante? Recuerde, a cada método se le debe dar una devolución de llamada que le dice qué hacer cuando la unidad de trabajo esté completa.
void ServeBreakfast(Diner diner)
{
ObtainOrderAsync(diner, order =>
{
ObtainIngredientsAsync(order, ingredients =>
{
ObtainRecipeAsync(order, recipe =>
{
recipe.PrepareAsync(ingredients, meal =>
{
diner.Give(meal);
})})})});
}
Esto podría parecer un terrible desorden, pero no es nada comparado con lo mal que se ponen los programas de verdad cuando están reescribiendo mediante asincronía basada en devoluciones de llamada. Piense en cómo enfrentaría hacer un bucle asincrónico o cómo enfrentaría las excepciones, los bloques try-finally u otras formas no triviales de flujo de control. Termina esencialmente desarmando el programa por completo; el código ahora enfatiza cómo se conectan todas las devoluciones de llamadas juntas y no cómo debe ser el flujo de trabajo lógico del programa.
Solución cuatro: Lograr que el compilador solucione el problema con asincronía basada en tareas
La asincronía basada en devoluciones de llamada sí mantiene el subproceso de UI con capacidad de respuesta y minimiza la pérdida de tiempo al esperar asincrónicamente que el trabajo de ejecución larga se complete. Pero la cura parece peor que la enfermedad. El precio que se paga por capacidad de respuesta y rendimiento es que hay que escribir código que enfatice cómo funcionan los mecanismos del trabajo asincrónico mientras se oscurece el significado y el propósito del código.
Las próximas versiones de C# y Visual Basic en su lugar le permiten escribir código que enfatice su significado y propósito, a la vez que se entregan suficientes pistas a los compiladores para crear los mecanismos necesarios para lo que ocurre tras bambalinas. La solución tiene dos partes: una en el sistema tipo y una en el lenguaje.
La versión de CLR 4 definió el tipo Task<T> (el tipo caballito de batalla de la biblioteca TPL) para representar el concepto de “cierto trabajo que va a producir un resultado de tipo T a futuro”. El concepto de “trabajo que se completará a futuro pero que no devuelve ningún resultado” lo representa el tipo de tarea no genérica.
Precisamente cómo el resultado de tipo T se va a producir a futuro es un detalle de implementación de una tarea determinada; el trabajo se puede asignar a otra máquina por completo, a otro proceso en esta máquina, a otro subproceso o quizás el trabajo es simplemente leer un resultado en caché anteriormente al que se puede obtener acceso de manera muy económica desde el subproceso actual. Las tareas de TPL generalmente se asignan a subprocesos de trabajadores desde un grupo de subprocesos en el proceso actual, pero ese detalle de implementación no es fundamental para el tipo Task<T>; más bien, Task<T> puede representar cualquier operación de alta latencia que produce una T.
La mitad de lenguaje de la solución es la nueva palabra clave await. Una llamada de método regular significa “recuerde lo que está haciendo, ejecute este método hasta que haya finalizado por completo y entonces retome donde lo dejó, ahora sabiendo el resultado del método”. Una expresión await, en contraste, significa “evaluar esta expresión para obtener un objeto que representa trabajo que a futuro producirá un resultado. Registre la parte restante del método actual como la devolución de llamada asociada con la continuación de la tarea. Una vez que la tarea se produce y se registra la devolución de llamada, inmediatamente devolver el control a quien me llama”.
Nuestro pequeño ejemplo reescrito en el nuevo estilo se lee mucho mejor:
async void ServeBreakfast(Diner diner)
{
var order = await ObtainOrderAsync(diner);
var ingredients = await ObtainIngredientsAsync(order);
var recipe = await ObtainRecipeAsync(order);
var meal = await recipe.PrepareAsync(ingredients);
diner.Give(meal);
}
En este esquema, cada versión asincrónica devuelve un Task<Order>, Task<List<Ingredient>> y así sucesivamente. Cada vez que se encuentra una expresión await, el método de ejecución actual registra el resto del método como lo siguiente por hacer cuando la tarea actual esté completa y luego vuelve inmediatamente. De alguna manera cada tarea se completará sola (ya sea porque la programaron para ejecutarse como un evento en el subproceso actual o porque usó un subproceso de finalización de E/S o un subproceso de trabajadores) y luego provocará que su continuación “retome donde la dejó” al ejecutar el resto del método,
Observe que el método ahora está marcado con la nueva palabra clave; esto es simplemente un indicador para el compilador que le permite saber que en el contexto de este método, la palabra clave await debe tratarse como un punto donde el flujo de trabajo devuelve el control a su autor de llamada y retoma de nuevo cuando la tarea asociada ha finalizado. Observe también que los ejemplos que he observado en este artículo usan código C#; Visual Basic tendrá una característica similar con sintaxis similar. El diseño de estas características en C# y Visual Basic se vio ampliamente influido por los flujos de trabajo asincrónicos de F#, una característica que F# ha tenido por algún tiempo.
Dónde obtener más información
Esta breve introducción simplemente motiva y luego araña la superficie de la nueva característica asincrónica en C# y Visual Basic. Para obtener una explicación más detallada de cómo funciona tras bambalinas y cómo razonar las características de rendimiento de código asincrónico, vea los artículos de compañía de este número a cargo de mis colegas Mads Torgersen y Stephen Toub.
Para obtener una versión de vista previa de esta característica, junto con ejemplos, notas del producto y un foro de la comunidad para preguntas, análisis y retroalimentación constructiva, visite msdn.com/async. Estas características del lenguaje y las bibliotecas que las admiten siguen en desarrollo; al tema de diseño le gustaría recibir de su parte todos los comentarios posibles.
Eric Lippert es un desarrollador principal del equipo de compilación de C# en Microsoft.
Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: *Mads Torgersen, *Stephen Toub *y *Lucian Wischik