Aller au contenu
Développement medium

Python multiprocessing : le vrai parallélisme des processus

20 min de lecture

logo python

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.

  • Lancer un processus avec Process et le synchroniser avec join.
  • Paralléliser un calcul CPU-bound avec Pool et mesurer le gain de temps réel.
  • Échanger des données entre processus via Queue et Pipe.
  • Partager un état avec Manager, Value, Array et shared_memory.
  • Protéger les accès concurrents avec Lock et comprendre les race conditions.
  • Choisir la méthode de démarrage et anticiper l'overhead du pickle.

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.

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 os
from 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=0

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 time
from 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 s
Pool(4) : 143872 premiers en 0.43 s
Accélération : x2.7

L'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: 81
imap : [0, 1, 4, 9, 16, 25]

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}

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 Process
from 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'

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): 400000

Le 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 : fork
mé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 time
from 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 s
Pool(4) : 0.103 s
Ratio : le Pool est x1.8 PLUS LENT

Le 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.

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

6 questions
6 min.
70% requis

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

  • multiprocessing contourne 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.
  • Process gère un processus à la main (start, join), Pool distribue automatiquement le travail avec map, apply et imap.
  • 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, Array ou shared_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.
  • 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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn