15%

Économisez 15% sur tous les services d'hébergement

Testez vos compétences et obtenez Réduction sur tout plan d'hébergement

Utilisez le code :

Skills
Commencer
09.10.2024

Python Multiprocessing : Un Guide Technique Complet sur l’Exécution Parallèle

Le module multiprocessing de Python permet une exécution parallèle réelle en créant des processus indépendants au niveau du système d’exploitation, chacun avec son propre espace mémoire et son propre interpréteur Python — contournant complètement le Global Interpreter Lock (GIL). Contrairement aux threads, qui partagent un état d’interpréteur unique et sont sérialisés par le GIL, les processus séparés s’exécutent simultanément sur tous les cœurs CPU disponibles, faisant du multiprocessing l’outil approprié pour les charges de travail liées au CPU telles que le calcul numérique, le traitement d’images et l’inférence en apprentissage automatique.

Ce guide couvre tout, de l’architecture fondamentale du modèle de processus Python aux modèles avancés incluant la mémoire partagée, les pools de processus, la communication inter-processus et les pièges de niveau production que la plupart des tutoriels omettent entièrement.

Pourquoi le GIL rend le multithreading insuffisant pour les tâches liées au CPU

Le Global Interpreter Lock est un mutex qui protège les compteurs de références d’objets internes de CPython. Un seul thread peut détenir le GIL et exécuter du bytecode Python à un moment donné. Pour les tâches liées aux E/S — requêtes réseau, requêtes de base de données, lectures de fichiers — les threads restent utiles car le GIL est libéré lors des appels système d’E/S bloquants. Cependant, pour le calcul pur, les threads se disputent continuellement le GIL, ne produisant aucun parallélisme réel même sur une machine à 64 cœurs.

Le multiprocessing contourne entièrement ce problème. Chaque processus créé est un processus OS complet et indépendant avec son propre interpréteur CPython, son tas et son GIL. Le planificateur du système d’exploitation distribue ces processus sur les cœurs physiques, offrant un véritable parallélisme.

Impact du GIL : un exemple concret

Considérons une fonction qui effectue 10 millions d’additions d’entiers. L’exécuter dans deux threads sur une machine à deux cœurs prendra à peu près le même temps réel que de l’exécuter dans un seul thread — parfois plus longtemps en raison de la surcharge de contention du GIL. L’exécuter dans deux processus séparés divisera le temps réel par deux.

Multiprocessing vs. Multithreading vs. Asyncio

Comprendre quand utiliser chaque modèle de concurrence est aussi important que de savoir comment les utiliser.

Fonctionnalité`multiprocessing``threading``asyncio`
Type de parallélismeRéel (processus OS)Pseudo (limité par le GIL)Coopératif (mono-thread)
Contournement du GILOuiNonNon
Modèle mémoireSéparé par processusPartagéPartagé
Meilleur cas d’utilisationTâches liées au CPUE/S + bibliothèques héritéesE/S, haute concurrence
Surcharge de communicationÉlevée (IPC requis)Faible (mémoire partagée)Faible (coroutines)
Isolation des pannesForte (isolation des crashs)Faible (un crash de thread peut tout tuer)Faible
Surcharge au démarrageÉlevéeFaibleTrès faible
Utilisation mémoire typiqueÉlevéeFaibleTrès faible

Règle générale : Utilisez `multiprocessing` pour les tâches liées au CPU, `threading` ou `asyncio` pour les tâches liées aux E/S. Si vous avez besoin des deux, `concurrent.futures` fournit une interface unifiée sur les deux modèles.

Architecture de base : comment Python crée des processus

Python prend en charge trois méthodes de démarrage pour créer des processus enfants, et le choix a des conséquences importantes :

  • `fork` (par défaut sur Linux/macOS) : Copie la mémoire du processus parent en utilisant le copy-on-write. Rapide, mais peut causer des problèmes avec des processus parents multi-threadés ou des extensions C qui détiennent des verrous.
  • `spawn` (par défaut sur Windows, disponible sur toutes les plateformes) : Démarre un nouvel interpréteur Python et importe le module. Plus lent mais plus sûr. Nécessite que tout le code soit importable, c’est pourquoi la protection `if __name__ == "__main__":` est obligatoire.
  • `forkserver` : Un processus serveur dédié effectue des forks à la demande. Évite les problèmes de sécurité des forks tout en étant plus efficace que le spawn pur pour de nombreux processus de courte durée.

