Capítulo 1. Presentación de Asyncio

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Mi historia se parece mucho a la tuya, sólo que es más interesante porque tiene que ver con robots.

Bender, episodio de Futurama "30% Iron Chef"

La pregunta más común que recibo sobre Asyncio en Python 3 es la siguiente: "¿Qué es y qué hago con él?". La respuesta que oirás con más frecuencia probablemente se refiera a la posibilidad de ejecutar varias solicitudes HTTP simultáneas en un solo programa. Pero es mucho más que eso, mucho más. Asyncio requiere cambiar tu forma de pensar sobre la estructuración de programas.

La siguiente historia proporciona un telón de fondo para adquirir esta comprensión. El enfoque central de Asyncio se centra en la mejor manera de realizar varias tareas al mismo tiempo, y no cualquier tarea, sino específicamente tareas que implican periodos de espera. La idea clave que requiere este estilo de programación es que, mientras esperas a que se complete esta tarea, se puede trabajar en otras tareas.

El Restaurante de ThreadBots

Es el año 2051, y te encuentras en el negocio de la restauración.La automatización, en gran parte mediante trabajadores robot, impulsa la mayor parte de la economía, pero resulta que a los humanos todavía les gusta salir a comer de vez en cuando. En tu restaurante, todos los empleados son robots -humanoides, por supuesto, pero inequívocamente robots-. El fabricante de robots de más éxito es Threading Inc., y los trabajadores robot de esta empresa han pasado a llamarse "ThreadBots".

Salvo por este pequeño detalle robótico, tu restaurante parece y funciona como uno de esos establecimientos antiguos de, digamos, 2020. Tus clientes buscarán esa experiencia vintage. Quieren comida fresca preparada desde cero. Quieren sentarse a la mesa. Quieren esperar para comer, pero sólo un poco. Quieren pagar al final, y a veces incluso quieren dejar propina, por los viejos tiempos.

Como eres nuevo en el negocio de los restaurantes robotizados, haces lo que cualquier otro restaurador y contratas una pequeña flota de robots: uno para recibir a los comensales en recepción (GreetBot), otro para servir las mesas y tomar los pedidos (WaitBot), otro para cocinar (ChefBot) y otro para gestionar el bar (WineBot).

Los comensales hambrientos llegan a la recepción y son recibidos por GreetBot, tu ThreadBot de recepción. A continuación, se les dirige a una mesa y, una vez sentados, WaitBot toma su pedido, que luego lleva a la cocina en un papelito (porque quieres conservar la experiencia de antaño, ¿recuerdas?). ChefBot mira el pedido en el papelito y empieza a preparar la comida. WaitBot comprobará periódicamente si la comida está lista y, cuando lo esté, llevará inmediatamente los platos a la mesa de los clientes. Cuando los clientes están listos para marcharse, vuelven a GreetBot, que calcula la cuenta, les cobra y les desea amablemente una agradable velada.

Tu restaurante es un éxito, y pronto creces hasta tener una gran clientela. Tus empleados robot hacen exactamente lo que se les dice, y son perfectamente buenos en las tareas que les asignas. Todo va muy bien, y no podrías estar más contento.

Con el tiempo, sin embargo, empiezas a notar algunos problemas. No es nada realmente grave; sólo algunas cosas que parecen ir mal. Todos los demás propietarios de restaurantes robotizados parecen tener problemas similares. Es un poco preocupante que estos problemas parezcan empeorar cuanto más éxito tengas.

Aunque raras, hay colisiones ocasionales que son muy inquietantes: a veces, cuando un plato de comida está listo en la cocina, WaitBot lo coge antes de que ChefBot haya soltado el plato. ChefBot lo limpia, por supuesto, pero aun así, uno pensaría que estos robots de primera categoría sabrían estar un poco más sincronizados entre sí. Esto también ocurre en el bar: a veces WineBot hace un pedido de bebida en la barra, y WaitBot lo coge antes de que WineBot lo haya soltado, con el resultado de cristales rotos y Cabernet Sauvignon de Nederburg derramado.

Además, a veces GreetBot sienta a nuevos comensales exactamente en el mismo momento en que WaitBot ha decidido limpiar lo que creía que era una mesa vacía. Es bastante incómodo para los comensales. Has intentado añadir una lógica de retraso a la función de limpieza de WaitBot, o retrasos a la función de asiento de GreetBot, pero en realidad no ayudan, y las colisiones siguen produciéndose. Pero al menos estos sucesos son poco frecuentes.

