15%

15% auf alle Hosting-Dienste sparen

Teste deine Fähigkeiten und erhalte Rabatt auf jeden Hosting-Plan

Benutze den Code:

Skills
Anfangen
09.10.2024

Python Multiprocessing: Ein vollständiger technischer Leitfaden zur parallelen Ausführung

Pythons Multiprocessing-Modul ermöglicht echte parallele Ausführung durch das Erstellen unabhängiger Prozesse auf OS-Ebene, jeder mit eigenem Speicherbereich und Python-Interpreter – wodurch der Global Interpreter Lock (GIL) vollständig umgangen wird. Im Gegensatz zu Threads, die einen einzelnen Interpreter-Zustand teilen und vom GIL serialisiert werden, laufen separate Prozesse gleichzeitig auf allen verfügbaren CPU-Kernen, was Multiprocessing zum richtigen Werkzeug für CPU-gebundene Arbeitslasten wie numerische Berechnungen, Bildverarbeitung und Machine-Learning-Inferenz macht.

Dieser Leitfaden behandelt alles von der grundlegenden Architektur von Pythons Prozessmodell bis hin zu fortgeschrittenen Mustern einschließlich Shared Memory, Prozesspools, Inter-Prozess-Kommunikation und produktionsrelevanten Fallstricken, die die meisten Tutorials vollständig auslassen.

Warum der GIL Multithreading für CPU-gebundene Arbeit unzureichend macht

Der Global Interpreter Lock ist ein Mutex, der CPythons interne Objekt-Referenzzähler schützt. Nur ein Thread kann den GIL halten und Python-Bytecode zu einem bestimmten Zeitpunkt ausführen. Für I/O-gebundene Aufgaben – Netzwerkanfragen, Datenbankabfragen, Dateilesevorgänge – bleiben Threads nützlich, da der GIL während blockierender I/O-Syscalls freigegeben wird. Bei reiner Berechnung hingegen konkurrieren Threads kontinuierlich um den GIL und erzeugen keine echte Parallelität, selbst auf einer 64-Kern-Maschine.

Multiprocessing umgeht dies vollständig. Jeder gestartete Prozess ist ein vollständiger, unabhängiger OS-Prozess mit eigenem CPython-Interpreter, Heap und GIL. Der Betriebssystem-Scheduler verteilt diese Prozesse auf physische Kerne und liefert echte Parallelität.

GIL-Auswirkung: Ein konkretes Beispiel

Betrachten Sie eine Funktion, die 10 Millionen ganzzahlige Additionen durchführt. Das Ausführen in zwei Threads auf einer Dual-Core-Maschine dauert ungefähr genauso lange wie das Ausführen in einem einzelnen Thread – manchmal länger aufgrund des GIL-Contention-Overheads. Das Ausführen in zwei separaten Prozessen halbiert die Wanduhrzeit.

Multiprocessing vs. Multithreading vs. Asyncio

Zu verstehen, wann welches Nebenläufigkeitsmodell verwendet werden soll, ist genauso wichtig wie zu wissen, wie man es verwendet.

Merkmal`multiprocessing``threading``asyncio`
ParallelismustypEcht (OS-Prozesse)Pseudo (GIL-begrenzt)Kooperativ (single-threaded)
GIL-UmgehungJaNeinNein
SpeichermodellSeparat pro ProzessGeteiltGeteilt
Bester AnwendungsfallCPU-gebundene AufgabenI/O-gebunden + Legacy-BibliothekenI/O-gebunden, hohe Nebenläufigkeit
Kommunikations-OverheadHoch (IPC erforderlich)Niedrig (Shared Memory)Niedrig (Coroutinen)
Fehler-IsolationStark (Crash-Isolation)Schwach (ein Thread-Absturz kann alle beenden)Schwach
Startzeit-OverheadHochNiedrigSehr niedrig
Typischer SpeicherverbrauchHochNiedrigSehr niedrig

Faustregel: Verwenden Sie `multiprocessing` für CPU-gebundene Arbeit, `threading` oder `asyncio` für I/O-gebundene Arbeit. Wenn Sie beides benötigen, bietet `concurrent.futures` eine einheitliche Schnittstelle über beide Modelle.

Kernarchitektur: Wie Python Prozesse startet

