PythonProgrammationDéveloppeur Python

Qu'est-ce qui empêche un objet coroutine **Python** d'être redémarré après avoir été attendu jusqu'à son achèvement, contrairement aux fonctions génératrices qui instancient de nouveaux itérateurs à chaque invocation ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Dans Python, les coroutines créées via async def sont implémentées comme des machines à états à usage unique régies par le drapeau CO_ITERABLE_COROUTINE ou des drapeaux de coroutine natifs au niveau du bytecode CPython. Lorsque vous appelez une fonction asynchrone, elle renvoie immédiatement un objet coroutine contenant un objet frame et un état d'exécution ; l'attente de celle-ci conduit cette machine à états à son achèvement, moment auquel le marqueur interne f_lasti (dernière instruction) atteint la fin et la frame est marquée comme épuisée. L'environnement d'exécution Python protège explicitement contre la réintroduire en vérifiant ce drapeau de complétion, levant une RuntimeError si des attentes ultérieures se produisent, car les coroutines sont conçues pour représenter des opérations asynchrones discrètes et singulières avec un flux de contrôle linéaire. En revanche, les fonctions génératrices sont des usines : chaque invocation crée un nouvel objet PyGenObject avec sa propre frame de pile et son pointeur d'instruction indépendants, permettant à la fonction de produire plusieurs itérateurs indépendants qui maintiennent chacun des contextes d'exécution séparés.

Situation de la vie quotidienne

Une équipe de développement construisait un client WebSocket résilient qui devait réessayer des tentatives de connexion échouées avec un backoff exponentiel. Ils ont d'abord défini une coroutine de connexion au niveau du module et tenté de la réutiliser dans la logique de réessai.

import asyncio async def establish_connection(): return await websockets.connect("wss://api.example.com") # Instanciation au niveau du module connection_coro = establish_connection() async def retry_connect(max_attempts=3): for attempt in range(max_attempts): try: ws = await connection_coro # Échoue à la deuxième itération return ws except Exception: await asyncio.sleep(2 ** attempt)

Le problème est apparu lorsque la seconde itération de la boucle a tenté d'attendre connection_coro à nouveau, déclenchant une RuntimeError car le premier await réussi avait déjà épuisé l'objet coroutine. L'équipe a envisagé trois solutions architecturales.

Une approche consistait à reconstruire manuellement l'objet coroutine à l'intérieur du bloc except après avoir attrapé la RuntimeError. Bien que techniquement réalisable, cela a introduit une gestion des états fragile et a rendu le code dépendant de la détection de l'épuisement via la gestion des exceptions, ce qui est sémantiquement ambigu et pourrait masquer des erreurs d'exécution légitimes au sein de la logique de connexion elle-même.

Une autre solution proposait de convertir establish_connection en une classe implémentant __await__ pour créer un objet attendable réinitialisable. Cela a fourni un patron de fabrication mais a ajouté un code redondant et de la complexité, obscurcissant l'intention simple d'établir une connexion et nécessitant un suivi manuel des états qui dupliquait ce que l'environnement d'exécution Python fournit déjà via des appels de fonction.

La solution choisie a été de considérer la fonction asynchrone comme une usine en déplaçant le point d'appel à l'intérieur de la boucle, garantissant que chaque itération recevait un objet coroutine neuf. En refactorisant pour ws = await establish_connection(), chaque tentative instancie une nouvelle machine à états avec une gestion des ressources indépendante. Cela s'alignait avec la philosophie de conception de Python, où les fonctions asynchrones sont des constructeurs pour des futurs computationnels à usage unique, résultant en une logique de réessai propre, sans exception, qui isolait correctement les tentatives de connexion échouées des réessais ultérieurs.

Ce que les candidats manquent souvent

Pourquoi le stockage d'une coroutine dans une variable et l'oubli de l'attendre créent-ils une fuite de ressources, et comment close() atténue-t-il cela ?

Les candidats supposent souvent que les coroutines non attendues sont simplement récupérées par le ramasse-miettes sans effets secondaires. Cependant, si une coroutine a pénétré dans son corps et est suspendue à une expression await (par exemple, maintenant une connexion à une base de données ou un verrou), la frame conserve des références à ces ressources. Appeler close() sur l'objet coroutine force une exception GeneratorExit à travers la frame, déclenchant des gestionnaires de contexte (async with) et des blocs try/finally pour libérer les ressources immédiatement. Sans close() explicite, ces ressources restent détenues jusqu'à ce que le ramasse-miettes cyclique fonctionne, ce qui peut être trop tard pour les scénarios d'épuisement du pool de connexions.

Quelle est la différence entre inspect.iscoroutine() et inspect.isawaitable(), et pourquoi cette distinction est-elle importante lors de l'écriture d'utilitaires asyncio génériques ?

inspect.iscoroutine() renvoie True uniquement pour les objets coroutine natifs créés par des fonctions async def, tandis que inspect.isawaitable() renvoie True pour tout objet implémentant __await__, y compris des coroutines, des tâches, des futurs et des objets attendables personnalisés. Les candidats oublient que les fonctions asyncio comme ensure_future() acceptent tout objet attendable, pas seulement des coroutines. Écrire des bibliothèques qui vérifient strictement iscoroutine() rejette des objets attendables valides comme asyncio.Queue().get() ou des objets futurs personnalisés, brisant le polymorphisme dans les fonctions utilitaires génériques conçues pour planifier des opérations asynchrones arbitraires.

Quelle est la différence entre async for et await lors de la consommation d'un générateur asynchrone, et pourquoi le premier exige-t-il que __aiter__ retourne le générateur lui-même plutôt qu'une coroutine ?

await consomme une coroutine ou un futur jusqu'à son achèvement et renvoie une seule valeur, tandis que async for itère sur un itérateur asynchrone, faisant pause à chaque yield à l'intérieur d'une fonction génératrice async def. Les candidats confondent async for avec l'attente d'une liste de coroutines. Il est crucial que __aiter__ retourne l'objet itérateur asynchrone directement (pas un objet attendable), car l'environnement d'exécution Python appelle __aiter__ de manière synchrone pour obtenir l'itérateur avant de commencer le protocole d'itération. Retourner une coroutine depuis __aiter__ provoque une TypeError, car le protocole attend un accès immédiat à la méthode __anext__ de l'itérateur pour entraîner la machine à états d'itération asynchrone.