L'un des mythes les plus tenaces de la communauté Python est que le langage est intrinsèquement lent. Bien qu'il soit vrai que Python possède un Verrou d'Interpréteur Global (GIL) qui empêche l'exécution véritablement parallèle des bytecodes dans CPython standard, il est loin d'être incapable de gérer efficacement des charges de travail à haut débit, liées à l'E/S ou au CPU. La clé réside dans la compréhension de la distinction entre concurrence et parallélisme, et dans le choix du bon outil pour la tâche.
Pour les développeurs intermédiaires à avancés, maîtriser ces concepts ne consiste pas seulement à écrire du code plus rapide ; il s'agit d'architecturer des systèmes capables de monter en charge. Cet article explore les nuances des modèles de concurrence de Python et fournit des stratégies pratiques pour tirer parti du traitement parallèle.
Concurrence vs Parallélisme : Définition des termes
Avant de plonger dans le code, nous devons clarifier une distinction fondamentale. La concurrence consiste à gérer beaucoup de choses à la fois. Il s'agit de structure. Dans un système concurrent, les tâches avancent pendant des périodes de temps qui se chevauchent, mais elles ne s'exécutent pas nécessairement simultanément. Imaginez un processeur à un seul cœur gérant plusieurs onglets de navigateur ; il bascule entre eux si rapidement que cela semble simultané.
Le parallélisme, en revanche, consiste à faire beaucoup de choses à la fois. Il s'agit d'exécution. Dans un système parallèle, les tâches s'exécutent littéralement en même temps sur plusieurs processeurs ou cœurs. Cela nécessite un support matériel (plusieurs cœurs) et un modèle de programmation capable de répartir le travail sur ces cœurs.
Threading : Le champion des tâches liées à l'E/S
Le module threading de Python est l'approche traditionnelle de la concurrence. Cependant, en raison du GIL, les threads ne conviennent pas aux tâches liées au CPU. Si vous créez cinq threads pour effectuer de lourds calculs mathématiques, ils s'exécuteront probablement de manière séquentielle, les uns après les autres, car un seul thread peut détenir le GIL à un instant donné.
Cependant, lorsqu'il s'agit de tâches liées à l'E/S (telles que les requêtes réseau, la lecture de fichiers ou les requêtes de base de données), les threads sont très efficaces. Lorsqu'un thread attend une opération d'E/S, il libère le GIL, permettant à d'autres threads de s'exécuter. Cela rend le threading idéal pour construire des serveurs réseau à haut débit ou des programmes de scraping.
Voici un exemple simple utilisant concurrent.futures pour télécharger plusieurs pages web de manière concurrente :
import concurrent.futures
import urllib.request
URLS = ['http://www.foxnews.com/', 'http://www.cnn.com/', 'http://espn.com/']
def load_url(url, timeout):
with urllib.request.urlopen(url, timeout=timeout) as conn:
return conn.read()
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
future_to_url = {executor.submit(load_url, url, 60): url for url in URLS}
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
data = future.result()
except Exception as exc:
print('%r generated an exception: %s' % (url, exc))
else:
print('%r page is %d bytes' % (url, len(data)))
Multiprocessing : Contourner le GIL pour les tâches CPU
Pour les tâches liées au CPU, telles que le traitement d'images, l'analyse de données ou les simulations complexes, le threading ne fournira pas de gains de performance. Pour obtenir un véritable parallélisme, vous devez utiliser plusieurs processus. Le module multiprocessing fournit une copie locale de l'interpréteur Python pour chaque processus, contournant ainsi complètement le GIL.
Bien que cela permette une exécution véritablement parallèle, cela a un coût : la communication inter-processus (IPC) est plus coûteuse que la synchronisation des threads, et la surcharge mémoire de la création de nouveaux processus est plus élevée. Néanmoins, pour des charges de travail de calcul intensif, c'est souvent la seule façon de saturer les CPU multi-cœurs modernes.
from multiprocessing import Pool
def square(n):
return n * n
if __name__ == '__main__':
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
with Pool(4) as p:
results = p.map(square, numbers)
print(results)
Asyncio : La norme moderne pour la concurrence E/S
Ces dernières années, asyncio est devenu la norme pour gérer la concurrence E/S à haut débit en Python. Contrairement aux threads, qui reposent sur des changements de contexte gérés par le système d'exploitation, asyncio utilise une boucle d'événements mono-thread pour gérer la multitâche coopérative. Cette approche est extrêmement légère et efficace, ce qui en fait la colonne vertébrale des frameworks web modernes comme FastAPI et Django Channels.
Asyncio vous permet d'écrire du code asynchrone qui ressemble à du code synchrone, améliorant la lisibilité tout en maintenant des performances élevées.
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in ['http://example.com', 'http://python.org']]
results = await asyncio.gather(*tasks)
print(results)
asyncio.run(main())
Conclusion
Choisir le bon modèle de concurrence en Python ne consiste pas à sélectionner le "meilleur", mais celui qui convient à votre goulot d'étranglement spécifique. Utilisez les threads pour les tâches E/S bloquantes simples. Utilisez le multiprocessing lorsque vous devez maximiser l'utilisation du CPU pour des calculs lourds. Et utilisez asyncio lors de la construction d'applications asynchrones évolutives et à haut débit.
En comprenant ces outils et leurs compromis, vous pouvez écrire des applications Python qui sont non seulement correctes, mais aussi performantes et évolutives. Ne laissez plus le GIL limiter votre potentiel ; commencez à architecturer pour le parallélisme dès aujourd'hui.