Table of Contents
Multithreading et Multiprocessing en Python : Partage de Ressources et Performance Optimale
Introduction
Les developpeurs Python sont souvent confrontes a la necessite d’optimiser les performances de leurs applications. L’une des approches les plus efficaces consiste a utiliser les threads et les processus pour permettre a plusieurs taches de s’executer simultanement.
Dans le monde moderne du developpement, la programmation concurrente est devenue indispensable. Que vous construisiez une API web qui doit gerer des milliers de requetes, un script de scraping qui doit parcourir des centaines de pages, ou une application de traitement de donnees qui doit analyser des gigaoctets d’informations, la maitrise du multithreading et du multiprocessing est essentielle.
Dans cet article, nous explorerons les concepts cles du multithreading et du multiprocessing en Python, ainsi que les meilleures pratiques pour partager les ressources entre ces differentes unites de travail. Nous verrons egalement les pieges courants a eviter et les patterns recommandes par la communaute Python.
Le Global Interpreter Lock (GIL)
Avant de plonger dans le monde des threads, il est important de comprendre la notion de GIL. Le GIL est un mecanisme qui empeche plusieurs threads d’executer du code Python simultanement. Bien que cela puisse sembler contre-intuitif, le GIL a ete mis en place pour garantir l’integrite des operations de la memoire et des objets Python.
Le GIL est specifique a l’implementation CPython (l’implementation standard de Python). D’autres implementations comme Jython ou IronPython n’ont pas cette limitation.
import threading
import time
def fonction_lente():
time.sleep(2)
# Execution d'une seule thread
start = time.time()
fonction_lente()
print("Temps d'execution (1 thread) : %.2fs" % (time.time() - start))
# Execution de plusieurs threads
start = time.time()
threads = [threading.Thread(target=fonction_lente) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("Temps d'execution (4 threads) : %.2fs" % (time.time() - start))
Dans cet exemple, les 4 threads s’executent en parallele pour les operations I/O (comme time.sleep), donc le temps total est d’environ 2 secondes et non 8. C’est parce que le GIL est relache pendant les operations I/O.
Le Multithreading
Le multithreading permet aux programmes d’executer plusieurs taches en parallele. Les threads partagent le meme espace memoire, ce qui les rend ideaux pour les taches I/O-bound (lecture de fichiers, requetes reseau, etc.).
Exemple basique de creation de threads
import threading
import time
def fonction_thread(nom):
print(f"Thread {nom} demarre")
time.sleep(1)
print(f"Thread {nom} termine")
# Creation de threads
threads = [threading.Thread(target=fonction_thread, args=(i,)) for i in range(3)]
# Demarrage des threads
for t in threads:
t.start()
# Attente de la fin de tous les threads
for t in threads:
t.join()
print("Tous les threads sont termines")
Utilisation de ThreadPoolExecutor (recommande)
Pour une gestion plus elegante des threads, utilisez concurrent.futures :
from concurrent.futures import ThreadPoolExecutor
import requests
def telecharger_page(url):
response = requests.get(url)
return len(response.content)
urls = [
"https://python.org",
"https://github.com",
"https://stackoverflow.com"
]
# Execution parallele avec un pool de threads
with ThreadPoolExecutor(max_workers=3) as executor:
resultats = list(executor.map(telecharger_page, urls))
print(f"Tailles des pages : {resultats}")
Le Multiprocessing
Le multiprocessing permet aux programmes d’executer plusieurs processus en parallele. Contrairement aux threads, chaque processus a son propre espace memoire et son propre GIL. C’est la solution ideale pour les taches CPU-bound (calculs intensifs).
Exemple basique de creation de processus
import multiprocessing
import os
def fonction_process(nom):
print(f"Process {nom} - PID: {os.getpid()}")
# Simulation d'un calcul intensif
resultat = sum(i * i for i in range(10_000_000))
print(f"Process {nom} termine - Resultat: {resultat}")
if __name__ == "__main__":
# Creation de processus
processes = [multiprocessing.Process(target=fonction_process, args=(i,)) for i in range(4)]
# Demarrage des processus
for p in processes:
p.start()
# Attente de la fin de tous les processus
for p in processes:
p.join()
print("Tous les processus sont termines")
Utilisation de ProcessPoolExecutor (recommande)
from concurrent.futures import ProcessPoolExecutor
import math
def calcul_intensif(n):
return sum(math.sqrt(i) for i in range(n))
nombres = [10_000_000, 20_000_000, 15_000_000, 25_000_000]
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=4) as executor:
resultats = list(executor.map(calcul_intensif, nombres))
print(f"Resultats : {resultats}")
Partage de Ressources et Synchronisation
Partage entre Threads avec Lock
Lorsque plusieurs threads partagent des ressources, il est crucial de prendre en compte les problemes de synchronisation. Utilisez un Lock pour eviter les conditions de course :
import threading
compteur = 0
lock = threading.Lock()
def incrementer():
global compteur
for _ in range(100_000):
with lock: # Protection de la section critique
compteur += 1
threads = [threading.Thread(target=incrementer) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Compteur final : {compteur}") # Toujours 400000
Partage entre Processus avec Value et Array
Les processus ne partagent pas la memoire par defaut. Utilisez multiprocessing.Value ou multiprocessing.Array :
import multiprocessing
def incrementer(compteur, lock):
for _ in range(100_000):
with lock:
compteur.value += 1
if __name__ == "__main__":
compteur = multiprocessing.Value('i', 0) # 'i' = integer
lock = multiprocessing.Lock()
processes = [multiprocessing.Process(target=incrementer, args=(compteur, lock)) for _ in range(4)]
for p in processes:
p.start()
for p in processes:
p.join()
print(f"Compteur final : {compteur.value}")
Communication avec Queue
Pour une communication plus complexe entre threads ou processus, utilisez une Queue :
from multiprocessing import Process, Queue
def producteur(queue):
for i in range(5):
queue.put(f"Message {i}")
queue.put(None) # Signal de fin
def consommateur(queue):
while True:
message = queue.get()
if message is None:
break
print(f"Recu : {message}")
if __name__ == "__main__":
queue = Queue()
p1 = Process(target=producteur, args=(queue,))
p2 = Process(target=consommateur, args=(queue,))
p1.start()
p2.start()
p1.join()
p2.join()
Bonnes Pratiques
Choisir entre Threading et Multiprocessing
| Situation | Recommandation |
|---|---|
| Taches I/O-bound (reseau, fichiers) | Threading ou asyncio |
| Taches CPU-bound (calculs) | Multiprocessing |
| Besoin de partager beaucoup de donnees | Threading |
| Isolation complete necessaire | Multiprocessing |
Regles d’or
-
Utilisez les context managers : Preferez
with ThreadPoolExecutor() as executor:pour une gestion automatique des ressources. -
Limitez le nombre de workers : Un bon point de depart est
os.cpu_count()pour le multiprocessing etos.cpu_count() * 5pour le threading. -
Protegez les sections critiques : Toujours utiliser des locks pour les ressources partagees.
-
Evitez les variables globales : Passez les donnees explicitement aux fonctions.
# Bonne pratique : passage explicite
def traiter(donnees, resultat_queue):
resultat_queue.put(process(donnees))
# A eviter : variable globale
donnees_globales = []
def traiter():
global donnees_globales # Problematique !
Pieges Courants
1. Oublier if __name__ == "__main__"
Sur Windows et macOS, le multiprocessing necessite cette protection :
# INCORRECT - peut causer une boucle infinie
from multiprocessing import Process
def worker():
print("Working")
p = Process(target=worker)
p.start() # Erreur sur Windows !
# CORRECT
if __name__ == "__main__":
p = Process(target=worker)
p.start()
2. Conditions de course (Race Conditions)
# DANGEREUX - condition de course
compteur = 0
def incrementer():
global compteur
temp = compteur
temp += 1
compteur = temp # Autre thread peut avoir modifie compteur !
# SECURISE - avec lock
lock = threading.Lock()
def incrementer_safe():
global compteur
with lock:
compteur += 1
3. Deadlocks
Evitez d’acquerir plusieurs locks dans un ordre different :
# RISQUE DE DEADLOCK
def fonction_a():
with lock_1:
with lock_2: # Thread B peut avoir lock_2 et attendre lock_1
pass
def fonction_b():
with lock_2:
with lock_1: # Deadlock !
pass
# SOLUTION : toujours acquerir les locks dans le meme ordre
4. Ne pas joindre les threads/processus
Toujours appeler join() pour attendre la fin des workers :
threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads:
t.start()
# NE PAS OUBLIER !
for t in threads:
t.join() # Attendre que tous les threads terminent
Conclusion
Le multithreading et le multiprocessing sont des outils puissants pour optimiser les performances de vos applications Python. Voici les points cles a retenir :
- Threading : Ideal pour les taches I/O-bound grace au relachement du GIL pendant les operations I/O
- Multiprocessing : Necessaire pour les taches CPU-bound car chaque processus a son propre GIL
- Synchronisation : Indispensable pour eviter les conditions de course avec les Locks, Semaphores et Queues
- concurrent.futures : API moderne et elegante pour gerer les pools de threads et processus
La maitrise de ces concepts vous permettra de construire des applications performantes et robustes. N’hesitez pas a experimenter avec les differents patterns presentes dans cet article pour trouver la solution optimale pour votre cas d’usage.
Pour aller plus loin
- Explorez
asynciopour la programmation asynchrone - Decouvrez
multiprocessing.Managerpour le partage d’objets complexes - Etudiez les patterns Producer-Consumer et Worker Pool
- Testez vos implementations avec des outils de profiling comme
cProfile
In-Article Ad
Dev Mode
Tags
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Recherche de valeurs dans les listes, tuples et dictionnaires Python
Apprenez a rechercher des elements dans les sequences Python : methode index(), mot-cle in, recherche dans les dictionnaires et algorithme bisect pour listes triees.
Expressions Regulieres en Python : Guide Complet pour Maitriser le Module re
Apprenez a maitriser les expressions regulieres en Python avec le module re. Decouvrez comment extraire des donnees, valider des formats, manipuler des chaines et eviter les pieges courants avec des exemples pratiques.
Gestion des packages Python : creer et utiliser requirements.txt efficacement
Guide complet pour gerer vos dependances Python avec pip. Apprenez a creer un fichier requirements.txt, utiliser les environnements virtuels et maitriser la gestion des packages pour des projets Python professionnels et reproductibles.