Définissez la méthode de démarrage explicitement en haut de votre point d’entrée :

“`python

import multiprocessing

if __name__ == "__main__":

multiprocessing.set_start_method("spawn")

“`

Ne pas comprendre les méthodes de démarrage est l’une des sources les plus courantes de bugs subtils et spécifiques à la plateforme dans le code de multiprocessing en production.

Importation du module

“`python

import multiprocessing

from multiprocessing import Process, Pool, Queue, Lock, Pipe, Value, Array

“`

Primitives clés et leurs rôles

PrimitiveObjectif
`Process`Crée un processus indépendant unique
`Pool`Gère un pool de workers réutilisables
`Queue`FIFO thread- et processus-safe pour l’IPC
`Pipe`Connexion rapide à deux points entre deux processus
`Lock` / `RLock`Exclusion mutuelle pour les ressources partagées
`Value` / `Array`Mémoire partagée pour les types simples
`Manager`Objets proxy pour l’état partagé complexe
`Event` / `Semaphore`Primitives de synchronisation

Exemple 1 : Création d’un processus unique

La classe `Process` est le bloc de construction fondamental. Elle correspond directement à un processus OS.

“`python

from multiprocessing import Process

def compute_square(n):

result = n ** 2

print(f"Square of {n} is {result}")

if __name__ == "__main__":

process = Process(target=compute_square, args=(7,))

process.start()

process.join()

print(f"Process exit code: {process.exitcode}")

“`

Attributs et méthodes clés :

  • `target` : Le callable à exécuter dans le processus enfant.
  • `args` / `kwargs` : Arguments passés à la fonction cible.
  • `start()` : Fork ou crée le processus enfant.
  • `join(timeout=None)` : Bloque l’appelant jusqu’à la fin du processus. Appelez toujours `join()` pour éviter les processus zombies.
  • `exitcode` : `0` en cas de sortie normale, valeur négative si tué par un signal, valeur positive si le processus a levé une exception non gérée.
  • `is_alive()` : Retourne `True` si le processus est toujours en cours d’exécution.
  • `terminate()` / `kill()` : Envoie `SIGTERM` / `SIGKILL` respectivement. À utiliser avec précaution — les ressources peuvent ne pas être nettoyées.

Piège critique : Si vous créez un processus sans appeler `join()`, l’enfant devient un processus zombie sur les systèmes Unix, consommant une entrée de la table des processus jusqu’à la sortie du parent.

Exemple 2 : Pools de processus avec `multiprocessing.Pool`

Pour les charges de travail qui appliquent la même fonction à de nombreux éléments de données, `Pool` est bien plus efficace que la gestion manuelle d’instances `Process` individuelles. Il maintient un nombre fixe de processus workers et distribue le travail entre eux.

“`python

from multiprocessing import Pool

import os

def process_chunk(data_chunk):

worker_pid = os.getpid()

result = sum(x ** 2 for x in data_chunk)

return result, worker_pid

if __name__ == "__main__":

dataset = [range(i, i + 1000) for i in range(0, 10000, 1000)]

with Pool(processes=4) as pool:

results = pool.map(process_chunk, dataset)

for result, pid in results:

print(f"Worker PID {pid} computed sum: {result}")

“`

Comparaison des méthodes du Pool

MéthodeBloquanteRetourneIdéale pour
`pool.map(f, iterable)`OuiListe de résultatsMap parallèle simple
`pool.imap(f, iterable)`ParesseuxItérateurGrands itérables, efficacité mémoire
`pool.imap_unordered(f, iterable)`ParesseuxItérateur (non ordonné)Quand l’ordre n’a pas d’importance
`pool.starmap(f, iterable)`OuiListe de résultatsFonctions avec plusieurs arguments
`pool.apply_async(f, args)`Non`AsyncResult`Fire-and-forget ou callbacks
`pool.map_async(f, iterable)`Non`AsyncResult`Soumission par lots non bloquante

Piège — sélection de la taille du pool : Définir `processes` au-delà de `os.cpu_count()` améliore rarement le débit pour les tâches liées au CPU et augmente la surcharge de changement de contexte. Une heuristique courante est `processes = os.cpu_count() – 1` pour laisser un cœur au système d’exploitation et au processus principal.

