async en Python : déclarer une fonction qui peut attendre sans bloquer

Pourquoi appeler une fonction async ne l'exécute pas, ce que le mot-clé fait vraiment gagner, et le piège du await oublié qui ne lève aucune erreur.
6 min de lecture
Believemy logo

Définition

Un script qui télécharge trente pages web passe presque tout son temps à ne rien faire : il envoie une requête, puis attend la réponse du serveur pendant que le processeur reste inoccupé. Trente fois de suite, le programme est lent sans avoir rien calculé.

async est le mot-clé qui permet de récupérer ce temps perdu. Placé devant def, il transforme une fonction ordinaire en fonction de coroutine : une fonction capable de se mettre en pause pour laisser la place à une autre pendant qu'elle attend.

La première conséquence surprend tout le monde : appeler une fonction normale l'exécute, appeler une fonction async ne l'exécute pas.

PYTHON
async def charger(url):
    print("Téléchargement de", url)
    return url.upper()

charger("rapport.csv")
# <coroutine object charger at 0x104e2f740>
# Rien ne s'est affiché

L'appel a renvoyé un objet coroutine : un travail décrit et prêt à partir, que personne n'a encore lancé. Il faudra le confier à un exécuteur.

Décrire un travail et le lancer deviennent ainsi deux gestes distincts : préparez-en cinquante avant d'en démarrer un seul, puis faites-les avancer ensemble.


Le trio async, await et asyncio

Beaucoup de code asynchrone qui ne va pas plus vite vient de la même confusion : croire que le mot-clé suffit. Trois pièces se partagent en réalité le travail.

PièceRôle
asyncDéclare que la fonction a le droit d'attendre
awaitMarque le point d'attente et rend la main pendant ce temps
asyncioFournit la boucle qui fait avancer toutes les tâches

Le mot-clé, seul, ne rend donc rien concurrent. Il ouvre une porte : dans une fonction déclarée ainsi, vous gagnez le droit d'écrire await, et c'est lui qui fait basculer d'une tâche à l'autre. Reste à démarrer la boucle, le rôle d'asyncio.run.

Les trois pièces réunies donnent ceci, où asyncio.gather lance les deux chargements ensemble.

PYTHON
import asyncio

async def principal():
    rapports = await asyncio.gather(
        charger("janvier.csv"),
        charger("fevrier.csv"),
    )
    return rapports

asyncio.run(principal())
Bon à savoir

Dans un notebook Jupyter, une boucle tourne déjà en arrière-plan et asyncio.run répond « asyncio.run() cannot be called from a running event loop ». Écrivez await principal() dans la cellule.


Ce qu'il fait gagner, et ce qu'il ne fait pas gagner

Le programme ira-t-il plus vite pour autant ? Cela dépend de l'endroit où il passe son temps, car le gain vient de l'attente, jamais du calcul. Une fonction async ne va pas plus vite : elle laisse la place aux autres pendant qu'elle patiente.

Le schéma compare les deux régimes sur trois appels réseau d'une seconde chacun.

Trois appels réseau d'une seconde : séquentiel contre asynchroneEn séquentiel, les trois attentes d'une seconde s'ajoutent et le programme se termine à trois secondes. En asynchrone, les trois mêmes attentes se recouvrent et tout est fini au bout d'une seconde.Séquentiel3 s au totalappel 1 : 1 sappel 2 : 1 sappel 3 : 1 sAsynchrone1 s au totalappel 1 : 1 sappel 2 : 1 sappel 3 : 1 s0 s1 s2 s3 sles attentes se recouvrent au lieu de s'additionner

Reste à savoir quels travaux relèvent de l'attente. Voici les quatre cas rencontrés en pratique.

Nature du travailEffet réel
Requêtes HTTP, appels d'APIGain massif, cent attentes tiennent dans le temps d'une seule
Accès à une base de donnéesGain net, à condition que la bibliothèque soit prévue pour
Calcul intensif, traitement d'imageAucun gain, le processeur reste occupé et personne ne rend la main
Lecture de fichier avec openAucun gain, l'appel fige la boucle entière

