Bases

Asyncio en Python : async/await, TaskGroup, threads, multiprocessing et GIL

Mis à jour: · Skillaria.Top

Asyncio est la bibliothèque intégrée de Python pour le code asynchrone : les fonctions déclarées avec async def (coroutines) se suspendent sur await, et pendant ce temps la boucle d'événements exécute d'autres tâches, si bien que des centaines de requêtes réseau avancent en même temps dans un seul thread. Pour attendre le réseau ou le disque, on utilise asyncio ou les threads (threading) ; pour les calculs lourds, les processus (multiprocessing), car le GIL empêche les threads de CPython classique de calculer en parallèle.

Asyncio est la bibliothèque intégrée de Python pour le code asynchrone. Les fonctions déclarées avec async def savent se suspendre sur await, et pendant qu'une fonction attend la réponse du réseau, la boucle d'événements en exécute d'autres. Ainsi, des centaines de requêtes avancent en même temps dans un seul thread. Mais asyncio n'est qu'un des trois outils disponibles : il existe aussi les threads (threading) et les processus (multiprocessing). Voyons comment fonctionne asyncio en Python, quel outil choisir selon la tâche, et quel rôle joue le GIL.

Attendre ou calculer : deux problèmes différents

Sans cette distinction, la suite serait incompréhensible. Un programme ralentit pour l'une de deux raisons :

  • I/O-bound — il attend des entrées-sorties : la réponse d'une API, d'une base de données, la lecture d'un disque. Le processeur reste alors inactif.
  • CPU-bound — il calcule : traitement d'images, boucles sur des millions d'enregistrements. Le processeur est chargé.

Pour l'attente, asyncio ou les threads conviennent : pendant qu'une opération attend, une autre s'exécute. Pour les calculs, il faut une véritable exécution parallèle sur plusieurs cœurs, donc des processus. Confondre les deux est l'erreur principale : les threads n'accélèrent pas un calcul en Python pur, et lancer des processus pour trois requêtes HTTP revient à utiliser un marteau-pilon pour écraser une mouche.

Qu'est-ce qu'asyncio : coroutines et boucle d'événements

Le code asynchrone repose sur deux mots-clés. async def déclare une coroutine, c'est-à-dire une fonction capable de se suspendre. Son appel n'exécute pas le corps de la fonction, mais crée un objet coroutine. await signifie à l'intérieur : « j'attends ici, et toi, boucle, occupe-toi des autres tâches ». Le tout est piloté par la boucle d'événements (event loop), lancée par asyncio.run().

import asyncio

async def obtenir_taux(devise):
    await asyncio.sleep(1)          # simulation d'un appel à une API
    return {"USD": 92.5, "EUR": 100.1, "CNY": 12.7}[devise]

async def main():
    taux = await obtenir_taux("USD")
    print(taux)

asyncio.run(main())  # 92.5

asyncio.sleep() est une pause asynchrone : elle rend la main à la boucle d'événements. En revanche, un time.sleep() ordinaire dans une coroutine bloquerait toute la boucle, et toutes les autres tâches attendraient.

L'un après l'autre ou en même temps

Si l'on écrit await dans une boucle, les requêtes partent les unes après les autres : trois devises prendront trois secondes. Pour qu'elles avancent simultanément, il faut les lancer comme des tâches.

import asyncio, time

async def main():
    debut = time.perf_counter()
    for devise in ("USD", "EUR", "CNY"):
        await obtenir_taux(devise)                       # l'une après l'autre
    print(f"{time.perf_counter() - debut:.1f} s")        # 3.0 s

asyncio.run(main())

TaskGroup : lancer ensemble et attendre tout le monde

La méthode moderne (Python 3.11 et plus) est le groupe de tâches. Dans le bloc async with, chaque tâche démarre immédiatement via tg.create_task(), et la sortie du bloc n'a lieu qu'une fois toutes les tâches terminées.

async def main():
    debut = time.perf_counter()
    async with asyncio.TaskGroup() as tg:
        taches = {d: tg.create_task(obtenir_taux(d)) for d in ("USD", "EUR", "CNY")}
    taux = {d: t.result() for d, t in taches.items()}
    print(taux, f"{time.perf_counter() - debut:.1f} s")   # ... 1.0 s

asyncio.run(main())

TaskGroup est strict : si une tâche échoue, les autres sont annulées, et toutes les erreurs sont rassemblées dans un ExceptionGroup. On les intercepte avec except* :

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(obtenir_taux("USD"))
            tg.create_task(obtenir_taux("XXX"))   # KeyError
    except* KeyError as eg:
        print("devise inconnue :", eg.exceptions)

asyncio.gather et create_task

Avant TaskGroup, la référence était asyncio.gather() : il lance les coroutines en même temps et renvoie les résultats dans le même ordre. On en trouve encore beaucoup dans le code existant.

async def main():
    usd, eur, cny = await asyncio.gather(
        obtenir_taux("USD"), obtenir_taux("EUR"), obtenir_taux("CNY")
    )
    # avec return_exceptions=True, les erreurs sont renvoyées dans la liste au lieu d'interrompre tout