Piège — sérialisation : Tous les arguments et valeurs de retour échangés entre le processus principal et les workers sont sérialisés avec `pickle`. Les objets qui ne peuvent pas être picklés (fonctions lambda, fonctions imbriquées définies à l’intérieur d’autres fonctions, descripteurs de fichiers, connexions de base de données) lèveront une `PicklingError`. Utilisez `pool.starmap` avec des fonctions au niveau du module, ou restructurez votre code pour éviter de passer des objets non picklables.

Exemple 3 : Communication inter-processus avec Queue

`multiprocessing.Queue` est un FIFO sécurisé pour les processus, construit sur un pipe et un verrou. C’est le mécanisme standard pour le modèle producteur-consommateur.

“`python

from multiprocessing import Process, Queue

import time

def producer(queue, items):

for item in items:

queue.put(item)

print(f"[Producer] Enqueued: {item}")

time.sleep(0.01)

queue.put(None) # Sentinel value to signal completion

def consumer(queue):

while True:

item = queue.get()

if item is None:

print("[Consumer] Received sentinel, shutting down.")

break

print(f"[Consumer] Processing: {item}")

if __name__ == "__main__":

q = Queue(maxsize=10) # Bounded queue prevents unbounded memory growth

data = list(range(20))

p = Process(target=producer, args=(q, data))

c = Process(target=consumer, args=(q,))

p.start()

c.start()

p.join()

c.join()

“`

Note de conception critique : N’utilisez jamais `queue.empty()` pour déterminer quand arrêter la consommation. La vérification `empty()` n’est pas fiable dans un contexte de multiprocessing — une condition de course existe entre la vérification et le `get()` suivant. Utilisez toujours une valeur sentinelle (telle que `None` ou un objet `STOP` dédié) pour signaler que la production est terminée.

Exemple 4 : Mémoire partagée avec Value et Array

Lorsque les processus ont besoin de partager un état numérique simple sans la surcharge d’un `Queue`, `multiprocessing.Value` et `multiprocessing.Array` fournissent une mémoire partagée directe basée sur `ctypes`.

“`python

from multiprocessing import Process, Value, Array, Lock

import ctypes

def increment_counter(counter, lock, iterations):

for _ in range(iterations):

with lock:

counter.value += 1

if __name__ == "__main__":

counter = Value(ctypes.c_int, 0)

lock = Lock()

processes = [

Process(target=increment_counter, args=(counter, lock, 1000))

for _ in range(4)

]

for p in processes:

p.start()

for p in processes:

p.join()

print(f"Final counter value: {counter.value}") # Expected: 4000

“`

Sans le verrou, la valeur finale serait de manière imprévisible inférieure à 4000 en raison de conditions de course sur le cycle lecture-modification-écriture. Protégez toujours l’état mutable partagé avec un `Lock`.

Pour les structures de données partagées complexes (listes, dicts, objets personnalisés), utilisez `multiprocessing.Manager`, qui crée un processus serveur gérant les objets et fournissant un accès par proxy. Le compromis est une latence plus élevée par accès par rapport à la mémoire partagée brute.

Exemple 5 : Pipe pour la communication directe entre deux processus

`multiprocessing.Pipe` crée une paire d’objets de connexion. Il est plus rapide que `Queue` pour la communication point à point entre exactement deux processus car il a moins de surcharge.

“`python

from multiprocessing import Process, Pipe

def worker(conn):

data = conn.recv()

result = [x ** 3 for x in data]

conn.send(result)

conn.close()

if __name__ == "__main__":

parent_conn, child_conn = Pipe()

p = Process(target=worker, args=(child_conn,))

p.start()

parent_conn.send([1, 2, 3, 4, 5])

result = parent_conn.recv()

p.join()

print(f"Cubed values: {result}")

“`

Utilisez `Queue` lorsque plusieurs producteurs ou consommateurs sont impliqués. Utilisez `Pipe` lorsque exactement deux processus échangent des données directement.

Exemple 6 : Utilisation de `concurrent.futures.ProcessPoolExecutor`

Pour le code Python moderne (3.2+), `concurrent.futures.ProcessPoolExecutor` fournit une API de plus haut niveau et plus propre sur `multiprocessing.Pool` et s’intègre naturellement avec les objets `Future`.

“`python

from concurrent.futures import ProcessPoolExecutor, as_completed

def heavy_computation(n):

return sum(i * i for i in range(n))

if __name__ == "__main__":

inputs = [106, 2 * 106, 3 * 106, 4 * 106]

with ProcessPoolExecutor(max_workers=4) as executor:

futures = {executor.submit(heavy_computation, n): n for n in inputs}

for future in as_completed(futures):

n = futures[future]

try:

result = future.result()

print(f"Input {n}: result = {result}")

except Exception as e:

print(f"Input {n} raised an exception: {e}")

“`