Bueno, solían serlo. Tu restaurante se ha vuelto tan popular que has tenido que contratar unos cuantos ThreadBots más. Para las noches muy concurridas de viernes y sábado, has tenido que añadir un segundo GreetBot y dos WaitBots más. Desgraciadamente, los contratos de contratación de ThreadBots implican que tienes que contratarlos para toda la semana, por lo que esto significa que durante la mayor parte de la parte tranquila de la semana, llevas tres ThreadBots extra que realmente no necesitas.

El otro problema de recursos, además del coste adicional, es que te supone más trabajo ocuparte de estos ThreadBots adicionales. Estaba bien controlar sólo cuatro bots, pero ahora ya tienes siete. Llevar la cuenta de siete ThreadBots es mucho más trabajo, y como tu restaurante se hace cada vez más famoso, te empieza a preocupar tener aún más ThreadBots. Se va a convertir en un trabajo a tiempo completo el mero hecho de estar al tanto de lo que hace cada ThreadBot. Y otra cosa: estos ThreadBots adicionales están ocupando mucho más espacio dentro de tu restaurante. Se está convirtiendo en un apretón para tus clientes, con todos estos robots dando vueltas. Te preocupa que si tienes que añadir aún más robots, este problema de espacio se agrave aún más. Quieres utilizar el espacio de tu restaurante para los clientes, no para los ThreadBots.

Las colisiones también han empeorado desde que añadiste más ThreadBots. Ahora, a veces dos WaitBots toman exactamente el mismo pedido de la misma mesa al mismo tiempo. Es como si ambos se hubieran dado cuenta de que la mesa estaba lista para pedir y se hubieran movido para cogerlo, sin darse cuenta de que el otro WaitBot estaba haciendo exactamente lo mismo. Como puedes imaginar, esto da lugar a pedidos de comida duplicados, lo que provoca una carga extra en la cocina y aumenta la posibilidad de colisiones al recoger los platos listos. Te preocupa que si añades más WaitBots, este problema pueda empeorar.

El tiempo pasa.

Entonces, durante un servicio de viernes por la noche muy, muy ajetreado, tienes un momento singular de claridad: el tiempo se ralentiza, la lucidez te invade y ves una instantánea de tu restaurante congelada en el tiempo. ¡Mis ThreadBots no están haciendo nada! En realidad no nada, para ser justos, pero están... esperando.

Cada uno de tus tres WaitBots en mesas diferentes está esperando a que uno de los comensales de su mesa dé su pedido. El WineBot ya ha preparado 17 bebidas, que ahora están esperando a ser recogidas (tardó sólo unos segundos), y está esperando un nuevo pedido de bebidas. Uno de los GreetBots ha saludado a un nuevo grupo de invitados y les ha dicho que tienen que esperar un minuto para sentarse, y está esperando a que los invitados respondan. El otro GreetBot, que está procesando el pago con tarjeta de crédito de otro invitado que se marcha, está esperando confirmación en el dispositivo de la pasarela de pago. Incluso el ChefBot, que está cocinando 35 platos, no está haciendo nada en este momento, sino que simplemente está esperando a que uno de los platos termine de cocinarse para emplatarlo y entregárselo a un WaitBot.

Te das cuenta de que, aunque tu restaurante está ahora lleno de ThreadBots, e incluso te estás planteando conseguir más (con todos los problemas que eso conlleva), los que tienes actualmente no se están utilizando del todo.

El momento pasa, pero no la realización. El domingo, añades un módulo de recogida de datos a tus ThreadBots. Para cada ThreadBot, mides cuánto tiempo se pasa esperando y cuánto se pasa haciendo trabajo activamente. En el transcurso de la semana siguiente, se recopilan los datos. Luego, el domingo por la noche, analizas los resultados. Resulta que incluso cuando tu restaurante está a pleno rendimiento, el ThreadBot más trabajador está inactivo aproximadamente el 98% del tiempo. Los ThreadBots son tan enormemente eficientes que pueden realizar cualquier tarea en fracciones de segundo.

Como empresario, esta ineficacia te molesta mucho. Sabes que todos los demás propietarios de restaurantes robotizados llevan su negocio igual que tú, con muchos de los mismos problemas. Pero piensas, golpeando con el puño en tu mesa: "¡Tiene que haber una forma mejor!".

Así que al día siguiente, que es un lunes tranquilo, pruebas algo audaz: programas un solo ThreadBot para que haga todas las tareas. Cada vez que se pone a esperar, aunque sólo sea un segundo, el ThreadBot pasa a la siguiente tarea a realizar en el restaurante, sea cual sea, en lugar de esperar. Parece increíble -un solo ThreadBot haciendo el trabajo de todos los demás-, pero estás seguro de que tus cálculos son correctos. Y además, el lunes es un día tranquilo; aunque algo salga mal, el impacto será pequeño. Para este nuevo proyecto, llamas al robot "LoopBot" porque hará un bucle sobre todos los trabajos del restaurante.

La programación fue más difícil de lo habitual. No es sólo que tuvieras que programar un ThreadBot con todas las tareas diferentes; también tuviste que programar parte de la lógica de cuándo cambiar entre tareas. Pero a estas alturas, ya tienes mucha experiencia con la programación de estos ThreadBots, así que te las arreglas para conseguirlo.

Vigilas tu LoopBot como un halcón. Se mueve entre estaciones en fracciones de segundo, comprobando si hay trabajo por hacer. Poco después de abrir, llega el primer cliente a la recepción. El LoopBot se presenta casi de inmediato y pregunta si el cliente desea una mesa cerca de la ventana o cerca del bar. Pero entonces, cuando el LoopBot empieza a esperar, su programación le dice que cambie a la siguiente tarea, y sale zumbando. Esto parece un terrible error, pero entonces ves que cuando el cliente empieza a decir "Ventana, por favor", el LoopBot está de vuelta. Recibe la respuesta y dirige al cliente a la mesa 42. Y se pone en marcha de nuevo, comprobando los pedidos de bebidas, los pedidos de comida, la limpieza de las mesas y los clientes que llegan, una y otra vez.

A última hora de la tarde del lunes, te felicitas por un éxito notable: compruebas el módulo de recogida de datos del LoopBot y te confirma que, incluso con un solo ThreadBot haciendo el trabajo de siete, el tiempo de inactividad seguía siendo de alrededor del 97%. Este resultado te da confianza para continuar el experimento durante el resto de la semana.

Cuando se acerca el ajetreado servicio del viernes, reflexionas sobre el gran éxito de tu experimento. Para un servicio durante una semana laboral normal, puedes gestionar fácilmente la carga de trabajo con un solo LoopBot. Y te has dado cuenta de otra cosa: ya no se producen colisiones. Esto tiene sentido; como sólo hay un LoopBot, no puede confundirse consigo mismo. Se acabaron los pedidos duplicados a la cocina y las confusiones sobre cuándo coger un plato o una bebida.

El viernes por la tarde comienza el servicio, y como esperabas, el único ThreadBot sigue el ritmo de todos los clientes y tareas, y el servicio se desarrolla incluso mejor que antes. Imaginas que ahora puedes atender incluso a más clientes, y no tienes que preocuparte de tener que incorporar más ThreadBots. Piensas en todo el dinero que te vas a ahorrar.

Entonces, por desgracia, algo va mal: una de las comidas, un intrincado soufflé, ha fracasado. Esto no había ocurrido nunca en tu restaurante. Empiezas a estudiar el LoopBot más de cerca. Resulta que en una de tus mesas hay un cliente muy hablador. Este cliente ha venido solo a tu restaurante y no para de intentar entablar conversación con el LoopBot, incluso a veces cogiendo a tu LoopBot de la mano. Cuando esto ocurre, tu LoopBot es incapaz de salir corriendo a atender la lista cada vez mayor de tareas en el resto de tu restaurante. Por eso la cocina produjo su primer soufflé fallido: tu LoopBot fue incapaz de volver a la cocina para sacar el plato del horno porque estaba retenido por un invitado.

Termina el servicio del viernes y te diriges a casa para reflexionar sobre lo que has aprendido. Es cierto que el LoopBot pudo hacer todo el trabajo necesario en el ajetreado servicio del viernes; pero, por otra parte, tu cocina produjo su primera comida estropeada, algo que nunca había ocurrido antes. Los clientes parlanchines solían mantener ocupados a los WaitBots todo el tiempo, pero eso nunca afectó en absoluto al servicio de cocina.

Teniendo todo en cuenta, decides que sigue siendo mejor seguir utilizando un solo LoopBot. Ya no se producen esas preocupantes colisiones, y hay mucho más espacio en tu restaurante, espacio que puedes utilizar para más clientes. Pero te das cuenta de algo profundo sobre el LoopBot: sólo puede ser eficaz si cada tarea es corta, o al menos puede realizarse en un breve periodo de tiempo. Si alguna actividad mantiene ocupado al LoopBot durante demasiado tiempo, las demás tareas empezarán a descuidarse.

Es difícil saber de antemano qué tareas pueden llevar demasiado tiempo. ¿Qué ocurre si un huésped pide un cóctel que requiere una preparación intrincada, lo que lleva mucho más tiempo del habitual? ¿Y si un cliente quiere quejarse de una comida en recepción, se niega a pagar y agarra al LoopBot por el brazo, impidiéndole cambiar de tarea? Decides que, en lugar de resolver todos estos problemas por adelantado, es mejor continuar con el LoopBot, registrar toda la información posible y resolver los problemas más tarde, a medida que vayan surgiendo.

