
Le module multiprocessing lance plusieurs processus Python indépendants pour exécuter du calcul en parallèle sur plusieurs cœurs, et c'est le seul moyen fiable de contourner le GIL sur une tâche CPU-bound. Là où les threads restent sérialisés par le verrou global de l'interpréteur, chaque processus possède son propre interpréteur et sa propre mémoire : le calcul avance réellement en simultané. Ce guide couvre Process, Pool, les canaux Queue et Pipe, l'état partagé via Manager, la mémoire partagée (Value, Array, shared_memory), le verrou Lock, la méthode de démarrage (spawn, fork) et le coût réel du pickle et du lancement de processus. Les exemples ont été exécutés avec Python 3.12.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Lancer un processus avec
Processet le synchroniser avecjoin. - Paralléliser un calcul CPU-bound avec
Poolet mesurer le gain de temps réel. - Échanger des données entre processus via
QueueetPipe. - Partager un état avec
Manager,Value,Arrayetshared_memory. - Protéger les accès concurrents avec
Locket comprendre les race conditions. - Choisir la méthode de démarrage et anticiper l'overhead du pickle.
Pourquoi des processus plutôt que des threads
Section intitulée « Pourquoi des processus plutôt que des threads »En CPython, un unique GIL (Global Interpreter Lock) autorise un seul thread à exécuter du bytecode Python à la fois. Sur une tâche CPU-bound (calcul pur : cryptographie, traitement d'image, agrégation numérique), plusieurs threads ne vont donc pas plus vite : ils se relaient sur un seul cœur. C'est la limite structurelle que multiprocessing lève, en créant des processus séparés au lieu de threads.
Chaque processus enfant démarre un interpréteur Python complet, avec sa propre mémoire et son propre GIL. Le système d'exploitation peut alors les répartir sur plusieurs cœurs physiques et le calcul progresse vraiment en parallèle. Le prix à payer : les processus ne partagent pas leur mémoire par défaut, et communiquer entre eux impose de sérialiser les objets (le fameux pickle).
Le bon réflexe est donc de trancher selon la nature du travail. Pour de l'attente réseau ou disque (I/O-bound), les threads ou asyncio suffisent et coûtent moins cher. Pour du calcul intensif, multiprocessing est le bon outil. Ne parallélisez jamais par réflexe : sur une tâche courte, l'overhead de création des processus dépasse le gain, comme le montre la dernière section.
Lancer un processus avec Process
Section intitulée « Lancer un processus avec Process »La brique de base est la classe Process. On lui passe une fonction cible (target) et ses arguments (args), puis on appelle start() pour démarrer l'exécution et join() pour attendre sa fin. Chaque processus reçoit un PID distinct, preuve qu'il s'agit bien d'un programme séparé et pas d'un thread.
import osfrom multiprocessing import Process, current_process
def travail(nom: str) -> None: print(f"[{current_process().name}] traite {nom} (PID {os.getpid()})")
if __name__ == "__main__": p = Process(target=travail, args=("rapport.pdf",), name="worker-1") p.start() p.join() print(f"Processus terminé, exitcode={p.exitcode}")La sortie confirme l'exécution dans un processus dédié, avec un code de retour nul :
[worker-1] traite rapport.pdf (PID 1288258)Processus terminé, exitcode=0Paralléliser un calcul avec Pool
Section intitulée « Paralléliser un calcul avec Pool »Gérer les processus à la main devient vite fastidieux. La classe Pool maintient un groupe de workers réutilisables et distribue automatiquement le travail. Sa méthode map applique une fonction à chaque élément d'une liste, exactement comme le map natif, mais réparti sur les processus. C'est l'interface à privilégier pour paralléliser une boucle de calcul.
Mesurons le gain sur une vraie tâche CPU-bound : compter les nombres premiers sur plusieurs intervalles. On compare une exécution séquentielle à un Pool de quatre workers, chronométrés avec time.perf_counter.
import timefrom multiprocessing import Pool
def compte_premiers(limite: int) -> int: total = 0 for n in range(2, limite): est_premier = True for d in range(2, int(n ** 0.5) + 1): if n % d == 0: est_premier = False break if est_premier: total += 1 return total
if __name__ == "__main__": lots = [200_000] * 8
debut = time.perf_counter() resultats_seq = [compte_premiers(n) for n in lots] duree_seq = time.perf_counter() - debut print(f"Séquentiel : {sum(resultats_seq)} premiers en {duree_seq:.2f} s")
debut = time.perf_counter() with Pool(processes=4) as pool: resultats_par = pool.map(compte_premiers, lots) duree_par = time.perf_counter() - debut print(f"Pool(4) : {sum(resultats_par)} premiers en {duree_par:.2f} s")
print(f"Accélération : x{duree_seq / duree_par:.1f}")Le résultat est sans appel : le même calcul, réparti sur quatre processus, s'exécute presque trois fois plus vite.
Séquentiel : 143872 premiers en 1.14 sPool(4) : 143872 premiers en 0.43 sAccélération : x2.7L'accélération reste inférieure au nombre de workers (x2.7 pour quatre processus, pas x4). C'est normal : la répartition du travail, le démarrage des processus et le rapatriement des résultats consomment du temps. Sur du calcul plus long et mieux équilibré, le rendement se rapproche du nombre de cœurs, sans jamais l'atteindre parfaitement.
Le Pool offre plusieurs façons de soumettre le travail, à choisir selon le besoin. map attend tous les résultats et préserve l'ordre ; apply exécute un seul appel et bloque jusqu'au retour ; imap renvoie un itérateur paresseux qui livre les résultats au fil de l'eau, utile pour commencer à traiter sans attendre la fin.
from multiprocessing import Pool
def carre(x: int) -> int: return x * x
if __name__ == "__main__": with Pool(processes=3) as pool: print("map :", pool.map(carre, [1, 2, 3, 4, 5])) print("apply:", pool.apply(carre, (9,))) print("imap :", list(pool.imap(carre, range(6))))map : [1, 4, 9, 16, 25]apply: 81imap : [0, 1, 4, 9, 16, 25]Échanger des données avec Queue et Pipe
Section intitulée « Échanger des données avec Queue et Pipe »Les processus ne partagent pas leur mémoire, il faut donc un canal explicite pour se transmettre des messages. La Queue est une file thread-safe et process-safe : un ou plusieurs producteurs y déposent des objets avec put, un ou plusieurs consommateurs les récupèrent avec get. C'est l'outil idéal d'un motif producteur-consommateur, où l'on signale la fin avec une valeur sentinelle comme None.
from multiprocessing import Process, Queue
def producteur(file: Queue) -> None: for i in range(3): file.put(f"tache-{i}") file.put(None)
if __name__ == "__main__": file = Queue() prod = Process(target=producteur, args=(file,)) prod.start() recus = [] while True: msg = file.get() if msg is None: break recus.append(msg) prod.join() print("reçus:", recus)reçus: ['tache-0', 'tache-1', 'tache-2']Pour une communication point à point entre exactement deux processus, le Pipe est plus léger. Il renvoie deux extrémités de connexion : ce qu'une extrémité envoie avec send, l'autre le reçoit avec recv. Les objets transmis sont sérialisés puis reconstruits de l'autre côté, on peut donc faire circuler un dictionnaire entier.
from multiprocessing import Process, Pipe
def envoyeur(conn) -> None: conn.send({"statut": "ok", "valeur": 42}) conn.close()
if __name__ == "__main__": parent_conn, enfant_conn = Pipe() e = Process(target=envoyeur, args=(enfant_conn,)) e.start() print("reçu via pipe:", parent_conn.recv()) e.join()reçu via pipe: {'statut': 'ok', 'valeur': 42}Partager un état avec Manager
Section intitulée « Partager un état avec Manager »Quand plusieurs processus doivent lire et écrire une structure commune (liste, dictionnaire), le Manager fournit des objets proxys. Un serveur intermédiaire héberge la vraie donnée, et chaque processus manipule un mandataire qui relaie les opérations. C'est l'approche la plus souple pour un état partagé riche, au prix d'une sérialisation à chaque accès.
from multiprocessing import Process, Manager
def ajoute(liste_partagee, valeur: int) -> None: liste_partagee.append(valeur)
if __name__ == "__main__": with Manager() as mgr: partagee = mgr.list() procs = [Process(target=ajoute, args=(partagee, i)) for i in range(5)] for pr in procs: pr.start() for pr in procs: pr.join() print("liste partagée:", sorted(partagee))liste partagée: [0, 1, 2, 3, 4]Le Manager privilégie la commodité sur la performance : chaque append traverse le serveur de gestion. Pour des millions d'opérations, il devient un goulot d'étranglement. On lui préfère alors la mémoire partagée brute, décrite juste après.
Partager de la mémoire brute avec Value, Array et shared_memory
Section intitulée « Partager de la mémoire brute avec Value, Array et shared_memory »Pour des données numériques simples, Value et Array allouent de la mémoire directement partagée entre processus, sans passer par un serveur. Value stocke un scalaire, Array un tableau, tous deux typés par un code de format ("i" pour un entier, "d" pour un double). L'accès est nettement plus rapide qu'un proxy de Manager.
from multiprocessing import Array
if __name__ == "__main__": tableau = Array("d", [1.0, 2.0, 3.0]) tableau[0] = 99.0 print("array:", list(tableau))array: [99.0, 2.0, 3.0]Pour partager de gros blocs binaires (images, buffers) sans les copier, le sous-module shared_memory (Python 3.8 et plus) réserve un segment nommé de mémoire. Un processus le crée, les autres l'ouvrent par son nom et travaillent sur le même buffer. Il faut penser à le libérer explicitement avec close puis unlink, sinon le segment fuit dans le système.
from multiprocessing import Processfrom multiprocessing import shared_memory
def ecrit_bloc(nom: str) -> None: bloc = shared_memory.SharedMemory(name=nom) bloc.buf[0:5] = b"HELLO" bloc.close()
if __name__ == "__main__": bloc = shared_memory.SharedMemory(create=True, size=16) try: p = Process(target=ecrit_bloc, args=(bloc.name,)) p.start() p.join() print("lu depuis le bloc:", bytes(bloc.buf[0:5])) finally: bloc.close() bloc.unlink()lu depuis le bloc: b'HELLO'Protéger les accès concurrents avec Lock
Section intitulée « Protéger les accès concurrents avec Lock »Dès que plusieurs processus modifient la même valeur partagée, une race condition guette. L'opération compteur.value += 1 n'est pas atomique : elle lit, incrémente, puis réécrit. Si deux processus s'intercalent entre la lecture et l'écriture, une incrémentation est perdue. Voici la démonstration : quatre processus incrémentent chacun 100 000 fois, on attend 400 000.
from multiprocessing import Process, Value
def incremente_sans_verrou(compteur: Value) -> None: for _ in range(100_000): compteur.value += 1
if __name__ == "__main__": compteur = Value("i", 0) procs = [Process(target=incremente_sans_verrou, args=(compteur,)) for _ in range(4)] for pr in procs: pr.start() for pr in procs: pr.join() print(f"compteur final (attendu 400000, obtenu {compteur.value})")Le résultat est faux et varie à chaque exécution, car des incréments se marchent dessus :
compteur final (attendu 400000, obtenu 141847)La parade est le Lock, un verrou d'exclusion mutuelle. Un seul processus peut le détenir à la fois : les autres attendent leur tour. En protégeant la section critique avec with verrou:, on sérialise les accès à la valeur partagée et le compte redevient exact.
from multiprocessing import Process, Value, Lock
def incremente(compteur: Value, verrou: Lock) -> None: for _ in range(100_000): with verrou: compteur.value += 1
if __name__ == "__main__": compteur = Value("i", 0) verrou = Lock() procs = [Process(target=incremente, args=(compteur, verrou)) for _ in range(4)] for pr in procs: pr.start() for pr in procs: pr.join() print("compteur final (attendu 400000):", compteur.value)compteur final (attendu 400000): 400000Le verrou a un coût : il annule une partie du parallélisme puisqu'il force l'attente. La bonne pratique est donc de verrouiller le moins possible, sur la plus petite section critique, et de préférer quand on le peut des workers indépendants qui ne partagent aucun état mutable.
Comprendre la méthode de démarrage : spawn, fork, forkserver
Section intitulée « Comprendre la méthode de démarrage : spawn, fork, forkserver »La façon dont un processus enfant est créé influence le comportement du programme. Trois méthodes de démarrage existent. Avec fork (historiquement le défaut sous Linux), l'enfant est une copie du parent et hérite de son état mémoire ; c'est rapide mais fragile avec des threads ou certaines bibliothèques. Avec spawn (défaut sur macOS et Windows), un interpréteur neuf démarre et réimporte le module ; plus lent, mais bien plus robuste.
import multiprocessing as mp
if __name__ == "__main__": print("méthode par défaut :", mp.get_start_method()) print("méthodes disponibles :", mp.get_all_start_methods())méthode par défaut : forkméthodes disponibles : ['fork', 'spawn', 'forkserver']On peut forcer la méthode avec mp.set_start_method("spawn") au démarrage du programme. C'est même recommandé pour la portabilité : un code écrit pour spawn tourne partout, alors qu'un code qui suppose fork casse sous Windows. Le troisième mode, forkserver, démarre un processus serveur minimal qui fork à la demande, combinant robustesse et rapidité.
Le coût réel : pickle et démarrage de processus
Section intitulée « Le coût réel : pickle et démarrage de processus »Le parallélisme n'est jamais gratuit. Créer un processus, sérialiser les arguments et les résultats avec pickle, puis rapatrier les données représente un overhead fixe. Sur une tâche trop courte, ce coût dépasse le gain de calcul. Démontrons-le en doublant un million d'entiers, une opération triviale où chaque élément coûte quasiment rien à calculer mais tout à transférer.
import timefrom multiprocessing import Pool
def double(x: int) -> int: return x * 2
if __name__ == "__main__": donnees = list(range(1_000_000))
debut = time.perf_counter() seq = [double(x) for x in donnees] duree_seq = time.perf_counter() - debut print(f"Séquentiel : {duree_seq:.3f} s")
debut = time.perf_counter() with Pool(processes=4) as pool: par = pool.map(double, donnees) duree_par = time.perf_counter() - debut print(f"Pool(4) : {duree_par:.3f} s") print(f"Ratio : le Pool est x{duree_par / duree_seq:.1f} PLUS LENT")Séquentiel : 0.058 sPool(4) : 0.103 sRatio : le Pool est x1.8 PLUS LENTLe verdict est clair : ici, multiprocessing ralentit le programme. Chaque entier doit être sérialisé, transmis à un worker, puis le résultat renvoyé, pour un calcul qui prend une nanoseconde. La leçon est structurante : ne parallélisez que des tâches dont le temps de calcul par élément est nettement supérieur au coût de transfert. En pratique, regroupez le travail en gros lots (comme les intervalles du benchmark plus haut) plutôt que de soumettre des millions de petits appels.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »multiprocessingcontourne le GIL en lançant des interpréteurs séparés : c'est le bon outil pour le calcul CPU-bound, pas pour l'attente réseau.Processgère un processus à la main (start,join),Pooldistribue automatiquement le travail avecmap,applyetimap.- Le gain est réel mais sous-linéaire : x2.7 mesuré sur quatre workers, jamais x4 à cause de la répartition.
- Les processus ne partagent pas leur mémoire : on communique avec
Queue,Pipe,Manager,Value,Arrayoushared_memory. - Toute écriture concurrente sur une valeur partagée exige un
Lock, sinon une race condition fausse le résultat. - La méthode de démarrage compte : spawn est portable, fork disparaît comme défaut dès Python 3.14. Le garde-fou
if __name__ == "__main__":est obligatoire. - L'overhead du pickle et du lancement peut rendre le parallélisme plus lent : ne parallélisez que des tâches longues, en gros lots.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- La programmation asynchrone avec asyncio : l'approche complémentaire pour les tâches I/O-bound, là où multiprocessing serait du gaspillage.
- itertools et functools : préparer et découper en lots les données à envoyer aux workers.
- Journaliser avec logging : suivre l'exécution de plusieurs processus sans mélanger les sorties.
- Comprendre le GIL : la raison profonde qui pousse à créer des processus plutôt que des threads pour un calcul CPU bound.
- Cython : l'autre voie pour exploiter plusieurs cœurs, en relâchant le GIL au sein d'une extension native.