← Назад к вопросам
Почему для вычислительных операций в Python не подходит многопоточность?
1.0 Junior🔥 161 комментариев
#Асинхронность и многопоточность
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
GIL (Global Interpreter Lock) и многопоточность в Python
Это один из самых важных вопросов в Python, касающихся производительности. Ответ кроется в архитектурной особенности CPython — Global Interpreter Lock (GIL).
Что такое GIL?
GIL — это мьютекс (взаимное исключение), который защищает доступ к объектам в памяти CPython. Правило простое:
В любой момент времени только ОДИН поток может выполнять Python код.
Даже если у вас есть многоядерный процессор, только один поток может работать с байт-кодом Python.
import threading
import time
def cpu_bound_work(n):
"""Вычислительная операция, привязанная к CPU"""
result = 0
for i in range(n):
result += i ** 2
return result
# Однопоточное выполнение
start = time.time()
for _ in range(4):
cpu_bound_work(50_000_000)
time_single = time.time() - start
print(f"Однопоточное: {time_single:.2f} сек") # ~4.2 сек
# Четырёхпоточное выполнение
start = time.time()
threads = []
for _ in range(4):
t = threading.Thread(target=cpu_bound_work, args=(50_000_000,))
threads.append(t)
t.start()
for t in threads:
t.join()
time_multi = time.time() - start
print(f"Четырёхпоточное: {time_multi:.2f} сек") # ~4.5 сек! (МЕДЛЕННЕЕ!)
print(f"Ускорение: {time_single / time_multi:.2f}x") # 0.93x (замедление!)
Почему это происходит?
1. Context Switching (переключение контекста)
# GIL вынужден переключаться между потоками
# Даже на многоядерном процессоре
thread_1: [Python code] -> [release GIL] -> [waiting]
^
|
(context switch)
|
v
thread_2: [acquire GIL] -> [Python code] -> [release GIL]
# Context switching стоит дорого:
# - Сохранение состояния процессора
# - Загрузка состояния другого потока
# - Инвалидация кэша процессора
2. GIL конкурирует за процессорное время
Даже если потоки работают на разных ядрах, GIL не позволяет одновременное выполнение:
from threading import Thread, Lock
import time
# Симуляция работы GIL
class GIL:
def __init__(self):
self.owner = None
self.lock = Lock()
def acquire(self, thread_id):
with self.lock:
self.owner = thread_id
def release(self):
self.owner = None
gil = GIL()
def worker(thread_id):
for i in range(100):
gil.acquire(thread_id)
# Выполнение Python кода
sum(range(10000))
gil.release()
# Если GIL занят другим потоком, текущий ждёт
start = time.time()
threads = [Thread(target=worker, args=(i,)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Время с 4 потоками: {time.time() - start:.2f} сек")
Визуальное объяснение
# На ОДНОПОТОЧНОМ процессоре
Core 0: [Thread 1] -> [GIL switch] -> [Thread 2] -> [GIL switch] -> [Thread 1]
Медленно: потоки сериализуются
# На МНОГОЯДЕРНОМ процессоре (ошибочное предположение)
Core 0: [Thread 1] ################
Core 1: [Thread 2] ################
Core 2: [idle]
Core 3: [idle]
# РЕАЛЬНОСТЬ с GIL
Core 0: [Thread 1] #### [switch] [Thread 2] #### [switch] [Thread 1]
Core 1: [idle - GIL занят потоком на Core 0]
Core 2: [idle - GIL занят потоком на Core 0]
Core 3: [idle - GIL занят потоком на Core 0]
Все потоки борются за ОДИН GIL, только один может выполняться
Практический пример: CPU-bound vs I/O-bound
I/O-bound операции (многопоточность РАБОТАЕТ!)
import threading
import requests
import time
def fetch_url(url):
"""I/O-bound операция (сетевой запрос)"""
try:
response = requests.get(url, timeout=5)
return response.status_code
except:
return None
# Однопоточное
start = time.time()
urls = [
'https://example.com',
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/2',
]
for url in urls:
fetch_url(url)
time_single = time.time() - start
print(f"Однопоточное: {time_single:.2f} сек") # ~8 сек
# Четырёхпоточное
start = time.time()
threads = []
for url in urls:
t = threading.Thread(target=fetch_url, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
time_multi = time.time() - start
print(f"Четырёхпоточное: {time_multi:.2f} сек") # ~2 сек (БЫСТРЕЕ!)
print(f"Ускорение: {time_single / time_multi:.2f}x") # 4x
# Почему? Пока один поток ждёт ответа от сервера (I/O),
# другие потоки могут выполняться. GIL отпускается при I/O.
CPU-bound операции (многопоточность НЕ работает)
import threading
import time
def cpu_work(n):
"""CPU-bound операция"""
result = 0
for i in range(n):
result += i ** 2
return result
# GIL НЕ отпускается, потому что это чистые вычисления в Python
# Потоки всё время конкурируют за GIL
Решения для CPU-bound операций
1. multiprocessing (нужные правильный выход)
from multiprocessing import Pool, cpu_count
import time
def cpu_work(n):
result = 0
for i in range(n):
result += i ** 2
return result
# multiprocessing создаёт отдельные процессы, каждый с собственным GIL
if __name__ == '__main__':
start = time.time()
# Используем все доступные процессоры
with Pool(cpu_count()) as pool:
results = pool.map(cpu_work, [50_000_000] * 4)
time_multi = time.time() - start
print(f"Multiprocessing: {time_multi:.2f} сек") # ~1.2 сек (4x ускорение!)
2. asyncio (для асинхронных операций)
import asyncio
import aiohttp
async def fetch_urls():
"""Асинхронное выполнение I/O операций"""
async with aiohttp.ClientSession() as session:
tasks = [
session.get('https://example.com'),
session.get('https://example.com'),
session.get('https://example.com'),
session.get('https://example.com'),
]
responses = await asyncio.gather(*tasks)
return [r.status for r in responses]
# asyncio работает в одном потоке, но эффективнее благодаря event loop
# Лучше для I/O-bound операций
3. Расширения на C (NumPy, Cython)
import numpy as np
import time
from multiprocessing import Pool
# NumPy отпускает GIL при выполнении операций на C уровне
arr = np.arange(50_000_000)
start = time.time()
for _ in range(4):
result = np.sum(arr ** 2)
print(f"NumPy: {time.time() - start:.2f} сек") # ~0.2 сек
# NumPy автоматически распределяет работу на несколько потоков
# и отпускает GIL, потому что вычисления происходят на C уровне
4. Cython
# compute.pyx
def cpu_work_cython(int n):
cdef int result = 0
cdef int i
for i in range(n):
result += i * i
return result
# После компиляции можно использовать `nogil` для отпуска GIL
cdef int cpu_work_nogil(int n) nogil: # nogil позволяет отпустить GIL
cdef int result = 0
cdef int i
for i in range(n):
result += i * i
return result
Когда GIL отпускается?
import threading
import time
# GIL отпускается при:
# 1. I/O операциях (файлы, сеть, БД)
# 2. Операциях в расширениях на C (NumPy, Pandas, Pillow)
# 3. Использовании time.sleep()
class GILBehavior:
@staticmethod
def io_operation():
"""GIL отпускается"""
with open('file.txt', 'r') as f:
data = f.read()
# Во время чтения файла другие потоки могут выполняться
@staticmethod
def cpu_operation():
"""GIL НЕ отпускается"""
result = sum(i ** 2 for i in range(10_000_000))
# Только этот поток может выполняться
@staticmethod
def sleep():
"""GIL отпускается"""
time.sleep(1)
# Во время sleep другие потоки могут выполняться
Резюме
| Сценарий | Многопоточность | Рекомендация |
|---|---|---|
| CPU-bound | Не работает | multiprocessing или NumPy/Cython |
| I/O-bound (блокирующее) | Работает хорошо | threading |
| I/O-bound (асинхронное) | Работает отлично | asyncio |
| Смешанное | Зависит | multiprocessing + asyncio |
Ключевые выводы
- GIL существует — защищает внутренние структуры данных CPython
- Для CPU-bound операций многопоточность бесполезна — используй multiprocessing
- Для I/O-bound операций многопоточность работает — потоки могут выполняться, пока один ждёт I/O
- NumPy и другие расширения отпускают GIL — эффективны для параллельных вычислений
- asyncio — правильный выбор для I/O — асинхронное выполнение в одном потоке
- Python 3.13+ улучшает ситуацию — экспериментальная поддержка "free-threaded" режима
Выбор инструмента зависит от типа вашей задачи!