Pasa más tiempo.

Poco a poco, otros propietarios de restaurantes se dan cuenta de tu funcionamiento, y acaban por darse cuenta de que ellos también pueden arreglárselas, e incluso prosperar, con un solo ThreadBot. Se corre la voz. Pronto todos los restaurantes funcionan así, y resulta difícil recordar que los restaurantes robotizados hayan funcionado alguna vez con varios ThreadBots.

Epílogo

En nuestra historia, cada trabajador robot del restaurante es un único hilo. La observación clave de la historia es que la naturaleza del trabajo en el restaurante implica una gran cantidad de espera, al igual querequests.get() espera una respuesta de un servidor.

En un restaurante, el tiempo de espera de los trabajadores no es enorme cuando son humanos lentos los que hacen el trabajo manual, pero cuando son robots supereficientes y rápidos los que hacen el trabajo, casi todo su tiempo es de espera. En programación informática, ocurre lo mismo cuando se trata de programación en red. Las CPU hacen el trabajo y esperan en la E/S de la red. Las CPU de los ordenadores modernos son extremadamente rápidas: cientos de miles de veces más rápidas que el tráfico de red. Por lo tanto, las CPU que ejecutan programas de red pasan mucho tiempo esperando.

La idea de la historia es que los programas pueden escribirse para dirigir explícitamente a la CPU para que se mueva entre las tareas de trabajo según sea necesario. Aunque hay una mejora en la economía (utilizar menos CPUs para el mismo trabajo), la ventaja real, en comparación con un enfoque de hilos (multi-CPU), es la eliminación de las condiciones de carrera.

Sin embargo, no todo son rosas: como descubrimos en la historia, la mayoría de las soluciones tecnológicas tienen ventajas e inconvenientes. La introducción del LoopBot resolvió cierto tipo de problemas, pero también introdujo otros nuevos, entre ellos que el dueño del restaurante tuvo que aprender una forma de programar ligeramente distinta.

¿Qué problema intenta resolver Asyncio?

Para las cargas de trabajo de E/S, hay exactamente (¡sólo!) dos razones para utilizar la concurrencia basada en asíncronos frente a la concurrencia basada en hilos:

  • Asyncio ofrece una alternativa más segura a la multitarea preventiva (es decir, al uso de hilos), evitando así los errores, las condiciones de carrera y otros peligros no deterministas que se producen con frecuencia en las aplicaciones con hilos no triviales.

  • Asyncio ofrece una forma sencilla de soportar muchos miles de conexiones de socketsimultáneas, incluida la capacidad de gestionar muchas conexiones de larga duración para tecnologías más recientes como WebSockets, o MQTT para aplicaciones del Internet de las Cosas (IoT).

Ya está.

Los hilos -como modelo de programación- se adaptan mejor a ciertos tipos de tareas computacionales que se ejecutan mejor con varias CPU y memoria compartida para una comunicación eficaz entre los hilos. En tales tareas, el uso de procesamiento multinúcleo con memoria compartida es un mal necesario porque el dominio del problema lo requiere.

La programación en red no es uno de esos dominios. La idea clave es que la programación en red implica una gran cantidad de "esperar a que ocurran cosas" y, por ello, no necesitamos que el sistema operativo distribuya eficazmente nuestras tareas entre varias CPU. Además, no necesitamos los riesgos que conlleva la multitarea preventiva, como las condiciones de carrera cuando se trabaja con memoria compartida.

Sin embargo, hay mucha desinformación sobre otras supuestas ventajas de los modelos de programación basados en eventos. He aquí algunas de las cosas que simplemente no son así:

Asyncio hará que mi código sea rapidísimo.

Por desgracia, no. De hecho, la mayoría de los puntos de referencia parecen mostrar que las soluciones de hilos son ligeramente más rápidas que sus comparables de Asyncio. Sin embargo, si el grado de concurrencia en sí se considera una métrica de rendimiento , Asyncio facilita un poco la creación de un gran número de conexiones de socket concurrentes. Los sistemas operativos suelen tener límites en cuanto al número de hilos que pueden crearse, y este número es significativamente inferior al número de conexiones de socket que pueden realizarse. Los límites del SO pueden cambiarse, pero sin duda es más fácil hacerlo con Asyncio. Y aunque esperamos que tener muchos miles de subprocesos suponga costes adicionales de cambio de contexto que las coroutines evitan, resulta difícil evaluar esto en la práctica.1 No, la velocidad no es la ventaja de Asyncio en Python; si eso es lo que buscas, prueba con Cython.

