Python Multiprocessing: Kompletny Przewodnik Techniczny po Równoległym Wykonywaniu
Moduł multiprocessing Pythona umożliwia prawdziwe równoległe wykonywanie kodu poprzez tworzenie niezależnych procesów na poziomie systemu operacyjnego, z własną przestrzenią pamięci i interpreterem Pythona — całkowicie omijając Global Interpreter Lock (GIL). W przeciwieństwie do wątków, które współdzielą stan jednego interpretera i są serializowane przez GIL, osobne procesy działają współbieżnie na wszystkich dostępnych rdzeniach CPU, co sprawia, że multiprocessing jest właściwym narzędziem dla zadań ograniczonych przez CPU, takich jak obliczenia numeryczne, przetwarzanie obrazów i wnioskowanie w uczeniu maszynowym.
Ten przewodnik obejmuje wszystko — od podstawowej architektury modelu procesów Pythona po zaawansowane wzorce, w tym pamięć współdzieloną, pule procesów, komunikację między procesami oraz pułapki produkcyjne, które większość poradników całkowicie pomija.
Dlaczego GIL sprawia, że wielowątkowość jest niewystarczająca dla zadań ograniczonych przez CPU
Global Interpreter Lock to mutex chroniący wewnętrzne liczniki referencji obiektów CPython. Tylko jeden wątek może posiadać GIL i wykonywać kod bajtowy Pythona w danym momencie. Dla zadań ograniczonych przez I/O — żądania sieciowe, zapytania do baz danych, odczyty plików — wątki pozostają użyteczne, ponieważ GIL jest zwalniany podczas blokujących wywołań systemowych I/O. Jednak dla czystych obliczeń wątki nieustannie rywalizują o GIL, nie zapewniając prawdziwego równoległości nawet na maszynie z 64 rdzeniami.
Multiprocessing całkowicie omija ten problem. Każdy uruchomiony proces jest pełnoprawnym, niezależnym procesem systemu operacyjnego z własnym interpreterem CPython, stertą i GIL. Harmonogram systemu operacyjnego rozkłada te procesy na fizyczne rdzenie, zapewniając prawdziwe równoległe wykonywanie.
Wpływ GIL: Konkretny przykład
Rozważmy funkcję wykonującą 10 milionów dodawań liczb całkowitych. Uruchomienie jej w dwóch wątkach na maszynie dwurdzeniowej zajmie mniej więcej tyle samo czasu zegarowego co uruchomienie w jednym wątku — czasem dłużej ze względu na narzut związany z rywalizacją o GIL. Uruchomienie jej w dwóch osobnych procesach skróci czas zegarowy o połowę.
Multiprocessing vs. wielowątkowość vs. Asyncio
Zrozumienie, kiedy używać każdego modelu współbieżności, jest równie ważne jak wiedza o tym, jak ich używać.
| Funkcja | `multiprocessing` | `threading` | `asyncio` |
|---|---|---|---|
| — | — | — | — |
| Typ równoległości | Prawdziwa (procesy OS) | Pseudo (ograniczona przez GIL) | Kooperatywna (jednowątkowa) |
| Ominięcie GIL | Tak | Nie | Nie |
| Model pamięci | Oddzielna dla każdego procesu | Współdzielona | Współdzielona |
| Najlepsze zastosowanie | Zadania ograniczone przez CPU | I/O + starsze biblioteki | I/O, wysoka współbieżność |
| Narzut komunikacyjny | Wysoki (wymagane IPC) | Niski (pamięć współdzielona) | Niski (korutyny) |
| Izolacja błędów | Silna (izolacja awarii) | Słaba (awaria jednego wątku może zabić wszystkie) | Słaba |
| Narzut uruchamiania | Wysoki | Niski | Bardzo niski |
| Typowe zużycie pamięci | Wysokie | Niskie | Bardzo niskie |
Zasada ogólna: Używaj `multiprocessing` dla zadań ograniczonych przez CPU, `threading` lub `asyncio` dla zadań ograniczonych przez I/O. Jeśli potrzebujesz obu, `concurrent.futures` zapewnia ujednolicony interfejs dla obu modeli.
Podstawowa architektura: Jak Python tworzy procesy
Python obsługuje trzy metody uruchamiania tworzenia procesów potomnych, a wybór ma istotne konsekwencje:
- `fork` (domyślna na Linux/macOS): Kopiuje pamięć procesu nadrzędnego przy użyciu copy-on-write. Szybka, ale może powodować problemy z wielowątkowymi procesami nadrzędnymi lub rozszerzeniami C trzymającymi blokady.
- `spawn` (domyślna na Windows, dostępna na wszystkich platformach): Uruchamia nowy interpreter Pythona i importuje moduł. Wolniejsza, ale bezpieczniejsza. Wymaga, aby cały kod był importowalny, dlatego zabezpieczenie `if __name__ == "__main__":` jest obowiązkowe.
- `forkserver`: Dedykowany proces serwera tworzy rozgałęzienia na żądanie. Unika problemów z bezpieczeństwem fork, będąc jednocześnie bardziej wydajnym niż czyste spawn dla wielu krótkotrwałych procesów.
Ustaw metodę uruchamiania jawnie na początku punktu wejścia:
“`python
import multiprocessing
if __name__ == "__main__":
multiprocessing.set_start_method("spawn")
“`
Niezrozumienie metod uruchamiania jest jednym z najczęstszych źródeł subtelnych, zależnych od platformy błędów w produkcyjnym kodzie multiprocessing.
Importowanie modułu
“`python
import multiprocessing
from multiprocessing import Process, Pool, Queue, Lock, Pipe, Value, Array
“`
Kluczowe prymitywy i ich role
| Prymityw | Przeznaczenie |
|---|---|
| — | — |
| `Process` | Tworzy pojedynczy niezależny proces |
| `Pool` | Zarządza pulą wielokrotnie używanych procesów roboczych |
| `Queue` | Bezpieczna dla wątków i procesów kolejka FIFO dla IPC |
| `Pipe` | Szybkie połączenie dwupunktowe między dwoma procesami |
| `Lock` / `RLock` | Wzajemne wykluczanie dla współdzielonych zasobów |
| `Value` / `Array` | Pamięć współdzielona dla prostych typów |
| `Manager` | Obiekty proxy dla złożonego współdzielonego stanu |
| `Event` / `Semaphore` | Prymitywy synchronizacji |
Przykład 1: Tworzenie pojedynczego procesu
Klasa `Process` jest podstawowym elementem budulcowym. Odpowiada bezpośrednio procesowi systemu operacyjnego.
“`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}")
“`
Kluczowe atrybuty i metody:
- `target`: Wywoływalny obiekt do wykonania w procesie potomnym.
- `args` / `kwargs`: Argumenty przekazywane do funkcji docelowej.
- `start()`: Tworzy rozgałęzienie lub uruchamia proces potomny.
- `join(timeout=None)`: Blokuje wywołującego do momentu zakończenia procesu. Zawsze wywołuj `join()`, aby zapobiec procesom zombie.
- `exitcode`: `0` przy czystym wyjściu, wartość ujemna jeśli proces został zabity przez sygnał, wartość dodatnia jeśli proces zgłosił nieobsłużony wyjątek.
- `is_alive()`: Zwraca `True` jeśli proces nadal działa.
- `terminate()` / `kill()`: Wysyła odpowiednio `SIGTERM` / `SIGKILL`. Używaj ostrożnie — zasoby mogą nie zostać wyczyszczone.
Krytyczna pułapka: Jeśli uruchomisz proces bez wywołania `join()`, proces potomny staje się procesem zombie w systemach Unix, zajmując wpis w tablicy procesów do momentu zakończenia procesu nadrzędnego.
Przykład 2: Pule procesów z `multiprocessing.Pool`
Dla zadań polegających na zastosowaniu tej samej funkcji do wielu elementów danych, `Pool` jest znacznie wydajniejszy niż ręczne zarządzanie poszczególnymi instancjami `Process`. Utrzymuje stałą liczbę procesów roboczych i rozdziela pracę między nimi.
“`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}")
“`
Porównanie metod puli
| Metoda | Blokująca | Zwraca | Najlepsza dla |
|---|---|---|---|
| — | — | — | — |
| `pool.map(f, iterable)` | Tak | Lista wyników | Prosta równoległa mapa |
| `pool.imap(f, iterable)` | Leniwa | Iterator | Duże iterowalne, wydajność pamięci |
| `pool.imap_unordered(f, iterable)` | Leniwa | Iterator (nieuporządkowany) | Gdy kolejność nie ma znaczenia |
| `pool.starmap(f, iterable)` | Tak | Lista wyników | Funkcje z wieloma argumentami |
| `pool.apply_async(f, args)` | Nie | `AsyncResult` | Uruchom i zapomnij lub wywołania zwrotne |
| `pool.map_async(f, iterable)` | Nie | `AsyncResult` | Nieblokujące wsadowe przesyłanie zadań |
Pułapka — dobór rozmiaru puli: Ustawienie `processes` wyższego niż `os.cpu_count()` rzadko poprawia przepustowość dla zadań ograniczonych przez CPU i zwiększa narzut związany z przełączaniem kontekstu. Powszechną heurystyką jest `processes = os.cpu_count() – 1`, aby pozostawić jeden rdzeń dla systemu operacyjnego i procesu głównego.
Pułapka — serializacja: Wszystkie argumenty i wartości zwracane przekazywane między procesem głównym a procesami roboczymi są serializowane przy użyciu `pickle`. Obiekty, których nie można spicklować (funkcje lambda, funkcje zagnieżdżone zdefiniowane wewnątrz innych funkcji, uchwyty plików, połączenia z bazami danych) spowodują zgłoszenie `PicklingError`. Używaj `pool.starmap` z funkcjami na poziomie modułu lub zrestrukturyzuj kod, aby unikać przekazywania obiektów niemożliwych do spicklowania.
Przykład 3: Komunikacja między procesami z Queue
`multiprocessing.Queue` to bezpieczna dla procesów kolejka FIFO zbudowana na potoku i blokadzie. Jest to standardowy mechanizm dla wzorca producent-konsument.
“`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()
“`
Krytyczna uwaga projektowa: Nigdy nie używaj `queue.empty()` do określania, kiedy przestać konsumować. Sprawdzenie `empty()` nie jest wiarygodne w kontekście multiprocessing — między sprawdzeniem a kolejnym `get()` istnieje warunek wyścigu. Zawsze używaj wartości sentinel (takiej jak `None` lub dedykowany obiekt `STOP`), aby sygnalizować zakończenie produkcji.
Przykład 4: Pamięć współdzielona z Value i Array
Gdy procesy muszą współdzielić prosty stan numeryczny bez narzutu `Queue`, `multiprocessing.Value` i `multiprocessing.Array` zapewniają bezpośrednią pamięć współdzieloną opartą na `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
“`
Bez blokady końcowa wartość byłaby nieprzewidywalnie mniejsza niż 4000 z powodu warunków wyścigu w cyklu odczyt-modyfikacja-zapis. Zawsze chroń współdzielony mutowalny stan za pomocą `Lock`.
Dla złożonych współdzielonych struktur danych (listy, słowniki, obiekty niestandardowe) używaj `multiprocessing.Manager`, który tworzy proces serwera zarządzający obiektami i zapewniający dostęp przez proxy. Kompromisem jest wyższe opóźnienie na dostęp w porównaniu z surową pamięcią współdzieloną.
Przykład 5: Pipe dla bezpośredniej komunikacji między dwoma procesami
`multiprocessing.Pipe` tworzy parę obiektów połączeń. Jest szybszy niż `Queue` dla komunikacji punkt-punkt między dokładnie dwoma procesami, ponieważ ma mniejszy narzut.
“`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}")
“`
Używaj `Queue` gdy zaangażowanych jest wielu producentów lub konsumentów. Używaj `Pipe` gdy dokładnie dwa procesy wymieniają dane bezpośrednio.
Przykład 6: Używanie `concurrent.futures.ProcessPoolExecutor`
Dla nowoczesnego kodu Python (3.2+), `concurrent.futures.ProcessPoolExecutor` zapewnia wyższy poziom, czystsze API nad `multiprocessing.Pool` i naturalnie integruje się z obiektami `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()` zwraca futures w miarę ich kończenia, a nie w kolejności przesyłania, co jest przydatne gdy czasy trwania zadań znacznie się różnią.
Pułapki produkcyjne i zaawansowane zagadnienia
Procesy demoniczne
Ustawienie `process.daemon = True` przed wywołaniem `start()` sprawia, że proces potomny staje się demonem. Procesy demoniczne są automatycznie kończone gdy proces nadrzędny kończy działanie, zapobiegając osieroconym procesom działającym w tle. Jednak procesy demoniczne nie mogą same tworzyć procesów potomnych.
Obsługa wyjątków w procesach roboczych
Wyjątki zgłaszane wewnątrz funkcji roboczych nie są automatycznie propagowane do procesu nadrzędnego podczas używania `Pool.map()` — są ponownie zgłaszane gdy wywołasz `result()` na zwróconej wartości lub gdy `map()` zwróci wynik. Z `apply_async` musisz jawnie wywołać `.get()` na `AsyncResult`, aby ujawnić wyjątki.
“`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}")
“`
Zużycie pamięci
Każdy uruchomiony proces duplikuje ślad pamięci procesu nadrzędnego (przy `fork`) lub ponownie importuje wszystkie moduły (przy `spawn`). Dla procesu nadrzędnego zużywającego 2 GB RAM, uruchomienie 8 procesów roboczych w systemie opartym na `fork` może pozornie zużywać 16 GB przed uruchomieniem mechanizmu copy-on-write. Dokładnie profiluj zużycie pamięci przed skalowaniem liczby procesów roboczych.
Unikanie globalnego stanu
Zmienne globalne w procesie nadrzędnym nie są współdzielone z procesami potomnymi po `spawn`. Zmiany dokonane w zmiennych globalnych w procesie potomnym są niewidoczne dla procesu nadrzędnego i innych procesów potomnych. Jeśli polegasz na globalnej konfiguracji, przekazuj ją jawnie jako argumenty lub używaj `Manager`.
Porcjowanie dla wydajności puli
`pool.map()` przyjmuje parametr `chunksize`. Dla dużych iterowalnych, ustawienie odpowiedniego rozmiaru porcji zmniejsza narzut IPC poprzez grupowanie wielu elementów na jeden cykl pickle/unpickle:
“`python
results = pool.map(process_item, large_list, chunksize=500)
“`
Wybór odpowiedniego sprzętu dla zadań multiprocessing
Pułap wydajności każdej aplikacji multiprocessing jest ostatecznie determinowany przez liczbę dostępnych fizycznych rdzeni CPU. Pula procesów z 32 procesami roboczymi na maszynie 4-rdzeniowej nie przewyższy puli 4 procesów roboczych — będzie wolniejsza ze względu na narzut związany z przełączaniem kontekstu.
Dla produkcyjnych wdrożeń aplikacji Python intensywnie wykorzystujących CPU — potoki danych, obliczenia naukowe, wsadowe wnioskowanie ML — potrzebujesz dedykowanych zasobów obliczeniowych. Serwery dedykowane z procesorami o dużej liczbie rdzeni eliminują rywalizację o zasoby charakterystyczną dla środowisk współdzielonych, dając każdemu procesowi roboczemu niepodzielny dostęp do fizycznego rdzenia.
Do programowania, testowania lub umiarkowanych obciążeń, odpowiednio dobrany Hosting VPS zapewnia ekonomiczne środowisko, w którym możesz dostosowywać liczbę procesów roboczych do dostępnych vCPU. Jeśli potrzebujesz panelu sterowania do zarządzania środowiskiem aplikacji Python, VPS z cPanel upraszcza wdrożenie i monitorowanie procesów.
Dla zadań akcelerowanych przez GPU, gdzie multiprocessing Pythona jest łączony z bibliotekami opartymi na CUDA, takimi jak PyTorch lub CuPy, Hosting GPU zapewnia niezbędny sprzęt do uruchamiania równoległego przetwarzania wstępnego CPU obok potoków obliczeniowych GPU.
Podczas wdrażania aplikacji udostępniających API oparte na multiprocessing przez HTTPS, połączenie serwera z odpowiednio skonfigurowanym Certyfikatem SSL jest niezbędnym minimum dla bezpieczeństwa produkcyjnego.
Praktyczna macierz decyzyjna
Użyj poniższej listy kontrolnej, aby określić właściwe podejście dla swojego zadania:
Używaj `multiprocessing.Process` bezpośrednio gdy:
- Masz małą, stałą liczbę heterogenicznych zadań
- Każde zadanie ma odrębny cykl życia i wymaga indywidualnego monitorowania
- Potrzebujesz szczegółowej kontroli nad atrybutami procesu (demon, nazwa, powinowactwo)
Używaj `multiprocessing.Pool` lub `ProcessPoolExecutor` gdy:
- Stosujesz tę samą funkcję do wielu elementów danych
- Chcesz automatycznego zarządzania cyklem życia procesów roboczych
- Potrzebujesz zbierania wyników przy minimalnym kodzie szablonowym
Używaj `multiprocessing.Queue` gdy:
- Masz architekturę producent-konsument
- Zaangażowanych jest wielu producentów lub konsumentów
- Potrzebujesz kontroli ciśnienia wstecznego przez `maxsize`
Używaj `multiprocessing.Pipe` gdy:
- Dokładnie dwa procesy komunikują się bezpośrednio
- Opóźnienie na wiadomość jest ważniejsze niż elastyczność
Używaj `multiprocessing.Value` / `Array` gdy:
- Współdzielisz prosty stan numeryczny między wieloma procesami roboczymi
- Częstotliwość dostępu jest wysoka, a narzut proxy Managera jest niedopuszczalny
Używaj `multiprocessing.Manager` gdy:
- Musisz współdzielić złożone obiekty Pythona (listy, słowniki)
- Spójność jest ważniejsza niż surowa szybkość dostępu
Unikaj multiprocessing całkowicie gdy:
- Wąskim gardłem jest I/O (sieć, dysk) — używaj `asyncio` lub `threading`
- Zadania są bardzo krótkotrwałe (< 1 ms) — narzut uruchamiania procesu będzie dominować
- Twoja baza kodu w dużym stopniu opiera się na obiektach niemożliwych do spicklowania
FAQ
P: Dlaczego muszę używać `if __name__ == "__main__":` w skryptach Python multiprocessing?
Na Windows i przy użyciu metody uruchamiania `spawn`, Python ponownie importuje główny moduł w każdym procesie potomnym. Bez zabezpieczenia `__main__`, proces potomny będzie próbował rekurencyjnie tworzyć własne procesy potomne, powodując nieskończoną bombę fork. To zabezpieczenie jest obowiązkowe na Windows i stanowi dobrą praktykę na wszystkich platformach.
P: Jaka jest różnica między `pool.map()` a `pool.imap()`?
`pool.map()` natychmiast konsumuje całe iterowalne, serializuje wszystkie elementy, dystrybuuje je do procesów roboczych i blokuje do momentu zebrania wszystkich wyników na liście. `pool.imap()` jest leniwy — przesyła elementy stopniowo i zwraca iterator, co czyni go wydajnym pamięciowo dla bardzo dużych zbiorów danych. Używaj `imap` gdy iterowalne wejście nie mieści się wygodnie w pamięci.
P: Czy procesy multiprocessing Pythona mogą współdzielić połączenie z bazą danych?
Nie. Połączenia z bazami danych nie są picklowalne i nie mogą być przekazywane między procesami. Każdy proces roboczy musi ustanowić własne połączenie. Używaj biblioteki puli połączeń (takiej jak `SQLAlchemy` z `pool_pre_ping=True`) inicjalizowanej wewnątrz funkcji roboczej, nie w procesie nadrzędnym.
P: Jak obsługiwać przerwania klawiatury (Ctrl+C) w sposób elegancki w puli multiprocessing?
Opakuj wywołanie `pool.map()` w blok `try/except KeyboardInterrupt` i wywołaj `pool.terminate()` a następnie `pool.join()` w klauzuli `except`. Dodatkowo ustaw procesy robocze jako procesy demoniczne, jeśli chcesz, aby były automatycznie kończone gdy proces nadrzędny zostanie zabity. Bez jawnej obsługi, procesy robocze mogą nadal działać jako osierocone po przerwaniu procesu nadrzędnego.
P: Czy multiprocessing Pythona jest bezpieczny w użyciu z `fork` na macOS?
Od Pythona 3.8, domyślna metoda uruchamiania na macOS zmieniła się z `fork` na `spawn` właśnie dlatego, że `fork` w połączeniu ze środowiskiem uruchomieniowym Objective-C macOS i pewnymi rozszerzeniami C (w tym używanymi przez NumPy i PyTorch) powodował zakleszczenia. Zawsze używaj `spawn` lub `forkserver` na macOS i jawnie ustawiaj metodę uruchamiania zamiast polegać na wartościach domyślnych, które różnią się między systemami operacyjnymi.
