15%

Збережіть 15% на всі хостинг-послуги

Перевірте свої навички і отримайте Знижку на будь-який план хостингу

Використовуй код:

Skills
Почати
09.10.2024

Python Multiprocessing: Повний технічний посібник з паралельного виконання

Модуль multiprocessing у Python забезпечує справжнє паралельне виконання шляхом створення незалежних процесів на рівні ОС, кожен з яких має власний простір пам’яті та інтерпретатор Python — повністю обходячи Global Interpreter Lock (GIL). На відміну від потоків, які спільно використовують єдиний стан інтерпретатора та серіалізуються GIL, окремі процеси виконуються паралельно на всіх доступних ядрах CPU, що робить multiprocessing правильним інструментом для задач, обмежених CPU, таких як числові обчислення, обробка зображень та інференс у машинному навчанні.

Цей посібник охоплює все — від базової архітектури моделі процесів Python до розширених патернів, включаючи спільну пам’ять, пули процесів, міжпроцесну комунікацію та виробничі підводні камені, які більшість підручників повністю оминають.

Чому GIL робить багатопотоковість недостатньою для задач, обмежених CPU

Global Interpreter Lock — це м’ютекс, який захищає внутрішні лічильники посилань на об’єкти CPython. Лише один потік може утримувати GIL і виконувати байткод Python у будь-який момент часу. Для задач, обмежених I/O — мережевих запитів, запитів до бази даних, читання файлів — потоки залишаються корисними, оскільки GIL звільняється під час блокуючих системних викликів I/O. Однак для чистих обчислень потоки безперервно конкурують за GIL, не забезпечуючи реального паралелізму навіть на 64-ядерній машині.

Multiprocessing повністю обходить цю проблему. Кожен створений процес є повноцінним незалежним процесом ОС з власним інтерпретатором CPython, купою та GIL. Планувальник операційної системи розподіляє ці процеси між фізичними ядрами, забезпечуючи справжній паралелізм.

Вплив GIL: конкретний приклад

Розглянемо функцію, яка виконує 10 мільйонів цілочисельних додавань. Запуск її у двох потоках на двоядерній машині займе приблизно стільки ж реального часу, що й запуск в одному потоці — іноді довше через накладні витрати на конкуренцію за GIL. Запуск у двох окремих процесах скоротить реальний час вдвічі.

Multiprocessing проти Multithreading проти Asyncio

Розуміння того, коли використовувати кожну модель конкурентності, так само важливо, як і знання того, як їх використовувати.

Характеристика`multiprocessing``threading``asyncio`
Тип паралелізмуСправжній (процеси ОС)Псевдо (обмежений GIL)Кооперативний (однопотоковий)
Обхід GILТакНіНі
Модель пам’ятіОкрема для кожного процесуСпільнаСпільна
Найкращий випадок використанняЗадачі, обмежені CPUI/O-обмежені + застарілі бібліотекиI/O-обмежені, висока конкурентність
Накладні витрати на комунікаціюВисокі (потрібен IPC)Низькі (спільна пам’ять)Низькі (корутини)
Ізоляція збоївСильна (ізоляція аварій)Слабка (аварія одного потоку може вбити всі)Слабка
Накладні витрати на запускВисокіНизькіДуже низькі
Типове використання пам’ятіВисокеНизькеДуже низьке

Практичне правило: Використовуйте `multiprocessing` для задач, обмежених CPU, `threading` або `asyncio` для задач, обмежених I/O. Якщо вам потрібні обидва, `concurrent.futures` надає уніфікований інтерфейс для обох моделей.

Базова архітектура: як Python створює процеси

Python підтримує три методи запуску для створення дочірніх процесів, і вибір має суттєві наслідки:

  • `fork` (за замовчуванням на Linux/macOS): Копіює пам’ять батьківського процесу з використанням copy-on-write. Швидкий, але може спричиняти проблеми з багатопотоковими батьківськими процесами або розширеннями C, які утримують блокування.
  • `spawn` (за замовчуванням на Windows, доступний на всіх платформах): Запускає новий інтерпретатор Python та імпортує модуль. Повільніший, але безпечніший. Вимагає, щоб весь код був імпортованим, саме тому захист `if __name__ == "__main__":` є обов’язковим.
  • `forkserver`: Виділений серверний процес виконує fork на вимогу. Уникає проблем з безпекою fork, будучи ефективнішим за чистий spawn для багатьох короткоживучих процесів.

Явно встановіть метод запуску на початку вашої точки входу:

“`python

import multiprocessing

if __name__ == "__main__":

multiprocessing.set_start_method("spawn")

“`

