8.2. Corrutinas y tareas

Las corrutinas son la unidad de trabajo a partir de la cual se construye un programa de asyncio; las tareas son la forma en que una aplicación ejecuta varias corrutinas de forma concurrente.

8.2.1. Corrutinas

Una corrutina es una función declarada con async def:

import asyncio

async def heartbeat(interval_ms):
    while True:
        print("tick")
        await asyncio.sleep_ms(interval_ms)

El cuerpo parece una función corriente, con un ingrediente extra: await. Allí donde la corrutina tiene que esperar algo —un sleep, una lectura de red, que se active un evento— hace await sobre una expresión que sabe cómo suspender la corrutina hasta que aquello que espera esté listo. En cada await la corrutina cede el control de vuelta a asyncio; asyncio la reanuda desde el mismo punto una vez que la operación esperada ha terminado.

El módulo asyncio incluye dos sleeps:

  • asyncio.sleep() —argumento en segundos, acepta un float.

  • asyncio.sleep_ms() —argumento en milisegundos, toma un int. Una extensión de MicroPython; suele ser la opción adecuada en la cámara porque los ajustes de tiempo del firmware tienen forma de milisegundos.

Un simple async def no hace nada por sí solo. Llamar a heartbeat(500) no ejecuta el cuerpo; devuelve un objeto corrutina que asyncio tiene que planificar. La forma más sencilla de planificar uno es asyncio.run():

asyncio.run(heartbeat(500))

asyncio.run() arranca el bucle de eventos, planifica la corrutina que se le entregó como punto de entrada de nivel superior, dirige el bucle hasta que esa corrutina retorna y luego desmonta el bucle. Para una única corrutina, ese es todo el programa. Para varias corrutinas, la aplicación recurre a las tareas.

8.2.2. Tareas

Una tarea es el envoltorio de asyncio alrededor de una corrutina que dice planifica esto de forma concurrente con la actual y déjame continuar. asyncio.create_task() crea una y devuelve un objeto Task que representa el trabajo planificado:

task = asyncio.create_task(heartbeat("fast", 100))

La corrutina está ahora en la planificación del bucle; el llamador no ha esperado por ella. La Task devuelta es el manejador que el llamador usa después para interactuar con ese trabajo en ejecución.

Una vez que la aplicación tiene el manejador, puede hacer tres cosas con él:

  • Esperar a que la tarea termine. Una Task es en sí misma esperable. result = await task suspende la corrutina actual hasta que la corrutina de task retorna, y luego se reanuda con lo que esa corrutina haya devuelto (o vuelve a lanzar lo que haya lanzado).

  • Cancelar la tarea. task.cancel() planifica que se lance asyncio.CancelledError dentro de la corrutina de la tarea en su siguiente await, dándole la oportunidad de ejecutar código de limpieza en un bloque finally. La página sobre tiempos de espera y cancelación cubre los detalles.

  • Identificarla más tarde. asyncio.current_task() devuelve la Task de la corrutina que se está ejecutando en ese momento. La mayoría de los scripts nunca la llaman; aparece en la instrumentación y en los manejadores de excepciones.

El script no tiene que capturar el manejador cada vez. Las tareas de fondo desechables que la aplicación arranca y deja ejecutándose pueden descartar el valor devuelto —el bucle las planifica igualmente:

import asyncio

async def heartbeat(name, interval_ms):
    while True:
        print(name)
        await asyncio.sleep_ms(interval_ms)

async def main():
    asyncio.create_task(heartbeat("fast", 100))
    asyncio.create_task(heartbeat("slow", 500))
    await asyncio.sleep(5)

asyncio.run(main())

Las dos llamadas a create_task planifican ambos heartbeats sin esperar a ninguno de ellos. El control regresa de inmediato a main, que luego hace await sobre un sleep de cinco segundos. Mientras duerme, las dos tareas de heartbeat avanzan; el bucle va alternando entre cualquier tarea que esté lista para ejecutarse. Tras cinco segundos main retorna, el bucle desmonta las tareas que sigan vivas y asyncio.run() regresa al llamador.

Captura el manejador siempre que la aplicación realmente necesite una de las tres operaciones anteriores. En la práctica eso significa casi siempre, porque cerrar limpiamente una aplicación implica cancelar las tareas de fondo que generó —la página de cancelación cubre el patrón.

8.2.3. La regla de las dos líneas

El programa mínimo de asyncio son las dos líneas con las que terminan los ejemplos anteriores:

async def main():
    ...

asyncio.run(main())

Todo lo demás —las tareas que crea la aplicación, las primitivas con las que las coordina, los streams que abre— ocurre dentro de main (y dentro de las corrutinas que main genera). Cuando un script se queda pequeño para el clásico bucle while True: csi0.snapshot() de la cámara, la respuesta no es llamar a asyncio.run() en varios sitios; es incorporar el nuevo trabajo a main como más tareas.