Python Async vs Threads : Quand utiliser asyncio
Pourquoi la concurrence est importante en Python
Vous avez un script Python qui récupère des données depuis plusieurs API. Il fonctionne, mais il est lent : chaque requête attend que la précédente se termine. Vous avez entendu parler d'asyncio et des threads, mais lequel utiliser ? Choisir le mauvais modèle peut conduire à un code soit inutilement complexe, soit douloureusement lent.
Ce guide clarifie les choses. Nous comparons les modèles asynchrone et de threading de Python, montrons exactement quand utiliser chacun, et fournissons des exemples pratiques que vous pouvez adapter à vos propres projets.
Comprendre le paysage de la concurrence en Python
Python propose trois principaux modèles de concurrence :
- Threads – Plusieurs threads d'exécution dans un seul processus, gérés par le système d'exploitation. Limités par le Global Interpreter Lock (GIL) pour les tâches CPU-bound.
- Async (asyncio) – Boucle d'événements mono-thread qui bascule entre les tâches aux points
await. Excellent pour les opérations I/O-bound. - Multiprocessing – Processus séparés, chacun avec son propre interpréteur Python et espace mémoire. Contourne le GIL pour un vrai parallélisme.
Le GIL est un mutex qui empêche plusieurs threads d'exécuter du bytecode Python simultanément. Cela signifie que les threads n'accélèrent pas les tâches CPU-bound, mais ils aident pour les tâches I/O-bound car le GIL est libéré pendant les opérations d'E/S.
Quand utiliser asyncio
asyncio brille lorsque votre programme passe la plupart de son temps à attendre des événements externes : requêtes réseau, E/S de fichiers, requêtes de base de données ou minuteries. Comme il utilise un seul thread, vous évitez la surcharge de création de threads et de changements de contexte. La boucle d'événements gère efficacement des milliers de connexions simultanées.
Utilisez asyncio quand :
- Vous construisez un serveur ou client web qui gère de nombreuses connexions simultanées (par ex., FastAPI, aiohttp).
- Vos tâches sont I/O-bound et vous avez besoin d'une concurrence élevée (centaines ou milliers de tâches).
- Vous voulez un contrôle fin sur l'ordonnancement et l'annulation des tâches.
- Vous utilisez déjà un framework ou une bibliothèque asynchrone.
Voici un exemple simple qui récupère plusieurs URL en parallèle :
import asyncio
import aiohttp
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f'Fetched {len(results)} pages')
asyncio.run(main())
Quand utiliser les threads
Les threads sont adaptés lorsque vous avez des opérations d'E/S bloquantes qui n'ont pas d'équivalents asynchrones, ou lorsque vous travaillez avec du code legacy qui n'est pas compatible avec l'async. Ils sont aussi plus simples à appréhender pour un petit nombre de tâches.
Utilisez les threads quand :
- Vous devez exécuter des E/S bloquantes en parallèle, comme lire plusieurs fichiers ou effectuer des appels API synchrones.
- Vous intégrez une bibliothèque qui ne supporte pas l'async.
- Vous avez un nombre modéré de tâches concurrentes (dizaines, pas milliers).
- Vous voulez garder votre code synchrone tout en chevauchant les E/S.
Exemple avec concurrent.futures.ThreadPoolExecutor :
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url):
response = requests.get(url)
return response.text
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch, urls))
print(f'Fetched {len(results)} pages')
Comparaison entre asyncio et les threads
Voici une comparaison côte à côte pour vous aider à décider :
| Aspect | asyncio | Threads |
|---|---|---|
| Modèle de concurrence | Boucle d'événements mono-thread | Threads OS multiples |
| Idéal pour | I/O-bound, concurrence élevée | I/O-bound, appels bloquants |
| Performance CPU-bound | Faible (GIL) | Faible (GIL) |
| Scalabilité | Milliers de tâches | Centaines de threads |
| Complexité | Nécessite la syntaxe async/await | Style synchrone familier |
| Débogage | Peut être délicat avec les tracebacks | Outils de débogage standards |
| Écosystème | Support async croissant | Universel |
Et les tâches CPU-bound ?
Ni asyncio ni les threads ne vous aideront à paralléliser un travail intensif en CPU en Python à cause du GIL. Pour les tâches CPU-bound—comme le calcul numérique, le traitement d'images ou la compression de données—utilisez multiprocessing ou concurrent.futures.ProcessPoolExecutor.
Exemple :
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i * i for i in range(n))
if __name__ == '__main__':
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_heavy, [10**6, 10**6, 10**6]))
print(results)
Bonnes pratiques et pièges
- Ne mélangez pas les appels bloquants avec asyncio. Si vous appelez une fonction bloquante dans une tâche async, elle bloquera toute la boucle d'événements. Utilisez
asyncio.to_thread()pour exécuter du code bloquant dans un pool de threads. - Limitez la taille du pool de threads. Créer trop de threads entraîne une surcharge de changements de contexte. Utilisez
ThreadPoolExecutor(max_workers=N). - Gérez correctement les exceptions. Dans asyncio, les exceptions non gérées dans les tâches peuvent passer inaperçues. Utilisez
asyncio.gather(..., return_exceptions=True)ou ajoutez des callbacks. - Préférez asyncio pour les nouveaux projets à forte intensité d'E/S. L'écosystème est mature, et des frameworks comme FastAPI et aiohttp le rendent facile.
- Utilisez les threads pour les scripts legacy ou simples. Si vous avez juste besoin de paralléliser quelques appels bloquants, les threads sont moins invasifs.
FAQ
Puis-je utiliser asyncio et les threads ensemble ?
Oui. Vous pouvez exécuter du code bloquant dans un pool de threads en utilisant asyncio.to_thread() ou loop.run_in_executor(). C'est utile lorsque vous devez appeler une bibliothèque synchrone depuis du code async sans bloquer la boucle d'événements.
asyncio fonctionne-t-il avec le GIL ?
Oui, asyncio s'exécute dans un seul thread et est soumis au GIL. Cependant, comme il est conçu pour les tâches I/O-bound, le GIL est libéré pendant les opérations d'E/S, permettant à d'autres tâches de s'exécuter. Pour le travail CPU-bound, asyncio n'offre aucune accélération.
Qu'est-ce qui est plus rapide : asyncio ou les threads ?
Pour les tâches I/O-bound avec de nombreuses opérations concurrentes, asyncio est généralement plus rapide et plus scalable car il évite la surcharge des threads. Pour un petit nombre de tâches bloquantes, les threads peuvent être plus simples et avoir des performances similaires. Aucun des deux n'aide pour les tâches CPU-bound.
Faire le bon choix
Commencez par identifier si votre goulot d'étranglement est l'E/S ou le CPU. Si c'est l'E/S et que vous avez besoin d'une concurrence élevée, optez pour asyncio. Si c'est l'E/S mais que vous avez affaire à des bibliothèques bloquantes ou des scripts plus simples, les threads sont un choix solide. Pour le travail CPU-bound, utilisez multiprocessing.
Rappelez-vous que vous pouvez souvent combiner ces modèles—par exemple, utiliser asyncio pour les opérations réseau et un pool de threads pour les E/S de fichiers. La clé est de comprendre les compromis et de choisir l'outil adapté à votre problème spécifique.
Lorsque vous devez rapidement formater ou valider des données JSON renvoyées par vos appels API concurrents, essayez notre JSON Formatter pour un pretty-print et un débogage en toute simplicité.