Définition
Un programme qui parle au réseau passe le plus clair de son temps à attendre. Il envoie une requête, puis reste une seconde sans rien faire, alors que dix autres requêtes auraient pu partir. Le problème est là : mettre un travail en pause sans arrêter tout le reste avec lui.
Une coroutine est la réponse de Python. C'est une fonction capable de s'interrompre en pleine exécution, de rendre la main au reste du programme, puis de reprendre là où elle s'était arrêtée, avec ses variables intactes. On la déclare en plaçant async devant def, et chaque endroit où elle accepte de s'interrompre porte un await.
Voici sa forme la plus courante, un travail qui passe presque toute sa durée à attendre.
import asyncio
async def telecharger(url):
print("début", url)
await asyncio.sleep(1) # seul endroit où la fonction rend la main
print("fin", url)
return url.upper()Le mot vient de « coopératif », à prendre au pied de la lettre : une coroutine ne se fait jamais interrompre de force, elle choisit ses points de pause. Vous voyez donc à la lecture les seuls endroits où elle peut être suspendue, ce qui vous dispense de protéger vos variables partagées. Les fils d'exécution de threading, eux, se coupent n'importe où.
L'appeler ne l'exécute pas
C'est la surprise du premier jour. Écrire telecharger("...") ne déclenche rien : l'appel fabrique un objet coroutine, une exécution préparée mais pas démarrée, et le rend aussitôt. Une fonction ordinaire travaille au moment de l'appel, celle-ci se contente d'emballer le travail.
tache = telecharger("https://exemple.fr")
print(tache)
# <coroutine object telecharger at 0x104f...>
# rien ne s'affiche : le corps n'a pas commencé
# RuntimeWarning: coroutine 'telecharger' was never awaitedReste à savoir qui lance ce travail, et il n'y a que deux réponses : un await depuis une autre coroutine, ou asyncio, avec asyncio.run() comme porte d'entrée. La première coroutine d'un programme ne peut être attendue par personne, puisqu'aucune autre ne tourne encore : asyncio.run() démarre la boucle d'événements et lui confie ce travail.
Oublier un await ne fait pas planter le programme. Python signale never awaited au moment où l'objet est ramassé, souvent longtemps après la ligne fautive. Le code continue, le travail n'a pas eu lieu, et cela se voit ailleurs : un None inattendu, un fichier vide.
Fonction, générateur, coroutine
Ces trois objets se déclarent presque de la même façon, ce qui les rend faciles à confondre. Tout se joue à l'appel : lisez le tableau en commençant par sa deuxième ligne.
| Critère | Fonction | Générateur | Coroutine |
|---|---|---|---|
| Déclaration | def | def avec yield | async def |
| Ce que l'appel rend | Le résultat | Un générateur | Un objet coroutine |
| Point de pause | Aucun | yield | await |
| Qui la relance | Personne | next() ou une boucle | La boucle d'événements |
| Valeur finale | Son return | Rarement lue | Le résultat de l'attente |
La parenté avec le générateur n'est pas une coïncidence : les coroutines sont nées de leur machinerie, et les deux gardent leur état pendant une pause. Ce qui les sépare, c'est qui décide de la reprise. Un générateur repart quand votre code réclame la valeur suivante, une coroutine quand un résultat extérieur arrive.
Là où elles font gagner du temps
Beaucoup passent leurs fonctions en async def, relancent le programme et le trouvent aussi lent qu'avant. Rien d'anormal : une coroutine seule n'accélère rien, elle rend seulement la pause négociable. Le gain arrive quand plusieurs attentes se recouvrent.
Les deux écritures suivantes appellent la même coroutine sur la même liste, et ne prennent pas du tout le même temps.
# Trois secondes : les attentes se suivent
for url in urls:
await telecharger(url)
# Une seconde : les attentes se recouvrent
await asyncio.gather(*(telecharger(url) for url in urls))La première est le contresens le plus répandu : await veut dire « arrêtez-vous ici jusqu'à la fin de cette attente », donc dans une boucle il fabrique une file. gather confie au contraire les coroutines à la boucle d'un seul coup, et rend les résultats dans l'ordre des arguments, jamais dans l'ordre d'arrivée.
Le piège du code bloquant
Une coroutine ne rend la main qu'à ses await, nulle part ailleurs. Un appel bloquant glissé au milieu gèle le programme entier, y compris les tâches qui n'ont rien à voir avec lui : la boucle attend elle aussi qu'il se termine.
C'est la panne la plus déroutante de l'asynchrone, parce qu'elle n'en a pas l'air : le code est correct, il ne lève aucune exception, et tout va aussi lentement qu'avant. Des deux fonctions suivantes, une seule mérite son async def.
async def mauvais():
time.sleep(2) # gèle toutes les tâches
async def bon():
await asyncio.sleep(2) # rend la main pendant deux secondesDans une coroutine, tout ce qui attend doit attendre avec await. Une bibliothèque réseau comme requests, une lecture de fichier ordinaire ou un calcul long ignorent ce protocole : il leur faut un équivalent asynchrone, ou un fil séparé via asyncio.to_thread().
Questions fréquentes
Faut-il passer tout son code en async ?
Non, et c'est même rarement une bonne idée. L'asynchrone paie sur les programmes qui passent leur temps à attendre du réseau, une base de données ou un disque. Sur un calcul qui occupe le processeur, il n'apporte aucun gain, faute d'attente à recouvrir. Regardez d'abord où votre programme perd réellement son temps.
Peut-on écrire await dans une fonction normale ?
Non, cela lève une SyntaxError à la lecture du fichier, avant la moindre exécution. Le mot désigne un point de pause, et une fonction ordinaire n'en possède aucun : il n'y a personne à qui rendre la main. Depuis du code synchrone, la coroutine se lance par asyncio.run().
Peut-on attendre deux fois le même objet coroutine ?
Non, il s'épuise comme un itérateur : le second await lève RuntimeError: cannot reuse already awaited coroutine. Pour refaire le travail, rappelez la fonction, qui fabrique un objet neuf. La formation Python déroule ce cycle, de l'objet créé au résultat récupéré.