#Una función no es una ejecución
Una función corrutina es una definición escrita con async def. Llamarla produce un objeto corrutina, que representa una ejecución concreta todavía pendiente. Una tarea, o Task, administra esa ejecución dentro del bucle: conserva su estado, resultado, excepción y petición de cancelación.
Con una función normal, calcular() ejecuta su cuerpo antes de devolver el resultado. Con una corrutina, consultar() devuelve un objeto que debe esperarse o programarse. Esta diferencia explica el aviso coroutine was never awaited: creaste una ejecución y nunca hiciste que avanzara.
import asyncio
async def consultar():
print("entra en consultar")
await asyncio.sleep(0.05)
return 42
async def main():
pendiente = consultar()
print("objeto creado")
resultado = await pendiente
print("resultado", resultado)
asyncio.run(main())
La salida empieza por «objeto creado», después «entra en consultar» y finalmente «resultado 42». El cuerpo no se ejecutó al llamar consultar(). await pendiente hace avanzar esa ejecución y recibe su return.
#Qué conserva una suspensión
await expresa una dependencia de resultado. Cuando la operación esperada todavía no puede completarse, la corrutina suspende su avance conservando variables locales y posición. El bucle puede atender otra tarea. Al llegar el resultado, continúa después del await; no vuelve a ejecutar la función desde el principio.
Si el resultado ya está disponible, await puede completarse sin suspender. Por eso no conviene confiar en que cualquier await dará un turno a otras tareas. Para ceder explícitamente en una demostración se utiliza await asyncio.sleep(0). No hace falta añadirlo entre todas las líneas de una aplicación: suele indicar que debes revisar un bucle de CPU demasiado largo.
Una operación esperable, o awaitable, es un objeto que admite await. Corrutinas y tareas lo son. También existe Future, un contenedor de resultado futuro empleado por bibliotecas y APIs de bajo nivel; para el código de este curso no necesitamos fabricarlos a mano.
#Programar varias ejecuciones antes de esperar
create_task() registra una corrutina en el bucle activo y devuelve la tarea. En la configuración normal de Python 3.12, podrá empezar cuando el llamador devuelva el control al bucle. No crea un hilo ni necesita que la esperes inmediatamente para progresar.
import asyncio
async def consultar(nombre, demora):
await asyncio.sleep(demora)
print("terminó", nombre)
return nombre.upper()
async def main():
lenta = asyncio.create_task(consultar("lenta", 0.1), name="consulta-lenta")
rapida = asyncio.create_task(consultar("rápida", 0.02), name="consulta-rapida")
print("resultado lento", await lenta)
print("resultado rápido", await rapida)
assert lenta.done() and rapida.done()
assert await rapida == "RÁPIDA"
asyncio.run(main())
«Terminó rápida» aparece antes que «terminó lenta», aunque main espere primero la lenta. La tarea rápida ya estaba programada. Esperarla después recoge el resultado guardado; no repite el trabajo.
Una corrutina consumida no admite una segunda ejecución con otro await; crea otra llamando de nuevo a la función. Una tarea terminada sí conserva su resultado y puede esperarse varias veces. task.result() consulta inmediatamente ese resultado: si aún no terminó, lanza InvalidStateError. No lo uses como sustituto de await para esperar.
#Quién prepara y cierra el bucle
En un script, asyncio.run(main()) crea el entorno de ejecución, hace progresar la corrutina principal y realiza el cierre del bucle y sus recursos administrados. Normalmente se llama una vez desde el punto de entrada. Dentro del programa pasas a funciones async y utilizas await.
En un notebook puede existir ya un bucle en el mismo hilo. Allí escribe await main() en una celda compatible; llamar a asyncio.run() dentro del bucle existente produce un error. No parches el bucle para ocultar esa diferencia: distingue quién posee el ciclo de vida.
Una biblioteca reutilizable debería ofrecer async def obtener_datos(...), no invocar asyncio.run() internamente cada vez. Así un programa de consola, un notebook o un servidor pueden integrarla bajo su propio ciclo de vida.
#Async with y async for sin magia
with establece un ámbito de adquisición y liberación de recursos. async with permite que entrar o salir de ese ámbito requiera una espera asíncrona. Por ejemplo, cerrar un cliente de red puede tener trabajo pendiente. No significa «ejecutar todas las líneas del bloque en paralelo».
# Fragmento de estructura: cliente es un gestor asíncrono ya construido.
async with cliente:
respuesta = await cliente.get(url)
async for consume elementos de un iterador cuya siguiente entrega puede necesitar una espera. Resulta útil al recibir bloques de una respuesta HTTP. El cuerpo sigue ejecutándose una vez por elemento y en orden. Presentaremos una instancia real de ambos mecanismos con HTTPX.
#Una tarea necesita dueño
Si main crea una tarea y termina inmediatamente, esa tarea no se vuelve un servicio independiente. El cierre de asyncio.run cancela tareas pendientes. Además, no recoger una excepción impide que tu programa decida qué hacer con el fallo.
Guardar tareas en una lista evita perder sus referencias, pero no resuelve su política de errores. Debes poder contestar quién espera cada tarea, quién la cancela y quién registra su fallo. Para un conjunto cuyo ciclo de vida pertenece a una operación, TaskGroup da un ámbito explícito y se desarrolla en el siguiente capítulo.
Una tarea «de fondo» también tiene dueño: normalmente el servicio que la creó. Si debe seguir después de apagar ese servicio, una tarea en memoria es el mecanismo equivocado. Necesitarás un trabajo persistente y otro proceso que lo reclame.
#Recursos ligados a un bucle
Crea clientes, colas y otros recursos asíncronos dentro del ámbito que los usará. Reutilizar un cliente entre varios asyncio.run() puede mezclar conexiones asociadas a un bucle que ya cerró. Del mismo modo, un asyncio.Lock no protege código que se ejecuta en otro hilo o proceso.
Es útil pasar un cliente como argumento a las funciones que consultan servicios: el dueño sabe cuándo cerrarlo y las funciones pequeñas no crean conexiones por su cuenta. Verás esta estructura completa en el proyecto final.
#Comprueba antes de continuar
Modifica el segundo ejemplo creando y esperando la tarea lenta antes de crear la rápida. Predice por qué desaparece el solapamiento. Después vuelve al original y cambia el orden de los dos await: el orden de recogida cambia, pero las dos tareas continúan programadas desde el comienzo.
Cuando distingues creación, planificación, suspensión y recogida, los errores de concurrencia dejan de parecer aleatorios. El siguiente paso es decidir qué tareas deben sobrevivir al fallo de otra.
#Referencias
- Corrutinas, tareas y objetos esperables, documentación de Python 3.12.
- Ejecutar programas asyncio, propiedad del bucle y cierre.