Définition
Un programme qui interroge une base de données ou télécharge une page passe l'essentiel de son temps à attendre une réponse venue d'ailleurs. Le processeur reste libre, le programme reste bloqué sur sa ligne, et cent requêtes de suite suffisent à rendre l'application lente.
await existe pour récupérer ces temps morts. Il suspend la coroutine en cours jusqu'à l'arrivée du résultat et rend la main à la boucle d'événements, qui en profite pour faire avancer une autre tâche. L'attente de l'une devient le temps de travail de l'autre.
Deux conditions : le mot ne s'emploie que dans une fonction déclarée avec async, et il se place devant un objet capable de se faire attendre.
import asyncio
async def charger_profil(identifiant):
# La coroutine s'arrête ici, la boucle sert d'autres tâches
donnees = await requete_reseau(identifiant)
return donnees["nom"]
# Ouvre la boucle d'événements et lance la coroutine
asyncio.run(charger_profil(42))La ligne du milieu se lit comme un appel ordinaire, et c'est l'intention : le code garde la forme séquentielle d'un programme classique pendant que l'exécution sert d'autres tâches.
La dernière ligne lève une surprise fréquente : une coroutine ne démarre pas toute seule, et asyncio.run() ouvre la boucle qui l'accueille.
Ce que await accepte devant lui
Peut-on mettre n'importe quoi derrière ce mot ? Non. Il faut un objet dit attendable, qui expose la mécanique de suspension réclamée par la boucle d'événements. Vous n'en croiserez que trois, avec ce que chacun rend une fois l'attente terminée.
| Objet | D'où il vient | Ce que await en tire |
|---|---|---|
| Coroutine | L'appel d'une fonction async def | Sa valeur de return |
| Task | asyncio.create_task() | Le résultat, une fois la tâche terminée |
| Future | Une couche basse, rarement écrite à la main | La valeur déposée par le producteur |
La Task se distingue sur un point : elle démarre le travail dès sa création, quand une coroutine patiente jusqu'à ce qu'on l'attende.
Un générateur ordinaire, une liste ou un entier n'entrent dans aucune de ces familles, et la tentative lève une TypeError : la fonction appelée est en réalité synchrone, donc son résultat aussi.
Attendre n'est pas bloquer
Toute la nuance se joue sur une question : qui s'arrête ? Une attente ordinaire immobilise le programme entier, quand une attente avec await ne suspend que la coroutine qui la contient.
Encore faut-il attendre quelque chose qui joue le jeu. Une fonction bloquante appelée dans une coroutine gèle la boucle d'événements d'asyncio, qui attend son retour pour redistribuer le travail. Les deux fonctions ci-dessous se ressemblent et n'ont rien en commun.
import time
async def mauvais():
time.sleep(2) # fige le programme entier
async def bon():
await asyncio.sleep(2) # laisse tourner les autres tâchesLe gain déçoit souvent au premier essai, car il n'apparaît que si plusieurs attentes se recouvrent. Des await alignés s'exécutent dans l'ordre et coûtent la somme des durées. Pour les faire se chevaucher, il faut lancer les travaux ensemble.
# Quatre secondes : la deuxième attente commence après la première
a = await telecharger(url_1)
b = await telecharger(url_2)
# Deux secondes : les deux téléchargements partent en même temps
a, b = await asyncio.gather(telecharger(url_1), telecharger(url_2))Rien de tout cela ne relève du parallélisme : await ne crée ni fil d'exécution ni processus. Vos coroutines se relaient sur un seul fil, chacune reprenant la main quand la précédente se met à attendre.
Deux formes dérivées
Deux structures de contrôle portent la même attente sans qu'on ait à l'écrire.
async with attend l'ouverture puis la fermeture d'un gestionnaire de contexte asynchrone, par exemple une connexion réseau ou une session de base de données.
async for attend chaque élément livré par un itérateur asynchrone, les lignes d'une réponse qui arrivent au fil de l'eau par exemple, et rend la main entre deux éléments.
Même règle pour les deux : dans une fonction asynchrone, et sur un objet conçu pour, jamais son équivalent classique.
Les erreurs qui reviennent
La première est l'oubli. Appeler une coroutine sans await ne l'exécute pas : l'objet fabriqué n'est consommé par personne, et Python le signale à la fin par un coroutine was never awaited. Rien n'a planté, rien n'a été fait non plus, et ce silence rend le cas long à repérer.
La deuxième est l'emploi hors contexte. Un await écrit dans une fonction ordinaire lève une SyntaxError avant la moindre exécution : le compilateur doit savoir dès la déclaration s'il a affaire à une coroutine. Ajouter async devant def corrige la ligne, mais la fonction appelante devient asynchrone à son tour, jusqu'au point d'entrée.
La troisième ne provoque aucune erreur, ce qui la rend sournoise : bâtir une application asynchrone sur une bibliothèque qui ne l'est pas. Un client réseau synchrone ne devient pas attendable parce qu'on l'appelle dans une coroutine. Restent sa version asynchrone, ou le déport avec asyncio.to_thread(), qui rejoint la logique de threading.
Une application asynchrone posée sur un client synchrone traite ses requêtes une par une, comme la version classique, avec la complexité d'async en plus et aucun gain. Vérifiez que vos bibliothèques réseau ont une version asynchrone avant de choisir ce style.
Questions fréquentes
Pourquoi ma coroutine ne s'exécute-t-elle pas ?
Parce que l'appel a produit un objet coroutine que rien n'attend. Il faut un await devant, ou asyncio.create_task() si le travail doit démarrer en arrière-plan pendant que la suite continue.
Peut-on écrire await en dehors d'une fonction async ?
Non, et le refus arrive tôt : l'analyseur rejette le fichier avant la moindre exécution. Seules exceptions, les consoles prévues pour, comme python -m asyncio ou certains carnets, qui enveloppent chaque saisie dans une boucle d'événements.
await rend-il un programme plus rapide ?
Pas en soi, et c'est une déception classique. Il récupère les temps morts, donc les attentes réseau, disque ou base de données, et n'apporte rien à un calcul qui occupe le processeur de bout en bout.