PythonProgramaciónDesarrollador de Python

¿Qué impide que un objeto coroutine de **Python** se reinicie después de haber sido esperado hasta completarse, a diferencia de las funciones generadoras que instancian nuevos iteradores en cada invocación?

Supere entrevistas con el asistente de IA Hintsage

Respuesta a la pregunta

En Python, las coroutines creadas con async def se implementan como máquinas de estado de un solo uso controladas por los flags CO_ITERABLE_COROUTINE o nativos de coroutine a nivel de bytecode de CPython. Cuando llamas a una función async, devuelve inmediatamente un objeto coroutine que contiene un objeto frame y un estado de ejecución; al esperarlo, se mueve esta máquina de estado a su finalización, momento en el cual el marcador interno f_lasti (última instrucción) alcanza el final y el frame se marca como agotado. El runtime de Python protege explícitamente contra la reentrada verificando esta bandera de finalización, levantando un RuntimeError si ocurren esperas subsiguientes, porque las coroutines están diseñadas para representar operaciones asincrónicas discretas y singulares con un flujo de control lineal. Por el contrario, las funciones generadoras son fábricas: cada invocación crea un nuevo PyGenObject con su propio frame de stack e indicador de instrucción independiente, permitiendo que la función produzca múltiples iteradores independientes que mantienen contextos de ejecución separados.

Situación de la vida real

Un equipo de desarrollo estaba construyendo un cliente WebSocket resiliente que necesitaba reintentar intentos de conexión fallidos utilizando un retroceso exponencial. Inicialmente definieron una coroutine de conexión a nivel de módulo y trataron de reutilizarla a través de la lógica de reintento.

import asyncio async def establish_connection(): return await websockets.connect("wss://api.example.com") # Instanciación a nivel de módulo connection_coro = establish_connection() async def retry_connect(max_attempts=3): for attempt in range(max_attempts): try: ws = await connection_coro # Falla en la segunda iteración return ws except Exception: await asyncio.sleep(2 ** attempt)

El problema surgió cuando la segunda iteración del ciclo intentó esperar connection_coro nuevamente, lo que activó un RuntimeError porque el primer await exitoso ya había agotado el objeto coroutine. El equipo consideró tres soluciones arquitectónicas.

Un enfoque consistió en reconstruir manualmente el objeto coroutine dentro del bloque except después de capturar el RuntimeError. Si bien era técnicamente factible, esto introdujo una gestión del estado frágil y hizo que el código dependiera de la detección de agotamiento a través del manejo de excepciones, lo que es semánticamente ambiguo y podría enmascarar errores de runtime legítimos dentro de la lógica de conexión misma.

Otra solución proponía convertir establish_connection en una clase que implementara __await__ para crear un objeto awaitable reiniciable. Esto proporcionaba un patrón de fábrica pero añadía un boilerplate y complejidad innecesarias, oscureciendo la simple intención de establecer una conexión y requiriendo seguimiento manual del estado que duplicaba lo que el runtime de Python ya proporciona a través de las llamadas a función.

La solución elegida fue tratar la función async como una fábrica moviendo el sitio de llamada dentro del ciclo, asegurando que cada iteración recibiera un objeto coroutine puro. Al refactorizar a ws = await establish_connection(), cada intento instanciaba una nueva máquina de estado con gestión de recursos independiente. Esto alineó con la filosofía de diseño de Python donde las funciones async son constructores para futuros computacionales de un solo uso, resultando en una lógica de reintento clara y libre de excepciones que aisló adecuadamente los intentos de conexión fallidos de los reintentos subsiguientes.

Lo que a menudo pasan por alto los candidatos

¿Por qué almacenar una coroutine en una variable y olvidar esperarla crea una fuga de recursos, y cómo mitiga esto close()?

Los candidatos a menudo asumen que las coroutines no esperadas simplemente se recolectan sin efectos secundarios. Sin embargo, si una coroutine ha entrado en su cuerpo y se ha suspendido en una expresión await (por ejemplo, retener una conexión a la base de datos o un bloqueo), el frame retiene referencias a estos recursos. Llamar a close() en el objeto coroutine fuerza una excepción GeneratorExit a través del frame, activando administradores de contexto (async with) y bloques try/finally para liberar recursos de inmediato. Sin un close() explícito, estos recursos permanecen retenidos hasta que se ejecute la recolección cíclica de basura, lo que puede ser demasiado tarde para escenarios de agotamiento de grupo de conexiones.

¿Cómo difiere inspect.iscoroutine() de inspect.isawaitable(), y por qué importa esta distinción al escribir utilidades genéricas de asyncio?

inspect.iscoroutine() devuelve True solo para objetos coroutine nativos creados por funciones async def, mientras que inspect.isawaitable() devuelve True para cualquier objeto que implemente __await__, incluyendo coroutines, tareas, futuros y awaitables personalizados. Los candidatos no se dan cuenta de que funciones de asyncio como ensure_future() aceptan cualquier awaitable, no solo coroutines. Escribir bibliotecas que verifiquen estrictamente iscoroutine() rechaza awaitables válidos como asyncio.Queue().get() u objetos futuros personalizados, rompiendo la polimorfismo en funciones utilitarias genéricas diseñadas para programar operaciones asincrónicas arbitrarias.

¿Cuál es la diferencia entre async for y await al consumir un generador asincrónico, y por qué el primero requiere que __aiter__ devuelva el generador en sí en lugar de una coroutine?

await consume una coroutine o futuro hasta completarse y devuelve un solo valor, mientras que async for itera sobre un iterador asincrónico, pausando en cada yield dentro de una función generadora async def. Los candidatos confunden async for con esperar una lista de coroutines. Crucialmente, __aiter__ debe devolver directamente el objeto iterador asincrónico (no un awaitable), porque el runtime de Python llama a __aiter__ de forma sincrónica para obtener el iterador antes de comenzar el protocolo de iteración. Devolver una coroutine desde __aiter__ provoca un TypeError, ya que el protocolo espera acceso inmediato al método __anext__ del iterador para impulsar la máquina de estado de iteración asincrónica.