Нерозуміння методів запуску є одним із найпоширеніших джерел тонких, платформозалежних помилок у виробничому коді multiprocessing.

Імпорт модуля

“`python

import multiprocessing

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

“`

Ключові примітиви та їх ролі

ПримітивПризначення
`Process`Створює єдиний незалежний процес
`Pool`Керує пулом робочих процесів для повторного використання
`Queue`Потокобезпечна та процесобезпечна черга FIFO для IPC
`Pipe`Швидке двоточкове з’єднання між двома процесами
`Lock` / `RLock`Взаємне виключення для спільних ресурсів
`Value` / `Array`Спільна пам’ять для простих типів
`Manager`Проксі-об’єкти для складного спільного стану
`Event` / `Semaphore`Примітиви синхронізації

Приклад 1: Створення одного процесу

Клас `Process` є фундаментальним будівельним блоком. Він безпосередньо відповідає процесу ОС.

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

“`

Ключові атрибути та методи:

  • `target`: Викликаний об’єкт для виконання в дочірньому процесі.
  • `args` / `kwargs`: Аргументи, що передаються цільовій функції.
  • `start()`: Виконує fork або spawn дочірнього процесу.
  • `join(timeout=None)`: Блокує викликача до завершення процесу. Завжди викликайте `join()` для запобігання процесам-зомбі.
  • `exitcode`: `0` при чистому завершенні, від’ємне значення, якщо процес завершено сигналом, додатне значення, якщо процес викинув необроблений виняток.
  • `is_alive()`: Повертає `True`, якщо процес ще виконується.
  • `terminate()` / `kill()`: Надсилає `SIGTERM` / `SIGKILL` відповідно. Використовуйте з обережністю — ресурси можуть не бути очищені.

Критична пастка: Якщо ви створюєте процес без виклику `join()`, дочірній процес стає процесом-зомбі на Unix-системах, займаючи запис у таблиці процесів до завершення батьківського процесу.

Приклад 2: Пули процесів з `multiprocessing.Pool`

Для робочих навантажень, які застосовують одну й ту саму функцію до багатьох елементів даних, `Pool` є набагато ефективнішим, ніж ручне керування окремими екземплярами `Process`. Він підтримує фіксовану кількість робочих процесів і розподіляє між ними роботу.

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

МетодБлокуючийПовертаєНайкраще для
`pool.map(f, iterable)`ТакСписок результатівПростий паралельний map
`pool.imap(f, iterable)`ЛінивоІтераторВеликі ітеровані об’єкти, ефективність пам’яті
`pool.imap_unordered(f, iterable)`ЛінивоІтератор (невпорядкований)Коли порядок не має значення
`pool.starmap(f, iterable)`ТакСписок результатівФункції з кількома аргументами
`pool.apply_async(f, args)`Ні`AsyncResult`Запуск без очікування результату або зворотні виклики
`pool.map_async(f, iterable)`Ні`AsyncResult`Неблокуюче пакетне надсилання

Пастка — вибір розміру пулу: Встановлення `processes` вище за `os.cpu_count()` рідко покращує пропускну здатність для задач, обмежених CPU, і збільшує накладні витрати на перемикання контексту. Поширена евристика — `processes = os.cpu_count() – 1` — залишити одне ядро для ОС та головного процесу.

Пастка — серіалізація: Усі аргументи та значення, що повертаються між головним процесом і робочими, серіалізуються за допомогою `pickle`. Об’єкти, які не можна серіалізувати (лямбда-функції, вкладені функції, визначені всередині інших функцій, файлові дескриптори, з’єднання з базою даних), викличуть `PicklingError`. Використовуйте `pool.starmap` з функціями рівня модуля або реструктуруйте код, щоб уникнути передачі об’єктів, які не можна серіалізувати.

Приклад 3: Міжпроцесна комунікація з Queue

`multiprocessing.Queue` — це процесобезпечна черга FIFO, побудована поверх pipe та блокування. Це стандартний механізм для патерну виробник-споживач.

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

“`

Критична примітка щодо дизайну: Ніколи не використовуйте `queue.empty()` для визначення того, коли зупинити споживання. Перевірка `empty()` ненадійна в контексті multiprocessing — між перевіркою та наступним `get()` існує стан гонки. Завжди використовуйте сигнальне значення (наприклад, `None` або спеціальний об’єкт `STOP`) для сигналізації про завершення виробництва.

Приклад 4: Спільна пам’ять з Value та Array

Коли процесам потрібно спільно використовувати простий числовий стан без накладних витрат `Queue`, `multiprocessing.Value` та `multiprocessing.Array` надають пряму спільну пам’ять, підкріплену `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

“`

