Как реализовать параллельность в Python с учетом GIL?
Комментарии (2)
Ответ сгенерирован нейросетью и может содержать ошибки
Параллельность в Python с учетом GIL
Global Interpreter Lock (GIL) — это механизм в CPython, который позволяет только одному потоку выполнять Python код в один момент времени. Это затрудняет истинный параллелизм, но существуют проверенные подходы для его обхода.
Что такое GIL
GIL защищает внутреннее состояние CPython: память, подсчет ссылок (refcounting), структуры данных. Без GIL каждая операция требовала бы собственной блокировки, что привело бы к меньшей производительности.
# GIL упрощает разработку интерпретатора
# но затрудняет многопоточное программирование
import threading
def cpu_bound_task(n):
total = 0
for i in range(n):
total += i
return total
# Это НЕ будет работать параллельно из-за GIL
threads = []
for _ in range(4):
t = threading.Thread(target=cpu_bound_task, args=(100_000_000,))
threads.append(t)
t.start()
for t in threads:
t.join() # Выполняется ПОСЛЕДОВАТЕЛЬНО, не параллельно
1. Многопроцессность (Multiprocessing) — лучший способ для CPU-bound задач
У каждого процесса свой Python интерпретатор и свой GIL. Это истинный параллелизм.
import multiprocessing
import time
def cpu_bound_task(n):
total = 0
for i in range(n):
total += i
return total
if __name__ == __main__: # ВАЖНО на Windows
n_workers = 4
tasks = [100_000_000] * n_workers
# Вариант 1: Pool
with multiprocessing.Pool(n_workers) as pool:
results = pool.map(cpu_bound_task, tasks)
print(f"Results: {sum(results)}")
# Ускорение: ~4x на 4 ядрах
Преимущества:
- Истинный параллелизм
- Использует все ядра процессора
- Изоляция процессов (безопасность)
Недостатки:
- Overhead на создание процесса (~50ms)
- Дорого передавать данные (pickling/unpickling)
- Сложнее отлаживать
2. Асинхронность (Asyncio) — для I/O-bound задач
Asyncio НЕ обходит GIL, но использует то, что I/O операции отпускают GIL.
import asyncio
import aiohttp
import time
async def fetch_url(session, url):
"""GIL отпускается при I/O операции"""
async with session.get(url) as response:
return await response.text() # GIL освобождается тут
async def main():
urls = [https://example.com] * 100
async with aiohttp.ClientSession() as session:
# Все 100 запросов работают "одновременно"
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
# Последовательно: 100 * 1 сек = 100 сек
# С asyncio: ~1 сек (все запросы параллельны)
start = time.time()
results = asyncio.run(main())
print(f"Time: {time.time() - start}s")
Когда использовать:
- Сетевые запросы (HTTP, WebSocket)
- Работа с БД
- Файловые операции
3. Threading для I/O-bound задач
Треды отпускают GIL при I/O операциях, что позволяет другим тредам выполняться.
import threading
import requests
import time
def fetch_url(url, results, index):
response = requests.get(url) # GIL отпускается
results[index] = len(response.text)
urls = [https://example.com] * 10
results = [None] * len(urls)
threads = []
start = time.time()
for i, url in enumerate(urls):
t = threading.Thread(target=fetch_url, args=(url, results, i))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Time: {time.time() - start}s") # ~2 сек вместо 10
Vs Asyncio:
- Проще писать
- Меньше boilerplate
- Но менее эффективно (thread overhead)
4. Процессы для Data Science задач
Для работы с NumPy/Pandas многопроцессность особенно полезна:
import multiprocessing
import numpy as np
import pandas as pd
def process_chunk(chunk):
"""NumPy/Pandas операции работают в отдельном процессе"""
return chunk.sum() * 2
def parallel_dataframe_processing(df, n_workers=4):
chunks = np.array_split(df, n_workers)
with multiprocessing.Pool(n_workers) as pool:
results = pool.map(process_chunk, chunks)
return sum(results)
# Пример
df = pd.DataFrame({value: range(1_000_000)})
result = parallel_dataframe_processing(df)
print(result)
5. ProcessPoolExecutor vs ThreadPoolExecutor
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
# CPU-bound: ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_task, data))
# I/O-bound: ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(io_task, urls))
6. Numpy/Pandas операции обходят GIL
Много операций в NumPy и Pandas написаны на C и отпускают GIL:
import numpy as np
import threading
import time
def numpy_work():
# GIL отпускается для этого на всю длительность
arr = np.random.randn(10_000_000)
return np.sum(arr ** 2)
# Это МОЖЕТ работать параллельно благодаря отпусканию GIL
threads = [threading.Thread(target=numpy_work) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("NumPy часто работает параллельно несмотря на GIL")
7. Будущее: PEP 703 (No-GIL Python)
Python 3.13+ имеет experimental режим без GIL:
# Компилировать CPython без GIL
python --disable-gil
Это обещает истинный многопоточный параллелизм без переписывания кода.
Практическая стратегия
| Тип задачи | Метод | Скорость | Сложность |
|---|---|---|---|
| CPU-bound (ML, обработка данных) | multiprocessing.Pool | Отличная | Средняя |
| I/O-bound (API, БД) | asyncio | Отличная | Низкая |
| I/O-bound (простые) | ThreadPoolExecutor | Хорошая | Низкая |
| Смешанные | multiprocessing для CPU + asyncio для I/O | Отличная | Высокая |
| NumPy/Pandas | Threading (они отпускают GIL) | Хорошая | Низкая |
Примеры бенчмарков
# CPU-bound: 100M итераций
# Последовательно: 10 сек
# Threading: 10 сек (GIL)
# Multiprocessing: 2.5 сек (4 ядра)
# I/O-bound: 100 HTTP запросов
# Последовательно: 50 сек
# Threading: 5 сек
# Asyncio: 1.5 сек
Заключение
- Для CPU-bound (ML, математика): используй
multiprocessing - Для I/O-bound (HTTP, БД): используй
asyncio - Для простого I/O:
ThreadPoolExecutor - NumPy/Pandas: threading часто работает хорошо благодаря отпусканию GIL
- Профилируй! Не угадывай — измеряй с помощью
cProfileилиtimeit
GIL — ограничение, но есть много способов с ним работать эффективно.