asyncio.create_task() lance une coroutine en arrière-plan, sans groupe. Il faut conserver une référence à la tâche, sinon le ramasse-miettes peut la supprimer, et personne ne verra ses erreurs. Pour les nouveaux projets, TaskGroup est donc plus sûr.

Annulation des tâches et délais

async def main():
    tache = asyncio.create_task(obtenir_taux("USD"))
    tache.cancel()                    # une CancelledError sera levée à l'intérieur de la tâche
    try:
        await tache
    except asyncio.CancelledError:
        print("annulée")

    try:
        async with asyncio.timeout(0.5):   # Python 3.11+
            await obtenir_taux("EUR")
    except TimeoutError:
        print("trop long")

Dans les anciennes versions, on utilise asyncio.wait_for(coro, timeout=0.5) à la place de asyncio.timeout(). Il existe aussi asyncio.wait(), qui renvoie les ensembles de tâches terminées et non terminées, et peut attendre la première d'entre elles (return_when=asyncio.FIRST_COMPLETED).

Dans Python 3.15, comme le montre le cours, TaskGroup dispose d'une méthode tg.cancel() : elle annule toutes les tâches du groupe sans exception. Cela couvre le scénario « premier arrivé » : interroger trois serveurs miroirs, prendre la première réponse et annuler les autres. Auparavant, il fallait lever une exception-signal personnalisée ou annuler les tâches à la main.

Synchronisation : Semaphore, Lock, Event, Queue

Asyncio possède ses propres primitives, qui fonctionnent avec await. La plus utile est Semaphore : elle limite le nombre d'opérations simultanées, pour ne pas submerger une API externe avec un millier de requêtes.

semaphore = asyncio.Semaphore(10)

async def limitee(url):
    async with semaphore:          # 10 opérations au maximum à la fois
        await asyncio.sleep(0.1)   # ici se trouverait la requête
        return url
  • asyncio.Lock — une seule coroutine à la fois dans la section critique.
  • asyncio.Event — une coroutine attend avec await event.wait(), une autre signale avec event.set().
  • asyncio.Queue — file « producteur — consommateur » : await q.put(), await q.get(), q.task_done().

Piège : oublier await

L'erreur la plus fréquente consiste à appeler une coroutine sans await :

async def main():
    taux = obtenir_taux("USD")    # await oublié
    print(taux * 2)               # TypeError
# RuntimeWarning: coroutine 'obtenir_taux' was never awaited

Python n'a pas exécuté la fonction : il a seulement créé l'objet coroutine. Si vous voyez « was never awaited » dans les journaux, cherchez le await manquant. La deuxième erreur typique est un appel bloquant (time.sleep, requests.get) dans une coroutine. Pour le HTTP, utilisez des bibliothèques asynchrones (httpx, aiohttp), et confiez le code synchrone inévitable à un thread avec await asyncio.to_thread(fonction, argument).

Multithreading en Python : le module threading

Un thread est un fil d'exécution indépendant à l'intérieur d'un même processus ; les threads partagent la mémoire. Pour attendre le réseau, ils fonctionnent très bien, sans qu'il soit nécessaire de réécrire le code en asynchrone.

import threading, time

def telecharger(nom):
    time.sleep(1)               # attente du réseau
    print(nom, "terminé")

threads = [threading.Thread(target=telecharger, args=(n,)) for n in ("a", "b", "c")]
for t in threads:
    t.start()
for t in threads:
    t.join()                    # le tout prend environ 1 seconde

La mémoire partagée est à la fois un avantage et une source d'erreurs : si deux threads modifient la même variable, il faut un threading.Lock. Le plus simple est en général de ne pas gérer les threads à la main, mais d'utiliser un pool.

ThreadPoolExecutor

concurrent.futures.ThreadPoolExecutor crée un pool de threads et leur distribue les tâches. map renvoie les résultats dans l'ordre d'origine.

from concurrent.futures import ThreadPoolExecutor
import time

def recuperer(url):
    time.sleep(1)
    return f"{url} : 200"

urls = [f"https://example.com/{i}" for i in range(5)]
with ThreadPoolExecutor(max_workers=5) as pool:
    for resultat in pool.map(recuperer, urls):
        print(resultat)         # le tout prend environ 1 seconde

Le GIL et pourquoi les threads n'accélèrent pas les calculs

Le GIL (Global Interpreter Lock) est le verrou global de l'interpréteur CPython. À chaque instant, un seul thread exécute du bytecode Python. Pendant qu'un thread attend une entrée-sortie, il relâche le GIL : les threads sont donc utiles pour le réseau. En revanche, sur des calculs purs, quatre threads ne vont pas plus vite qu'un seul : ils se passent le verrou à tour de rôle.

from concurrent.futures import ThreadPoolExecutor

def lourd(n):
    return sum(i * i for i in range(n))

with ThreadPoolExecutor(4) as pool:
    list(pool.map(lourd, [5_000_000] * 4))   # à peu près la même durée qu'en séquentiel

