Почему несколько потоков не могут выполнять задачи параллельно в Python?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
# Почему несколько потоков не могут выполнять задачи параллельно в Python?
Это вызвано Global Interpreter Lock (GIL) — механизмом в CPython (официальной реализации Python), который позволяет только одному потоку выполнять Python байт-код в один момент времени. Это одна из наиболее обсуждаемых особенностей Python.
Что такое GIL (Global Interpreter Lock)?
GIL — это мьютекс (взаимное исключение), который защищает доступ к объектам в Python. Только один поток может одновременно выполнять Python код:
import threading
import time
def worker(thread_id):
"""Функция, которую выполняет поток"""
print(f"Поток {thread_id} начал работу")
for i in range(100_000_000):
pass # Вычисления
print(f"Поток {thread_id} завершил работу")
# Синхронное выполнение
start = time.time()
worker(1)
worker(2)
print(f"Синхронно: {time.time() - start:.2f} сек")
# С потоками (работает медленнее из-за GIL!)
start = time.time()
threads = [
threading.Thread(target=worker, args=(1,)),
threading.Thread(target=worker, args=(2,))
]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"С потоками: {time.time() - start:.2f} сек")
# Результат:
# Синхронно: ~2.5 сек
# С потоками: ~2.5-3.0 сек (медленнее из-за overhead GIL!)
Почему GIL существует?
GIL был введен в CPython для упрощения управления памятью. Python использует подсчет ссылок для управления памятью:
import sys
obj = []
print(sys.getrefcount(obj)) # Количество ссылок на объект
def process(o):
print(sys.getrefcount(o))
process(obj) # Счетчик ссылок растет и уменьшается
Подсчет ссылок не потокобезопасен без синхронизации:
# Проблема без GIL:
# Поток 1: читает счетчик ссылок (8)
# Поток 2: читает счетчик ссылок (8)
# Поток 1: уменьшает счетчик (7)
# Поток 2: уменьшает счетчик (7)
# Результат: счетчик должен быть 6, но он 7!
# Объект не удалилась, когда должна была → утечка памяти
# С GIL:
# Только один поток может менять счетчик в одно время
# Операции атомарны
Практический пример проблемы
import threading
import time
# CPU-bound задача (вычисления)
def cpu_bound_task(n):
"""Выполняет вычисления — CPU-bound"""
total = 0
for i in range(n):
total += i ** 2
return total
# Одноядерное выполнение
start = time.time()
result1 = cpu_bound_task(100_000_000)
result2 = cpu_bound_task(100_000_000)
single_core_time = time.time() - start
print(f"Один поток: {single_core_time:.2f} сек")
# Многопоточное выполнение
start = time.time()
t1 = threading.Thread(target=cpu_bound_task, args=(100_000_000,))
t2 = threading.Thread(target=cpu_bound_task, args=(100_000_000,))
t1.start()
t2.start()
t1.join()
t2.join()
multi_thread_time = time.time() - start
print(f"Два потока: {multi_thread_time:.2f} сек (медленнее!)")
print(f"Замедление: {multi_thread_time / single_core_time:.1f}x")
# Результат:
# Один поток: ~2.5 сек
# Два потока: ~2.7-3.0 сек (медленнее из-за GIL!)
# Замедление: 1.1x
Когда потоки работают нормально: I/O-bound задачи
Потоки хорошо работают для операций, которые блокируют (ввод-вывод):
import threading
import requests
import time
# I/O-bound задача (сетевые запросы)
def fetch_url(url):
"""Получить контент с URL — I/O-bound"""
response = requests.get(url)
return len(response.content)
urls = [
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/2"
]
# Последовательное выполнение
start = time.time()
for url in urls:
fetch_url(url)
sequential_time = time.time() - start
print(f"Последовательно: {sequential_time:.2f} сек")
# Многопоточное выполнение
start = time.time()
threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
multithreaded_time = time.time() - start
print(f"Многопоточно: {multithreaded_time:.2f} сек (быстрее!)")
print(f"Ускорение: {sequential_time / multithreaded_time:.1f}x")
# Результат:
# Последовательно: 6.1 сек (2 сек * 3 запроса)
# Многопоточно: 2.1 сек (примерно одновременно)
# Ускорение: 3x
Решение 1: Используйте multiprocessing для CPU-bound
import multiprocessing
import time
def cpu_bound_task(n):
total = 0
for i in range(n):
total += i ** 2
return total
if __name__ == '__main__':
# Процессы имеют свои GIL, поэтому работают параллельно
start = time.time()
with multiprocessing.Pool(2) as pool:
results = pool.map(cpu_bound_task, [100_000_000, 100_000_000])
parallel_time = time.time() - start
print(f"Два процесса: {parallel_time:.2f} сек (параллелизм!)")
# На двухядерной машине это примерно в 2 раза быстрее
# Каждый процесс имеет свой GIL
Решение 2: Используйте asyncio для I/O-bound
import asyncio
import aiohttp
import time
async def fetch_url_async(session, url):
"""Асинхронный запрос без потоков"""
async with session.get(url) as response:
return len(await response.content.read())
async def main():
urls = [
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/2"
]
# Асинхронное выполнение (не требует потоков)
async with aiohttp.ClientSession() as session:
tasks = [fetch_url_async(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
# Запуск
start = time.time()
asyncio.run(main())
async_time = time.time() - start
print(f"Asyncio: {async_time:.2f} сек")
# Результат: ~2.1 сек (как многопоточно, но без GIL)
Решение 3: C расширения и numba
import numpy as np
from numba import jit
import threading
import time
# Обычный Python — медленно
def python_calculation(n):
result = 0
for i in range(n):
result += i ** 2
return result
# JIT компиляция с Numba — быстро и может использовать параллелизм
@jit(nopython=True, parallel=True)
def numba_calculation(n):
result = 0
for i in range(n):
result += i ** 2
return result
start = time.time()
python_calculation(100_000_000)
print(f"Python: {time.time() - start:.2f} сек")
start = time.time()
numba_calculation(100_000_000)
print(f"Numba (параллельный): {time.time() - start:.2f} сек")
# Результат: Numba может быть в 2-4x быстрее
Решение 4: Используйте альтернативные реализации Python
# PyPy — имеет GIL, но обычно быстрее для CPU-bound задач
# Jython — работает на JVM, нет GIL (но медленнее для чистого Python)
# IronPython — работает на .NET, нет GIL
# Cython — скомпилировать Python в C, убрать GIL
# cython
# def cpu_bound_task(n):
# cdef int total = 0
# cdef int i
# for i in range(n):
# total += i ** 2
# return total
Выбор инструмента по типу задачи
import threading
import multiprocessing
import asyncio
# ❌ CPU-bound + многопоточность
# Медленнее, чем однопоточность из-за overhead GIL
def cpu_bound_with_threads():
threads = [threading.Thread(target=heavy_computation) for _ in range(4)]
# ...
# ✅ CPU-bound + многопроцессность
# Быстро, параллельное выполнение на нескольких ядрах
def cpu_bound_with_processes():
processes = [multiprocessing.Process(target=heavy_computation) for _ in range(4)]
# ...
# ✅ I/O-bound + многопоточность
# Хорошо, потоки блокируются на I/O
def io_bound_with_threads():
threads = [threading.Thread(target=fetch_data) for _ in range(10)]
# ...
# ✅ I/O-bound + asyncio
# Отлично, эффективнее потоков
async def io_bound_with_asyncio():
tasks = [fetch_data_async() for _ in range(10)]
await asyncio.gather(*tasks)
Будущее Python: PEP 703 (Remove GIL)
В октябре 2023 года была принята PEP 703 о удалении GIL из Python. Guido van Rossum лично возглавляет этот проект:
# В будущем Python 3.13+ можно будет скомпилировать без GIL
# python3.13 --disable-gil script.py
# Или с флагом при компиляции CPython
# Это позволит многопоточности работать как ожидается
# Многопоточность будет такой же эффективной как multiprocessing
Заключение
Несколько потоков не работают параллельно в Python из-за GIL, который защищает внутреннее состояние интерпретатора.
Правило:
- CPU-bound: используй
multiprocessing(разные процессы, разные GIL) - I/O-bound: используй
threading(потоки хороши) или лучшеasyncio(экономнее) - Будущее: Python 3.13+ будет иметь опцию удалить GIL
Понимание GIL и его ограничений — критический навык для Python-разработчика, работающего с производительностью.