Les deux dernières lignes méritent une pause. Une seule instruction bloquante annule le bénéfice de tout le reste : la boucle est unique, et tant qu'elle est figée, aucune tâche n'avance.

Attention

Appeler time.sleep(2) ou requests.get dans une fonction async gèle le programme entier le temps de l'opération. Seuls les équivalents asynchrones, comme asyncio.sleep, rendent la main.

Pour du calcul pur, il faut des processus séparés. Le threading occupe la position intermédiaire, avec des fils confiés au système. La démonstration ci-dessous reprend la comparaison.

Ce que l'asynchrone fait gagner, et quand il ne fait rien gagner
5
90 %
En séquentiel5 s
En asynchrone1,4 s
Gain : 3,6 ×

Les appels passent l'essentiel de leur temps à attendre une réponse. Les attentes se recouvrent, la durée totale se rapproche de celle d'un seul appel : c'est le terrain où l'asynchrone change tout.

Le modèle suppose que les attentes se recouvrent parfaitement et que le calcul est sérialisé, ce qui est le comportement d'asyncio sur un seul fil. Le verrou global de l'interpréteur est la raison pour laquelle le calcul, lui, ne profite pas de l'asynchrone.


Les deux instructions qu'il modifie

À l'intérieur d'une fonction asynchrone, deux instructions familières posent le même problème : ouvrir une connexion prend du temps, lire un flux ligne à ligne aussi. Python leur donne une version qui sait attendre.

PYTHON
async def traiter(session):
    async with session.connexion() as conn:
        async for ligne in conn.flux("SELECT * FROM ventes"):
            await enregistrer(ligne)

async with attend l'ouverture puis la fermeture d'un gestionnaire de contexte, là où with les exécute d'un seul tenant. Une connexion peut ainsi s'établir sans immobiliser le reste du programme.

async for attend chaque élément d'une source qui les produit au fil de l'eau, quand une boucle for classique réclame le suivant immédiatement. Le principe vaut pour les générateurs asynchrones, où yield cohabite avec l'attente, et ces formes n'existent que dans une fonction async.


Le piège de l'appel oublié

Reste l'erreur qui coûte le plus de temps, d'autant plus retorse qu'elle ne lève aucune exception. Le programme se contente d'un avertissement discret, souvent noyé dans la sortie.

PYTHON
async def principal():
    charger("janvier.csv")        # oubli de await : rien ne part
    await charger("fevrier.csv")  # correct

# RuntimeWarning: coroutine 'charger' was never awaited

La première ligne a créé une coroutine puis l'a laissée tomber. Le programme se termine normalement, le fichier n'est jamais chargé, et la valeur attendue vaut souvent None quelques lignes plus loin, très loin de sa cause.

D'où le réflexe : tout appel à une fonction async qui n'est ni précédé de await, ni confié à une tâche, est un bug.


Questions fréquentes

Question

Peut-on appeler une fonction async depuis une fonction normale ?

Pas directement : l'appel renverrait un objet inerte. Il faut passer par asyncio.run, qui démarre la boucle et sert de point d'entrée, ou se trouver déjà dans du code asynchrone. D'où l'unique asyncio.run, tout en haut d'un projet.

Question

Faut-il déclarer toutes ses fonctions en async ?

Non, et le réflexe coûte cher. Le mot-clé se propage vers le haut : une fonction qui attend doit être attendue à son tour, et une seule suffit à contaminer toute la chaîne d'appels. Réservez-le aux fonctions qui font des entrées et sorties ; la logique métier reste en fonctions ordinaires, plus simples à tester.

Question

async remplace-t-il les threads ?

Pour l'attente réseau, oui : il consomme beaucoup moins de mémoire et évite les verrous. Pour le calcul, non, pas davantage face à une bibliothèque qui ne connaît que le mode bloquant. Cette frontière se travaille sur des cas réels dans la formation Python.

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.