Почему не потоки? Проблема GIL

Почему не потоки? Проблема GIL


Мультипроцессинг в Python: параллельные вычисления без ограничений GIL

Многозадачность в Python часто ассоциируется с потоками, но для CPU-задач (тяжелых вычислений) модуль multiprocessing становится настоящим спасением. В отличие от потоков, процессы обходят ограничение Global Interpreter Lock (GIL), выполняя код параллельно на разных ядрах процессора. Это делает мультипроцессинг ключевым инструментом для оптимизации производительности.


Почему не потоки? Проблема GIL

В Python потоки выполняются в рамках одного процесса и делят один GIL, что исключает истинный параллелизм для CPU-операций. Например, при обработке изображений или математических расчетах потоки будут работать последовательно. Мультипроцессинг же запускает отдельные процессы с собственными интерпретаторами и памятью, что позволяет задействовать все доступные ядра.


Базовое использование: класс Process

Создание процесса аналогично работе с потоками, но через модуль multiprocessing:

from multiprocessing import Process
import os

def task(name):
    print(f"Процесс {name} (PID: {os.getpid()}) выполнил задачу")

if __name__ == "__main__":
    processes = []
    for i in range(3):
        p = Process(target=task, args=(f"Process-{i}",))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()  # Ожидание завершения всех процессов

Пояснение:

  • Каждый процесс имеет уникальный PID.
  • if __name__ == "__main__" обязательно для Windows во избежание ошибок.
  • Методы start() и join() работают аналогично потокам.

Обмен данными между процессами

Процессы не разделяют память, поэтому для коммуникации используются специальные объекты:

1. Очереди (Queue)

from multiprocessing import Process, Queue

def worker(q):
    q.put("Сообщение из дочернего процесса")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()
    print(q.get())  # Вывод: "Сообщение из дочернего процесса"
    p.join()

2. Совместная память (Value, Array)

from multiprocessing import Process, Value, Array

def update_shared_data(val, arr):
    val.value += 10
    for i in range(len(arr)):
        arr[i] *= 2

if __name__ == "__main__":
    num = Value("i", 5)           # int со значением 5
    arr = Array("d", [1.0, 2.0])  # Массив double

    p = Process(target=update_shared_data, args=(num, arr))
    p.start()
    p.join()

    print(num.value)  # 15
    print(arr[:])     # [2.0, 4.0]

Важно: Для синхронизации используйте Lock, чтобы избежать состояний гонки.


Пул процессов (Pool)

Класс Pool упрощает распределение задач между несколькими процессами:

from multiprocessing import Pool
import time

def square(x):
    return x * x

if __name__ == "__main__":
    with Pool(4) as p:  # Пул из 4 процессов
        result = p.map(square, [1, 2, 3, 4, 5])
    print(result)  # [1, 4, 9, 16, 25]

Методы map() и apply_async() позволяют параллельно обрабатывать данные.


Синхронизация процессов

Как и в потоках, для защиты общих ресурсов используются примитивы:

from multiprocessing import Process, Lock

lock = Lock()
counter = Value("i", 0)

def increment():
    global counter
    with lock:
        counter.value += 1

processes = []
for _ in range(100):
    p = Process(target=increment)
    processes.append(p)
    p.start()

for p in processes:
    p.join()

print(counter.value)  # Всегда 100

Практические примеры

1. Параллельная обработка данных

from multiprocessing import Pool
import math

def compute_factorial(n):
    return math.factorial(n)

if __name__ == "__main__":
    data = [100, 200, 300, 400]
    with Pool() as pool:  # По умолчанию число процессов = число ядер
        results = pool.map(compute_factorial, data)
    print(results)

2. Распределение HTTP-запросов

from multiprocessing import Pool
import requests

def fetch_url(url):
    response = requests.get(url)
    return response.status_code

urls = ["https://example.com"] * 10
with Pool(5) as p:
    print(p.map(fetch_url, urls))

Преимущества и недостатки

Плюсы:

  • Истинный параллелизм для CPU-bound задач.
  • Обход GIL.
  • Изоляция процессов (сбой в одном не влияет на другие).

Минусы:

  • Высокие накладные расходы на создание процессов.
  • Сложности с обменом данными (сериализация через pickle).
  • Большее потребление памяти.

Лучшие практики

  1. Используйте пулы процессов вместо создания множества отдельных Process.
  2. Оптимальное число процессов — обычно равно числу ядер CPU (os.cpu_count()).
  3. Минимизируйте передачу данных между процессами (IPC — дорогая операция).
  4. Избегайте глобальных переменных — процессы не разделяют память.
  5. Для сложных задач используйте библиотеки вроде multiprocessing.Manager для разделяемых структур данных.

Альтернативы

  • Threading: Для I/O-bound задач (сетевая активность, файловые операции).
  • Asyncio: Для асинхронного I/O с одним потоком.
  • Joblib/Dask: Высокоуровневые инструменты для распределенных вычислений.

Заключение

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