Без блокування кінцеве значення було б непередбачувано меншим за 4000 через стани гонки в циклі читання-модифікації-запису. Завжди захищайте спільний змінний стан за допомогою `Lock`.

Для складних спільних структур даних (списків, словників, користувацьких об’єктів) використовуйте `multiprocessing.Manager`, який створює серверний процес, що керує об’єктами та надає проксі-доступ. Компроміс полягає у вищій затримці на кожен доступ порівняно з прямою спільною пам’яттю.

Приклад 5: Pipe для прямої комунікації між двома процесами

`multiprocessing.Pipe` створює пару об’єктів з’єднання. Він швидший за `Queue` для точкової комунікації між рівно двома процесами, оскільки має менші накладні витрати.

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

“`

Використовуйте `Queue`, коли задіяно кілька виробників або споживачів. Використовуйте `Pipe`, коли рівно два процеси обмінюються даними безпосередньо.

Приклад 6: Використання `concurrent.futures.ProcessPoolExecutor`

Для сучасного коду Python (3.2+) `concurrent.futures.ProcessPoolExecutor` надає більш високорівневий та чистий API поверх `multiprocessing.Pool` і природно інтегрується з об’єктами `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()` повертає ф’ючерси в міру їх завершення, а не в порядку надсилання, що корисно, коли тривалість задач суттєво відрізняється.

Виробничі пастки та розширені міркування

Демонічні процеси

Встановлення `process.daemon = True` перед викликом `start()` робить дочірній процес демонічним. Демонічні процеси автоматично завершуються при виході батьківського процесу, запобігаючи появі осиротілих фонових робочих процесів. Однак демонічні процеси самі не можуть створювати дочірні процеси.

Обробка винятків у робочих процесах

Винятки, викинуті всередині робочих функцій, не поширюються до батьківського процесу автоматично при використанні `Pool.map()` — вони повторно викидаються при виклику `result()` на поверненому значенні або коли `map()` повертається. З `apply_async` ви повинні явно викликати `.get()` на `AsyncResult` для виявлення винятків.

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

“`

Споживання пам’яті

Кожен створений процес дублює обсяг пам’яті батьківського процесу (при `fork`) або повторно імпортує всі модулі (при `spawn`). Для батьківського процесу, що споживає 2 GB RAM, створення 8 робочих процесів на системі з `fork` може здаватися споживанням 16 GB до того, як спрацює copy-on-write. Ретельно профілюйте використання пам’яті перед масштабуванням кількості робочих процесів.

Уникнення глобального стану

Глобальні змінні в батьківському процесі не є спільними з дочірніми процесами після `spawn`. Зміни, внесені до глобальних змінних у дочірньому процесі, невидимі для батьківського та інших дочірніх процесів. Якщо ви покладаєтесь на глобальну конфігурацію, передавайте її явно як аргументи або використовуйте `Manager`.

Розбиття на частини для ефективності Pool

`pool.map()` приймає параметр `chunksize`. Для великих ітерованих об’єктів встановлення відповідного розміру частини зменшує накладні витрати IPC шляхом пакетування кількох елементів за один цикл серіалізації/десеріалізації:

“`python

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

“`

Вибір правильного обладнання для робочих навантажень multiprocessing

Стеля продуктивності будь-якого застосунку multiprocessing в кінцевому підсумку визначається кількістю доступних фізичних ядер CPU. Пул процесів з 32 робочими на 4-ядерній машині не перевершить пул з 4 робочими — він буде повільнішим через накладні витрати на перемикання контексту.

Для виробничих розгортань CPU-інтенсивних Python-застосунків — конвеєрів даних, наукових обчислень, пакетного ML-інференсу — вам потрібні виділені обчислювальні ресурси. Виділені сервери з процесорами з великою кількістю ядер усувають конкуренцію за ресурси, притаманну спільним середовищам, надаючи кожному робочому процесу безперешкодний доступ до фізичного ядра.

Для розробки, тестування або помірних робочих навантажень правильно розмірений екземпляр VPS Хостингу надає економічно ефективне середовище, де ви можете налаштовувати кількість робочих процесів відповідно до доступних vCPU. Якщо вам потрібна панель керування для управління середовищем Python-застосунку, VPS з cPanel спрощує розгортання та моніторинг процесів.

Для робочих навантажень з прискоренням GPU, де multiprocessing Python поєднується з бібліотеками на основі CUDA, такими як PyTorch або CuPy, GPU Хостинг надає необхідне обладнання для паралельного виконання попередньої обробки на CPU поряд з конвеєрами обчислень на GPU.

При розгортанні застосунків, які надають API на основі multiprocessing через HTTPS, поєднання вашого сервера з належним чином налаштованим SSL Сертифікатом є обов’язковою базовою вимогою для виробничої безпеки.