Python unterstützt drei Startmethoden zum Erstellen von Kindprozessen, und die Wahl hat erhebliche Konsequenzen:

  • `fork` (Standard auf Linux/macOS): Kopiert den Elternprozess-Speicher mittels Copy-on-Write. Schnell, kann aber Probleme mit mehrthreadigen Elternprozessen oder C-Erweiterungen verursachen, die Locks halten.
  • `spawn` (Standard auf Windows, auf allen Plattformen verfügbar): Startet einen neuen Python-Interpreter und importiert das Modul. Langsamer, aber sicherer. Erfordert, dass der gesamte Code importierbar ist, weshalb der `if __name__ == "__main__":`-Guard obligatorisch ist.
  • `forkserver`: Ein dedizierter Serverprozess forkt bei Bedarf. Vermeidet Fork-Sicherheitsprobleme und ist effizienter als reines Spawn für viele kurzlebige Prozesse.

Legen Sie die Startmethode explizit am Anfang Ihres Einstiegspunkts fest:

“`python

import multiprocessing

if __name__ == "__main__":

multiprocessing.set_start_method("spawn")

“`

Das Nichtverständnis von Startmethoden ist eine der häufigsten Quellen subtiler, plattformspezifischer Fehler in produktivem Multiprocessing-Code.

Importieren des Moduls

“`python

import multiprocessing

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

“`

Wichtige Primitive und ihre Rollen

PrimitivZweck
`Process`Startet einen einzelnen unabhängigen Prozess
`Pool`Verwaltet einen wiederverwendbaren Worker-Pool
`Queue`Thread- und prozesssichere FIFO für IPC
`Pipe`Schnelle Zwei-Endpunkt-Verbindung zwischen zwei Prozessen
`Lock` / `RLock`Gegenseitiger Ausschluss für gemeinsame Ressourcen
`Value` / `Array`Shared Memory für einfache Typen
`Manager`Proxy-Objekte für komplexen gemeinsamen Zustand
`Event` / `Semaphore`Synchronisationsprimitive

Beispiel 1: Starten eines einzelnen Prozesses

Die `Process`-Klasse ist der grundlegende Baustein. Sie bildet direkt einen OS-Prozess ab.

“`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}")

“`

Wichtige Attribute und Methoden:

  • `target`: Das aufrufbare Objekt, das im Kindprozess ausgeführt wird.
  • `args` / `kwargs`: Argumente, die an die Zielfunktion übergeben werden.
  • `start()`: Forkt oder startet den Kindprozess.
  • `join(timeout=None)`: Blockiert den Aufrufer, bis der Prozess beendet wird. Rufen Sie immer `join()` auf, um Zombie-Prozesse zu verhindern.
  • `exitcode`: `0` bei sauberem Beenden, negativer Wert wenn durch ein Signal beendet, positiver Wert wenn der Prozess eine unbehandelte Ausnahme ausgelöst hat.
  • `is_alive()`: Gibt `True` zurück, wenn der Prozess noch läuft.
  • `terminate()` / `kill()`: Sendet `SIGTERM` / `SIGKILL` entsprechend. Mit Vorsicht verwenden – Ressourcen werden möglicherweise nicht bereinigt.

Kritischer Fallstrick: Wenn Sie einen Prozess starten, ohne `join()` aufzurufen, wird der Kindprozess auf Unix-Systemen zu einem Zombie-Prozess, der einen Prozesstabelleneintrag belegt, bis der Elternprozess beendet wird.

Beispiel 2: Prozesspools mit `multiprocessing.Pool`

Für Arbeitslasten, die dieselbe Funktion auf viele Datenpunkte anwenden, ist `Pool` weitaus effizienter als das manuelle Verwalten einzelner `Process`-Instanzen. Es hält eine feste Anzahl von Worker-Prozessen aufrecht und verteilt die Arbeit auf diese.

“`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}")

“`

Pool-Methoden im Vergleich

MethodeBlockierendGibt zurückAm besten für
`pool.map(f, iterable)`JaListe von ErgebnissenEinfaches paralleles Map
`pool.imap(f, iterable)`LazyIteratorGroße Iterables, Speichereffizienz
`pool.imap_unordered(f, iterable)`LazyIterator (ungeordnet)Wenn die Reihenfolge keine Rolle spielt
`pool.starmap(f, iterable)`JaListe von ErgebnissenFunktionen mit mehreren Argumenten
`pool.apply_async(f, args)`Nein`AsyncResult`Fire-and-Forget oder Callbacks
`pool.map_async(f, iterable)`Nein`AsyncResult`Nicht-blockierende Batch-Übermittlung

Fallstrick – Pool-Größenauswahl: Das Setzen von `processes` höher als `os.cpu_count()` verbessert den Durchsatz für CPU-gebundene Aufgaben selten und erhöht den Context-Switching-Overhead. Eine gängige Heuristik ist `processes = os.cpu_count() – 1`, um einen Kern für das OS und den Hauptprozess freizulassen.

Fallstrick – Serialisierung: Alle Argumente und Rückgabewerte, die zwischen dem Hauptprozess und den Workern übergeben werden, werden mit `pickle` serialisiert. Objekte, die nicht gepickelt werden können (Lambda-Funktionen, verschachtelte Funktionen, die innerhalb anderer Funktionen definiert sind, Datei-Handles, Datenbankverbindungen) lösen einen `PicklingError` aus. Verwenden Sie `pool.starmap` mit Funktionen auf Modulebene oder strukturieren Sie Ihren Code um, um die Übergabe nicht-pickelbarer Objekte zu vermeiden.

Beispiel 3: Inter-Prozess-Kommunikation mit Queue

`multiprocessing.Queue` ist eine prozesssichere FIFO, die auf einer Pipe und einem Lock aufgebaut ist. Es ist der Standardmechanismus für das Producer-Consumer-Muster.

“`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()

“`

Kritischer Designhinweis: Verwenden Sie niemals `queue.empty()`, um zu bestimmen, ob das Konsumieren gestoppt werden soll. Die `empty()`-Prüfung ist in einem Multiprocessing-Kontext nicht zuverlässig – es besteht eine Race Condition zwischen der Prüfung und dem nachfolgenden `get()`. Verwenden Sie immer einen Sentinel-Wert (wie `None` oder ein dediziertes `STOP`-Objekt), um zu signalisieren, dass die Produktion abgeschlossen ist.

Beispiel 4: Shared Memory mit Value und Array

Wenn Prozesse einfachen numerischen Zustand ohne den Overhead einer `Queue` teilen müssen, bieten `multiprocessing.Value` und `multiprocessing.Array` direkten Shared Memory, der durch `ctypes` unterstützt wird.

“`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

“`

Ohne den Lock wäre der Endwert aufgrund von Race Conditions beim Lesen-Modifizieren-Schreiben-Zyklus unvorhersehbar kleiner als 4000. Schützen Sie gemeinsamen veränderlichen Zustand immer mit einem `Lock`.

Für komplexe gemeinsame Datenstrukturen (Listen, Dicts, benutzerdefinierte Objekte) verwenden Sie `multiprocessing.Manager`, das einen Serverprozess erstellt, der die Objekte verwaltet und Proxy-Zugriff bereitstellt. Der Kompromiss ist eine höhere Latenz pro Zugriff im Vergleich zu rohem Shared Memory.

Beispiel 5: Pipe für direkte Zwei-Prozess-Kommunikation

`multiprocessing.Pipe` erstellt ein Paar von Verbindungsobjekten. Es ist schneller als `Queue` für die Punkt-zu-Punkt-Kommunikation zwischen genau zwei Prozessen, da es weniger Overhead hat.

“`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}")

“`

Verwenden Sie `Queue`, wenn mehrere Producer oder Consumer beteiligt sind. Verwenden Sie `Pipe`, wenn genau zwei Prozesse Daten direkt austauschen.

Beispiel 6: Verwendung von `concurrent.futures.ProcessPoolExecutor`

Für modernen Python-Code (3.2+) bietet `concurrent.futures.ProcessPoolExecutor` eine übergeordnete, sauberere API über `multiprocessing.Pool` und integriert sich natürlich mit `Future`-Objekten.

“`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()` liefert Futures in der Reihenfolge ihrer Fertigstellung statt in der Einreichungsreihenfolge, was nützlich ist, wenn die Aufgabendauern erheblich variieren.

Produktionsfallstricke und fortgeschrittene Überlegungen

Daemon-Prozesse

Das Setzen von `process.daemon = True` vor dem Aufruf von `start()` macht den Kindprozess zu einem Daemon. Daemon-Prozesse werden automatisch beendet, wenn der Elternprozess beendet wird, was verwaiste Hintergrund-Worker verhindert. Daemon-Prozesse können jedoch selbst keine Kindprozesse starten.

Ausnahmebehandlung in Worker-Prozessen

Ausnahmen, die innerhalb von Worker-Funktionen ausgelöst werden, propagieren nicht automatisch zum Elternprozess, wenn `Pool.map()` verwendet wird – sie werden erneut ausgelöst, wenn Sie `result()` auf dem zurückgegebenen Wert aufrufen oder wenn `map()` zurückkehrt. Mit `apply_async` müssen Sie explizit `.get()` auf dem `AsyncResult` aufrufen, um Ausnahmen sichtbar zu machen.

“`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}")

“`

Speicherverbrauch

Jeder gestartete Prozess dupliziert den Speicher-Footprint des Elternprozesses (bei `fork`) oder importiert alle Module neu (bei `spawn`). Für einen Elternprozess, der 2 GB RAM verbraucht, kann das Starten von 8 Workern auf einem `fork`-basierten System scheinbar 16 GB verbrauchen, bevor Copy-on-Write greift. Profilieren Sie Ihren Speicherverbrauch sorgfältig, bevor Sie die Anzahl der Worker skalieren.

Globalen Zustand vermeiden

Globale Variablen im Elternprozess werden nach `spawn` nicht mit Kindprozessen geteilt. Änderungen an globalen Variablen in einem Kindprozess sind für den Elternprozess und andere Kinder unsichtbar. Wenn Sie auf globale Konfiguration angewiesen sind, übergeben Sie diese explizit als Argumente oder verwenden Sie einen `Manager`.

Chunking für Pool-Effizienz

`pool.map()` akzeptiert einen `chunksize`-Parameter. Für große Iterables reduziert das Setzen einer geeigneten Chunk-Größe den IPC-Overhead, indem mehrere Elemente pro Pickle/Unpickle-Zyklus gebündelt werden:

“`python

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

“`

Die richtige Hardware für Multiprocessing-Arbeitslasten wählen

Die Leistungsgrenze jeder Multiprocessing-Anwendung wird letztendlich durch die Anzahl der verfügbaren physischen CPU-Kerne bestimmt. Ein Prozesspool mit 32 Workern auf einer 4-Kern-Maschine wird einen Pool von 4 Workern nicht übertreffen – er wird aufgrund des Context-Switching-Overheads langsamer sein.

Für Produktionsbereitstellungen CPU-intensiver Python-Anwendungen – Datenpipelines, wissenschaftliche Berechnungen, Batch-ML-Inferenz – benötigen Sie dedizierte Rechenressourcen. Dedizierte Server mit Prozessoren mit hoher Kernanzahl eliminieren die Ressourcenkonkurrenz in gemeinsam genutzten Umgebungen und geben jedem Worker-Prozess uneingeschränkten Zugriff auf einen physischen Kern.

Für Entwicklung, Staging oder moderate Arbeitslasten bietet eine angemessen dimensionierte VPS-Hosting-Instanz eine kosteneffektive Umgebung, in der Sie die Anzahl der Worker gegen verfügbare vCPUs abstimmen können. Wenn Sie ein Control Panel für die Verwaltung Ihrer Python-Anwendungsumgebung benötigen, vereinfacht VPS mit cPanel die Bereitstellung und Prozessüberwachung.

Für GPU-beschleunigte Arbeitslasten, bei denen Python-Multiprocessing mit CUDA-basierten Bibliotheken wie PyTorch oder CuPy kombiniert wird, bietet GPU-Hosting die notwendige Hardware, um parallele CPU-Vorverarbeitung neben GPU-Berechnungspipelines auszuführen.

Bei der Bereitstellung von Anwendungen, die Multiprocessing-gestützte APIs über HTTPS bereitstellen, ist die Kombination Ihres Servers mit einem ordnungsgemäß konfigurierten SSL-Zertifikat eine unverzichtbare Grundlage für die Produktionssicherheit.

Praktische Entscheidungsmatrix

Verwenden Sie die folgende Checkliste, um den richtigen Ansatz für Ihre Arbeitslast zu bestimmen:

Verwenden Sie `multiprocessing.Process` direkt, wenn:

  • Sie eine kleine, feste Anzahl heterogener Aufgaben haben
  • Jede Aufgabe einen eigenen Lebenszyklus hat und individuelle Überwachung erfordert
  • Sie eine feinkörnige Kontrolle über Prozessattribute benötigen (Daemon, Name, Affinität)

Verwenden Sie `multiprocessing.Pool` oder `ProcessPoolExecutor`, wenn:

  • Sie dieselbe Funktion auf viele Datenpunkte anwenden
  • Sie automatisches Worker-Lifecycle-Management wünschen
  • Sie Ergebnissammlung mit minimalem Boilerplate benötigen

Verwenden Sie `multiprocessing.Queue`, wenn:

  • Sie eine Producer-Consumer-Architektur haben
  • Mehrere Producer oder Consumer beteiligt sind
  • Sie Backpressure-Kontrolle über `maxsize` benötigen

Verwenden Sie `multiprocessing.Pipe`, wenn:

  • Genau zwei Prozesse direkt kommunizieren
  • Latenz pro Nachricht wichtiger ist als Flexibilität

Verwenden Sie `multiprocessing.Value` / `Array`, wenn:

  • Sie einfachen numerischen Zustand zwischen vielen Workern teilen
  • Die Zugriffsfrequenz hoch ist und der Manager-Proxy-Overhead inakzeptabel ist

Verwenden Sie `multiprocessing.Manager`, wenn:

  • Sie komplexe Python-Objekte teilen müssen (Listen, Dicts)
  • Konsistenz wichtiger ist als rohe Zugriffsgeschwindigkeit

Vermeiden Sie Multiprocessing vollständig, wenn:

  • Ihr Engpass I/O ist (Netzwerk, Festplatte) – verwenden Sie `asyncio` oder `threading`
  • Aufgaben sehr kurzlebig sind (< 1 ms) – der Prozessstart-Overhead wird dominieren
  • Ihre Codebasis stark auf nicht-pickelbare Objekte angewiesen ist

FAQ

F: Warum muss ich `if __name__ == "__main__":` in Python-Multiprocessing-Skripten verwenden?

Auf Windows und bei Verwendung der `spawn`-Startmethode importiert Python das Hauptmodul in jedem Kindprozess neu. Ohne den `__main__`-Guard versucht der Kindprozess, rekursiv eigene Kinder zu starten, was zu einer unendlichen Fork-Bombe führt. Dieser Guard ist auf Windows obligatorisch und auf allen Plattformen eine Best Practice.

F: Was ist der Unterschied zwischen `pool.map()` und `pool.imap()`?

`pool.map()` verbraucht das gesamte Iterable sofort, serialisiert alle Elemente, verteilt sie an Worker und blockiert, bis alle Ergebnisse in einer Liste gesammelt sind. `pool.imap()` ist lazy – es übermittelt Elemente inkrementell und gibt einen Iterator zurück, was es speichereffizient für sehr große Datensätze macht. Verwenden Sie `imap`, wenn das Eingabe-Iterable nicht bequem in den Speicher passt.

F: Können Python-Multiprocessing-Prozesse eine Datenbankverbindung teilen?

Nein. Datenbankverbindungen sind nicht pickelbar und können nicht zwischen Prozessen übergeben werden. Jeder Worker-Prozess muss seine eigene Verbindung herstellen. Verwenden Sie eine Connection-Pool-Bibliothek (wie `SQLAlchemy` mit `pool_pre_ping=True`), die innerhalb der Worker-Funktion initialisiert wird, nicht im Elternprozess.

F: Wie behandle ich Tastaturunterbrechungen (Strg+C) in einem Multiprocessing-Pool ordnungsgemäß?

Umschließen Sie Ihren `pool.map()`-Aufruf in einem `try/except KeyboardInterrupt`-Block und rufen Sie `pool.terminate()` gefolgt von `pool.join()` in der `except`-Klausel auf. Setzen Sie Worker-Prozesse zusätzlich als Daemon-Prozesse, wenn diese automatisch beendet werden sollen, wenn der Elternprozess beendet wird. Ohne explizite Behandlung können Worker-Prozesse nach der Unterbrechung des Elternprozesses als Waisen weiterlaufen.

F: Ist Python-Multiprocessing sicher mit `fork` auf macOS zu verwenden?

Seit Python 3.8 wurde die Standard-Startmethode auf macOS von `fork` zu `spawn` geändert, speziell weil `fork` in Kombination mit macOS’s Objective-C-Runtime und bestimmten C-Erweiterungen (einschließlich derer, die von NumPy und PyTorch verwendet werden) Deadlocks verursachte. Verwenden Sie immer `spawn` oder `forkserver` auf macOS und setzen Sie die Startmethode explizit, anstatt sich auf Standardwerte zu verlassen, die sich je nach Betriebssystem unterscheiden.

15%

15% auf alle Hosting-Dienste sparen

Teste deine Fähigkeiten und erhalte Rabatt auf jeden Hosting-Plan

Benutze den Code:

Skills
Anfangen