Une coroutine en Python : la fonction qui se met en pause et reprend

Une coroutine s'interrompt en pleine exécution puis reprend là où elle s'était arrêtée. Pourquoi l'appeler ne suffit pas à la lancer.
5 min de lecture
Believemy logo

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.

PYTHON
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.

PYTHON
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 awaited

Reste à 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.

Attention

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èreFonctionGénérateurCoroutine
Déclarationdefdef avec yieldasync def
Ce que l'appel rendLe résultatUn générateurUn objet coroutine
Point de pauseAucunyieldawait
Qui la relancePersonnenext() ou une boucleLa boucle d'événements
Valeur finaleSon returnRarement lueLe 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.

PYTHON
# 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.

PYTHON
async def mauvais():
    time.sleep(2)           # gèle toutes les tâches

async def bon():
    await asyncio.sleep(2)  # rend la main pendant deux secondes

Dans 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

Question

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.

Question

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().

Question

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é.

Termes connexes

Découvrez notre glossaire Python

Parcourez les termes et définitions les plus couramment utilisés dans le domaine du développement avec Python.

Partager cet article

Tu veux nous aider ? Fais un lien vers cet article sur tes réseaux ou encore mieux : sur ton site, dans un article ou dans ta newsletter.