Практична матриця рішень

Використовуйте наступний контрольний список для визначення правильного підходу до вашого робочого навантаження:

Використовуйте `multiprocessing.Process` безпосередньо, коли:

  • У вас є невелика фіксована кількість різнорідних задач
  • Кожна задача має окремий життєвий цикл і потребує індивідуального моніторингу
  • Вам потрібен детальний контроль над атрибутами процесу (daemon, name, affinity)

Використовуйте `multiprocessing.Pool` або `ProcessPoolExecutor`, коли:

  • Ви застосовуєте одну й ту саму функцію до багатьох елементів даних
  • Вам потрібне автоматичне керування життєвим циклом робочих процесів
  • Вам потрібне збирання результатів з мінімальним шаблонним кодом

Використовуйте `multiprocessing.Queue`, коли:

  • У вас є архітектура виробник-споживач
  • Задіяно кілька виробників або споживачів
  • Вам потрібен контроль зворотного тиску через `maxsize`

Використовуйте `multiprocessing.Pipe`, коли:

  • Рівно два процеси спілкуються безпосередньо
  • Затримка на повідомлення важливіша за гнучкість

Використовуйте `multiprocessing.Value` / `Array`, коли:

  • Ви спільно використовуєте простий числовий стан між багатьма робочими процесами
  • Частота доступу висока, а накладні витрати проксі Manager неприйнятні

Використовуйте `multiprocessing.Manager`, коли:

  • Вам потрібно спільно використовувати складні Python-об’єкти (списки, словники)
  • Узгодженість важливіша за швидкість доступу

Уникайте multiprocessing повністю, коли:

  • Ваше вузьке місце — I/O (мережа, диск) — використовуйте `asyncio` або `threading`
  • Задачі дуже короткоживучі (< 1 мс) — накладні витрати на створення процесу домінуватимуть
  • Ваша кодова база значною мірою покладається на об’єкти, які не можна серіалізувати

FAQ

П: Чому я повинен використовувати `if __name__ == "__main__":` у скриптах Python multiprocessing?

На Windows та при використанні методу запуску `spawn` Python повторно імпортує головний модуль у кожному дочірньому процесі. Без захисту `__main__` дочірній процес спробує рекурсивно створювати власні дочірні процеси, спричиняючи нескінченну fork-бомбу. Цей захист є обов’язковим на Windows і найкращою практикою на всіх платформах.

П: У чому різниця між `pool.map()` та `pool.imap()`?

`pool.map()` негайно споживає весь ітерований об’єкт, серіалізує всі елементи, розподіляє їх між робочими процесами та блокується до збирання всіх результатів у список. `pool.imap()` є ледачим — він надсилає елементи поступово та повертає ітератор, що робить його ефективним за пам’яттю для дуже великих наборів даних. Використовуйте `imap`, коли вхідний ітерований об’єкт не вміщується зручно в пам’яті.

П: Чи можуть процеси Python multiprocessing спільно використовувати з’єднання з базою даних?

Ні. З’єднання з базою даних не є серіалізованими і не можуть передаватися між процесами. Кожен робочий процес повинен встановлювати власне з’єднання. Використовуйте бібліотеку пулу з’єднань (наприклад, `SQLAlchemy` з `pool_pre_ping=True`), ініціалізовану всередині робочої функції, а не в батьківському процесі.

П: Як коректно обробляти переривання клавіатури (Ctrl+C) у пулі multiprocessing?

Оберніть виклик `pool.map()` у блок `try/except KeyboardInterrupt` та викличте `pool.terminate()` з наступним `pool.join()` у блоці `except`. Крім того, встановіть робочі процеси як демонічні, якщо ви хочете, щоб вони автоматично завершувалися при знищенні батьківського процесу. Без явної обробки робочі процеси можуть продовжувати виконуватися як осиротілі після переривання батьківського процесу.

П: Чи безпечно використовувати Python multiprocessing з `fork` на macOS?

Починаючи з Python 3.8, метод запуску за замовчуванням на macOS змінився з `fork` на `spawn` саме тому, що `fork` у поєднанні з середовищем виконання Objective-C macOS та певними розширеннями C (включаючи ті, що використовуються NumPy та PyTorch) спричиняв взаємні блокування. Завжди використовуйте `spawn` або `forkserver` на macOS та явно встановлюйте метод запуску, а не покладайтесь на значення за замовчуванням, які відрізняються між операційними системами.

15%

Збережіть 15% на всі хостинг-послуги

Перевірте свої навички і отримайте Знижку на будь-який план хостингу

Використовуй код:

Skills
Почати