Les bibliothèques écrites en C (NumPy et consorts) relâchent souvent le GIL pendant les opérations lourdes ; pour elles, les threads apportent bien un gain.

multiprocessing : le parallélisme réel

Chaque processus possède son propre interpréteur et donc son propre GIL : les processus calculent simultanément sur des cœurs différents. Le prix à payer est une mémoire séparée : les données sont transmises entre processus par sérialisation (pickle), et lancer un processus coûte plus cher qu'un thread.

from concurrent.futures import ProcessPoolExecutor

def lourd(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":      # obligatoire sous Windows et macOS
    with ProcessPoolExecutor() as pool:
        print(list(pool.map(lourd, [5_000_000] * 4)))

Sans la vérification if __name__ == "__main__", les processus enfants, en important le module, lanceraient à leur tour de nouveaux processus. Il existe aussi le module bas niveau multiprocessing avec Process, Pool et Queue, mais ProcessPoolExecutor est plus simple et possède la même interface que le pool de threads.

Que choisir : asyncio, threading ou multiprocessing

TâcheOutilPourquoi
Télécharger 200 pages, nombreuses requêtes à une APIasyncio (httpx/aiohttp) ou ThreadPoolExecutorattente du réseau, processeur libre
Plusieurs requêtes dans un programme déjà asynchroneTaskGrouple code est déjà en async
Bibliothèque synchrone sans version asyncthreads, asyncio.to_threadpas besoin de réécrire le code
Calcul lourd en Python purProcessPoolExecutorcontourne le GIL, utilise tous les cœurs

Python sans GIL et sous-interpréteurs : les nouveautés

Depuis Python 3.13, il existe une version de l'interpréteur sans GIL (free-threading, PEP 703), reconnaissable à la lettre t dans son nom, par exemple python3.15t. Expérimentale en 3.13, elle est officiellement prise en charge depuis la 3.14 (PEP 779). Dans cette version, les threads calculent réellement en parallèle sur plusieurs cœurs. Mais par défaut, vous obtenez toujours le Python classique avec GIL : le code mono-thread tourne un peu plus lentement dans la version free-threaded, et toutes les bibliothèques ne sont pas encore prêtes pour elle.

import sys
print(sys._is_gil_enabled())   # False dans la version free-threaded

Une seconde voie vers le calcul parallèle est apparue dans la version 3.14 (PEP 734) : les sous-interpréteurs, dans le module concurrent.interpreters. Il s'agit de plusieurs interpréteurs isolés dans un même processus, chacun avec son propre GIL. Le plus simple est d'utiliser InterpreterPoolExecutor, dont l'usage ressemble à celui d'un pool de threads. Dans la mesure faite pour le cours, quatre tâches lourdes prennent 0,39 s contre 1,03 s en un seul thread.

En bref

Asyncio en Python est un outil pour les tâches d'attente : des coroutines déclarées avec async def, des pauses sur await, et un lancement simultané via TaskGroup ou gather. Les threads résolvent le même problème sans réécrire le code en async ; pour les calculs lourds, il faut des processus, car le GIL empêche les threads de CPython classique de calculer en parallèle. Le free-threading et les sous-interpréteurs modifient progressivement ce tableau, mais la règle de base reste valable : pour attendre, asyncio ou les threads ; pour calculer, les processus. Le cours met tout cela en pratique : le suivi des dépenses récupère les taux de change en parallèle en une seconde au lieu de trois.

Questions fréquentes

Peut-on appeler une fonction async depuis du code ordinaire ?

Oui, avec asyncio.run(coro()) : il crée la boucle d'événements, exécute la coroutine et ferme la boucle. Depuis une boucle déjà en cours, asyncio.run ne peut pas être appelé : on utilise alors await.

Asyncio fonctionne-t-il sur plusieurs threads ?

Non, la boucle d'événements exécute toutes les coroutines dans un seul thread et ne change de tâche qu'aux points await. Il y a donc moins de conflits d'accès qu'avec les threads, mais une seule opération bloquante fige tout.

Faut-il installer asyncio avec pip ?

Non, asyncio fait partie de la bibliothèque standard de Python 3 ; il suffit d'écrire import asyncio. Le paquet asyncio sur PyPI est obsolète et inutile.

Comment lancer une fonction lourde depuis asyncio sans bloquer la boucle ?

Utilisez loop.run_in_executor avec un ProcessPoolExecutor pour les calculs, ou asyncio.to_thread pour les entrées-sorties bloquantes ; la boucle continue de traiter les autres tâches.

Quelle est la différence entre une coroutine et une tâche (Task) ?

La coroutine est un objet qui ne fait rien tant qu'on ne l'attend pas avec await. La tâche enveloppe une coroutine et la programme immédiatement dans la boucle d'événements, de sorte qu'elle s'exécute en parallèle du reste du code.

Apprenez ce sujet en vidéo

Ce sujet est traité en détail avec de la pratique dans le cours « Python 3.15: от новичка до профи ».

Voir le cours