Asyncio hace superfluo el roscado.

¡Desde luego que no! El verdadero valor de los hilos reside en poder escribir programas multi-CPU, en los que diferentes tareas de cálculo pueden compartir memoria. La biblioteca numérica numpy, por ejemplo, ya hace uso de esto acelerando ciertos cálculos matriciales mediante el uso de varias CPU, aunque toda la memoria sea compartida. En cuanto a rendimiento, no hay ningún competidor de este modelo de programación para el cálculo limitado a la CPU.

Asyncio elimina los problemas con el GIL.

De nuevo, no. Es cierto que Asyncio no se ve afectado por la GIL,2 pero esto es sólo porque la GIL afecta a los programas multihilo. Los "problemas" con la GIL a los que se refiere la gente ocurren porque impide el verdadero paralelismo multinúcleo cuando se utilizan hilos. Como Asyncio es monohilo (casi por definición), no se ve afectado por la GIL, pero tampoco puede beneficiarse de los múltiples núcleos de la CPU.3 También vale la pena señalar que en el código multihilo, la GIL de Python puede causar problemas de rendimiento adicionales, además de lo que ya se ha mencionado en otros puntos: Dave Beazley presentó una charla sobre esto llamada"Understanding the Python GIL" en la PyCon 2010, y gran parte de lo que se comenta en esa charla sigue siendo cierto hoy en día.

Asyncio evita todas las condiciones de carrera.

Falso. La posibilidad de que se produzcan condiciones de carrera siempre está presente en cualquier programación concurrente, independientemente de si se utiliza la programación basada en hilos o en eventos. Es cierto que Asyncio puede eliminar virtualmente cierta clase de condiciones de carrera comunes en los programas multihilo, como el acceso a memoria compartida intraproceso. Sin embargo, no elimina la posibilidad de otros tipos de condiciones de carrera, como las carreras interproceso con recursos compartidos comunes en arquitecturas de microservicios distribuidos. Debes seguir prestando atención a cómo se utilizan los recursos compartidos. La principal ventaja de Asyncio sobre el código con hilos es que los puntos en los que se transfiere el control de la ejecución entre coroutines son visibles (debido a la presencia de las palabras claveawait ), y por tanto es mucho más fácil razonar sobre cómo se accede a los recursos compartidos.

Asyncio facilita la programación concurrente.

¿Por dónde empiezo?

El último mito es el más peligroso. Tratar con la concurrencia siempre es complejo, independientemente de si utilizas hilos o Asyncio. Cuando los expertos dicen que "Asyncio facilita la concurrencia", lo que realmente quieren decir es que Asyncio hace que sea un poco más fácil evitar ciertos tipos de errores de condición de carrera realmente espantosos, de esos que te quitan el sueño y que cuentas a otros programadores en voz baja en las hogueras, con los lobos aullando a lo lejos.

Incluso con Asyncio, sigue habiendo una gran complejidad con la que lidiar. ¿Cómo soportará tu aplicación las comprobaciones de estado? ¿Cómo te comunicarás con una base de datos que puede permitir sólo unas pocas conexiones, muchas menos que tus cinco mil conexiones de socket a clientes? ¿Cómo terminará tu programa las conexiones de forma elegante cuando reciba una señal de cierre? ¿Cómo gestionarás el acceso al disco (¡bloqueante!) y el registro? Éstas son sólo algunas de las muchas y complejas decisiones de diseño a las que tendrás que dar respuesta.

El diseño de la aplicación seguirá siendo difícil, pero la esperanza es que te resulte más fácil razonar sobre la lógica de tu aplicación cuando sólo tengas que ocuparte de un hilo.

1 Parece difícil encontrar investigaciones en este campo, pero las cifras parecen rondar los 50 microsegundos por cambio de contexto de hilos en Linux sobre hardware moderno. Para dar una idea (muy) aproximada: mil hilos implican un coste total de 50 ms sólo por el cambio de contexto. Es cierto, pero tampoco va a destrozar tu aplicación.

2 El bloqueo global del intérprete (GIL) hace que el código del intérprete de Python (¡no tu código!) sea seguro para los hilos al bloquear el procesamiento de cada opcode; tiene el desafortunado efecto secundario de inmovilizar efectivamente la ejecución del intérprete en una sola CPU, y por tanto impide el paralelismo multinúcleo.

3 Esto es similar a por qué JavaScript carece de un "problema" GIL: sólo hay un hilo.

Get Utilizar Asyncio en Python now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.