#Un plazo debe tener un dueño
Un deadline es el instante máximo hasta el que una operación puede consumir tiempo. Un timeout expresa una duración de espera. Si cada paso recibe cinco segundos nuevos, tres pasos pueden consumir quince segundos; si comparten un deadline de cinco segundos, el tiempo que gastó el primero deja de estar disponible para los demás.
Piensa en presupuesto de usuario, operación y conexión. El usuario puede tolerar dos segundos para todo el lote, cada endpoint 500 milisegundos y la apertura de una conexión 100. Los plazos se anidan, pero no representan lo mismo.
#Qué significa cancelar una tarea
task.cancel() solicita que se lance asyncio.CancelledError en la tarea en una oportunidad de suspensión. No confirma por sí solo que terminó. Esperar esa tarea permite saber que completó su salida y sus bloques de limpieza.
import asyncio
async def usar_recurso(iniciada, eventos):
eventos.append("abierto")
iniciada.set()
try:
await asyncio.sleep(60)
finally:
eventos.append("cerrado")
async def main():
iniciada = asyncio.Event()
eventos = []
tarea = asyncio.create_task(usar_recurso(iniciada, eventos))
await iniciada.wait()
tarea.cancel()
try:
await tarea
except asyncio.CancelledError:
print("cancelación confirmada")
assert tarea.cancelled()
assert eventos == ["abierto", "cerrado"]
print(eventos)
asyncio.run(main())
Event es una señal: set() la activa y wait() espera hasta que esté activa. Aquí evita una prueba basada en «dormir un poquito y confiar en que ya arrancó». Sabemos que se adquirió el recurso antes de cancelar.
El except CancelledError pertenece al dueño que solicitó la cancelación. Dentro de una operación normalmente debes limpiar y volver a propagarla. Desde Python 3.8, CancelledError deriva directamente de BaseException; un except Exception no la atrapa. No lo amplíes sin necesidad.
#Limpieza con finally y async with
finally se ejecuta al salir normalmente o con una excepción. Úsalo para soltar el recurso que realmente posees. Si lo adquiriste antes del try, comprueba que no existe una suspensión entre la adquisición y el ámbito que garantiza su liberación.
Para bibliotecas que ofrecen un gestor de contexto, async with suele ser más claro: expresa de forma conjunta apertura, uso y cierre. En un cliente HTTP, el bloque puede cerrar conexiones aunque la descarga se cancele. Esto no deshace una petición que el servidor ya ejecutó.
Una limpieza asíncrona también puede esperar y recibir nuevas cancelaciones. Mantén el cierre acotado y usa las garantías de la biblioteca. No envuelvas toda operación en shield: ese mecanismo evita que la cancelación del llamador cancele directamente la tarea protegida, pero el llamador sigue recibiendo cancelación y la tarea protegida sigue necesitando un dueño que la espere. No es un botón para «hacer que nada falle».
#Timeout de un bloque completo
asyncio.timeout() cancela la tarea actual al vencer el presupuesto y transforma su propia cancelación en TimeoutError al salir del contexto. Captura ese error fuera del bloque.
import asyncio
async def main():
eventos = []
try:
async with asyncio.timeout(0.08):
await asyncio.sleep(0.05)
eventos.append("primer paso")
try:
await asyncio.sleep(0.05)
finally:
eventos.append("limpieza")
except TimeoutError:
eventos.append("plazo agotado")
assert eventos == ["primer paso", "limpieza", "plazo agotado"]
print(eventos)
asyncio.run(main())
El segundo paso no dispone de otros 80 milisegundos: hereda el tiempo restante. La limpieza puede prolongar el tiempo de salida; un timeout cooperativo es una política de cancelación, no una garantía de que el proceso desaparecerá exactamente en ese instante.
#Llevar el mismo deadline entre funciones
Para un presupuesto compartido calcula una vez deadline = loop.time() + segundos y utiliza asyncio.timeout_at(deadline). loop.time() es un reloj monotónico: no retrocede por cambiar la hora del sistema. Ese valor solo tiene sentido dentro del entorno de tiempo que lo generó; no lo guardes como fecha de calendario ni lo envíes como timestamp universal a otra máquina.
# Fragmento dentro de una corrutina que procesa una entrada.
loop = asyncio.get_running_loop()
deadline = loop.time() + 2.0
async with asyncio.timeout_at(deadline):
await esperar_turno()
await consultar_servicio()
Las dos funciones se dejan como nombres de sus responsabilidades, no como un programa ejecutable. El proyecto las sustituye por cola, control de tasa y HTTP. La decisión importante es si el tiempo en cola cuenta: para una respuesta con compromiso de latencia suele contar; para una tarea de mantenimiento puede existir un presupuesto distinto desde que empieza el worker.
#Wait_for y la cancelación del objeto esperado
await asyncio.wait_for(operacion(), timeout=1) limita una operación esperable y solicita cancelarla si vence el plazo. Espera a que la cancelación se complete, de modo que puede tardar más de un segundo. timeout resulta cómodo cuando quieres abarcar varias esperas dentro de un bloque; wait_for puede ser útil para una sola espera.
No reutilices por accidente una tarea que wait_for ya canceló. Si varias partes comparten una tarea, cancelar la espera de una puede afectar a ese trabajo común. En ese caso debes diseñar explícitamente su propietario y su política; el mero hecho de guardar la tarea en un diccionario no define quién puede cancelarla.
#Cancelar no deshace un efecto
Si cancelas una descarga, normalmente puedes cerrar la conexión y descartar los bytes. Si cancelas un POST que crea un pedido, el servidor puede haber confirmado el pedido antes de que tú dejaras de esperar. «Timeout» significa que no recibiste confirmación dentro del presupuesto, no que el pedido no exista.
Para efectos remotos utiliza identificadores estables y operaciones idempotentes, consulta el estado o reconcilia después. Tampoco un hilo delegado termina automáticamente al cancelar su await; lo demostraremos en el capítulo de hilos y procesos.
#Cómo probar cancelación de verdad
Una prueba útil coordina el instante de cancelación con una señal, espera la salida y comprueba el estado final. Debe verificar que se liberó el permiso, que se cerró el recurso y que no quedaron tareas hijas activas. «No apareció una excepción en pantalla» no prueba ninguna de esas propiedades.
No uses asyncio.all_tasks() para cancelar indiscriminadamente todo un servidor: incluye tareas que podrían pertenecer a otras funciones o bibliotecas. Guarda las referencias de tus tareas o usa un ámbito TaskGroup. En el proyecto compararemos las tareas antes y después únicamente como comprobación de laboratorio.