`as_completed()` produit des futures au fur et à mesure qu’elles se terminent plutôt que dans l’ordre de soumission, ce qui est utile lorsque les durées des tâches varient significativement.

Pièges de production et considérations avancées

Processus démons

Définir `process.daemon = True` avant d’appeler `start()` rend le processus enfant un démon. Les processus démons sont automatiquement terminés lorsque le processus parent se termine, empêchant les workers en arrière-plan orphelins. Cependant, les processus démons ne peuvent pas eux-mêmes créer des processus enfants.

Gestion des exceptions dans les processus workers

Les exceptions levées dans les fonctions worker ne se propagent pas automatiquement au processus parent lors de l’utilisation de `Pool.map()` — elles sont re-levées lorsque vous appelez `result()` sur la valeur retournée ou lorsque `map()` retourne. Avec `apply_async`, vous devez explicitement appeler `.get()` sur le `AsyncResult` pour faire remonter les exceptions.

“`python

from multiprocessing import Pool

def risky_function(x):

if x == 3:

raise ValueError(f"Cannot process value {x}")

return x * 10

if __name__ == "__main__":

with Pool(2) as pool:

try:

results = pool.map(risky_function, [1, 2, 3, 4])

except ValueError as e:

print(f"Caught worker exception: {e}")

“`

Consommation mémoire

Chaque processus créé duplique l’empreinte mémoire du parent (sur `fork`) ou réimporte tous les modules (sur `spawn`). Pour un processus parent consommant 2 Go de RAM, créer 8 workers sur un système basé sur `fork` peut sembler consommer 16 Go avant que le copy-on-write n’entre en jeu. Profilez soigneusement votre utilisation mémoire avant de faire évoluer le nombre de workers.

Éviter l’état global

Les variables globales dans le processus parent ne sont pas partagées avec les processus enfants après `spawn`. Les modifications apportées aux variables globales dans un processus enfant sont invisibles pour le parent et les autres enfants. Si vous vous appuyez sur une configuration globale, passez-la explicitement en arguments ou utilisez un `Manager`.

Chunking pour l’efficacité du Pool

`pool.map()` accepte un paramètre `chunksize`. Pour les grands itérables, définir une taille de chunk appropriée réduit la surcharge IPC en regroupant plusieurs éléments par cycle pickle/unpickle :

“`python

results = pool.map(process_item, large_list, chunksize=500)

“`

Choisir le bon matériel pour les charges de travail de multiprocessing

Le plafond de performance de toute application de multiprocessing est en fin de compte déterminé par le nombre de cœurs CPU physiques disponibles. Un pool de processus avec 32 workers sur une machine à 4 cœurs ne surpassera pas un pool de 4 workers — il sera plus lent en raison de la surcharge de changement de contexte.

Pour les déploiements en production d’applications Python intensives en CPU — pipelines de données, calcul scientifique, inférence ML par lots — vous avez besoin de ressources de calcul dédiées. Les Serveurs Dédiés avec des processeurs à nombre élevé de cœurs éliminent la contention des ressources inhérente aux environnements partagés, donnant à chaque processus worker un accès incontesté à un cœur physique.

Pour le développement, la mise en scène ou les charges de travail modérées, une instance d’Hébergement VPS correctement dimensionnée fournit un environnement rentable où vous pouvez ajuster le nombre de workers par rapport aux vCPUs disponibles. Si vous avez besoin d’un panneau de contrôle pour gérer votre environnement d’application Python, le VPS avec cPanel simplifie le déploiement et la surveillance des processus.

Pour les charges de travail accélérées par GPU où le multiprocessing Python est combiné avec des bibliothèques basées sur CUDA comme PyTorch ou CuPy, l’Hébergement GPU fournit le matériel nécessaire pour exécuter le prétraitement CPU parallèle aux côtés des pipelines de calcul GPU.

Lors du déploiement d’applications qui exposent des API soutenues par le multiprocessing via HTTPS, associer votre serveur à un Certificat SSL correctement configuré est une base non négociable pour la sécurité en production.

Matrice de décision pratique

Utilisez la liste de contrôle suivante pour déterminer l’approche correcte pour votre charge de travail :

Utilisez `multiprocessing.Process` directement lorsque :

  • Vous avez un petit nombre fixe de tâches hétérogènes
  • Chaque tâche a un cycle de vie distinct et nécessite une surveillance individuelle
  • Vous avez besoin d’un contrôle fin sur les attributs du processus (démon, nom, affinité)

Utilisez `multiprocessing.Pool` ou `ProcessPoolExecutor` lorsque :

  • Vous appliquez la même fonction à de nombreux éléments de données
  • Vous souhaitez une gestion automatique du cycle de vie des workers
  • Vous avez besoin de collecter des résultats avec un minimum de code répétitif

Utilisez `multiprocessing.Queue` lorsque :

  • Vous avez une architecture producteur-consommateur
  • Plusieurs producteurs ou consommateurs sont impliqués
  • Vous avez besoin d’un contrôle de contre-pression via `maxsize`

Utilisez `multiprocessing.Pipe` lorsque :

  • Exactement deux processus communiquent directement
  • La latence par message est plus importante que la flexibilité

Utilisez `multiprocessing.Value` / `Array` lorsque :

  • Vous partagez un état numérique simple entre de nombreux workers
  • La fréquence d’accès est élevée et la surcharge du proxy Manager est inacceptable

Utilisez `multiprocessing.Manager` lorsque :

  • Vous avez besoin de partager des objets Python complexes (listes, dicts)
  • La cohérence est plus importante que la vitesse d’accès brute

Évitez entièrement le multiprocessing lorsque :

  • Votre goulot d’étranglement est les E/S (réseau, disque) — utilisez `asyncio` ou `threading`
  • Les tâches sont très courtes (< 1 ms) — la surcharge de création de processus dominera
  • Votre base de code repose fortement sur des objets non picklables

FAQ

Q : Pourquoi dois-je utiliser `if __name__ == "__main__":` dans les scripts Python de multiprocessing ?

Sur Windows et lors de l’utilisation de la méthode de démarrage `spawn`, Python réimporte le module principal dans chaque processus enfant. Sans la protection `__main__`, le processus enfant tentera de créer ses propres enfants de manière récursive, provoquant une bombe fork infinie. Cette protection est obligatoire sur Windows et constitue une bonne pratique sur toutes les plateformes.

Q : Quelle est la différence entre `pool.map()` et `pool.imap()` ?

`pool.map()` consomme immédiatement l’intégralité de l’itérable, sérialise tous les éléments, les distribue aux workers et bloque jusqu’à ce que tous les résultats soient collectés dans une liste. `pool.imap()` est paresseux — il soumet les éléments de manière incrémentale et retourne un itérateur, ce qui le rend efficace en mémoire pour les très grands ensembles de données. Utilisez `imap` lorsque l’itérable d’entrée ne tient pas confortablement en mémoire.

Q : Les processus de multiprocessing Python peuvent-ils partager une connexion de base de données ?

Non. Les connexions de base de données ne sont pas picklables et ne peuvent pas être passées entre les processus. Chaque processus worker doit établir sa propre connexion. Utilisez une bibliothèque de pool de connexions (telle que `SQLAlchemy` avec `pool_pre_ping=True`) initialisée dans la fonction worker, pas dans le processus parent.

Q : Comment gérer les interruptions clavier (Ctrl+C) de manière élégante dans un pool de multiprocessing ?

Enveloppez votre appel `pool.map()` dans un bloc `try/except KeyboardInterrupt` et appelez `pool.terminate()` suivi de `pool.join()` dans la clause `except`. De plus, définissez les processus workers comme processus démons si vous souhaitez qu’ils se terminent automatiquement lorsque le parent est tué. Sans gestion explicite, les processus workers peuvent continuer à s’exécuter en tant qu’orphelins après l’interruption du parent.

Q : Le multiprocessing Python est-il sûr à utiliser avec `fork` sur macOS ?

Depuis Python 3.8, la méthode de démarrage par défaut sur macOS est passée de `fork` à `spawn` précisément parce que `fork` combiné avec le runtime Objective-C de macOS et certaines extensions C (y compris celles utilisées par NumPy et PyTorch) causait des blocages. Utilisez toujours `spawn` ou `forkserver` sur macOS et définissez explicitement la méthode de démarrage plutôt que de vous fier aux valeurs par défaut, qui diffèrent selon les systèmes d’exploitation.

15%

Économisez 15% sur tous les services d'hébergement

Testez vos compétences et obtenez Réduction sur tout plan d'hébergement

Utilisez le code :

Skills
Commencer