Как связаны GIL, многопоточность и вытесняющая многозадачность?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
GIL, многопоточность и вытесняющая многозадачность
Это одна из самых сложных концепций в Python. GIL (Global Interpreter Lock) ограничивает параллелизм и требует понимания разницы между параллелизмом и параллельностью.
Что такое GIL?
GIL — это мьютекс (взаимное исключение), которое защищает доступ к Python объектам в CPython. Только один поток может выполнять Python код в один момент времени, даже если у системы несколько ядер.
# ПЛОХО: многопоточность не поможет для CPU-bound задач
import threading
import time
def cpu_bound_task():
total = 0
for i in range(100_000_000):
total += i
return total
# Однопоточное выполнение
start = time.time()
cpu_bound_task()
print(f"Single thread: {time.time() - start:.2f}s") # ~5 сек
# Двухпоточное выполнение
start = time.time()
t1 = threading.Thread(target=cpu_bound_task)
t2 = threading.Thread(target=cpu_bound_task)
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Two threads: {time.time() - start:.2f}s") # ~10 сек (медленнее!)
# GIL не позволяет параллельное выполнение
Почему существует GIL?
- Упрощение реализации — не нужны блокировки для каждого объекта
- Безопасность памяти — автоматическое управление памятью (garbage collection)
- Совместимость с C расширениями — многие C библиотеки не потокобезопасны
// Внутри CPython (упрощённо)
struct PyObject {
int refcount; // Счётчик ссылок
PyTypeObject *type;
};
// GIL защищает все операции с refcount
// Без GIL пришлось бы использовать сложные атомарные операции
Вытесняющая многозадачность (Preemptive Multitasking)
Python использует вытесняющую многозадачность: операционная система переключает потоки, а не сам код. Это происходит каждые ~5 миллисекунд (настраивается через sys.setswitchinterval()).
import threading
import sys
# Интервал переключения потоков (по умолчанию 5ms)
print(f"Switch interval: {sys.getswitchinterval()}s")
# Изменить интервал (внимание: частые переключения = больше overhead)
sys.setswitchinterval(0.001) # 1ms
def task(name):
for i in range(3):
print(f"{name}: {i}")
t1 = threading.Thread(target=task, args=("Thread-1",))
t2 = threading.Thread(target=task, args=("Thread-2",))
t1.start()
t2.start()
t1.join()
t2.join()
# Вывод показывает перемешивание выполнения (вытесняющая многозадачность)
# Thread-1: 0
# Thread-2: 0
# Thread-1: 1
# Thread-2: 1
# ...
Жизненный цикл GIL
Поток 1 GIL Поток 2
|
| (захватывает GIL)
|-----------> [GIL] <----|
| |
| (выполняет Python код) | (ждёт GIL)
| |
| ~5ms прошло |
| |
| (отпускает GIL) |
|-----------> [GIL] <----|
| |
| (ждёт GIL) | (выполняет Python код)
| |
| | ~5ms прошло
| |
| | (отпускает GIL)
I/O-bound vs CPU-bound задачи
I/O-bound (многопоточность работает)
import threading
import requests
import time
def fetch_data(url):
response = requests.get(url)
return len(response.content)
# Однопоточное
start = time.time()
for url in [url1, url2, url3]:
fetch_data(url)
print(f"Single thread: {time.time() - start:.2f}s") # ~3 сек (3 запроса * 1 сек)
# Многопоточное
start = time.time()
threads = []
for url in [url1, url2, url3]:
t = threading.Thread(target=fetch_data, args=(url,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"Three threads: {time.time() - start:.2f}s") # ~1 сек (параллельно!)
# Многопоточность помогает! Потому что:
# - Когда поток ждёт I/O, GIL отпускается
# - Другие потоки могут работать одновременно
Почему это работает:
Когда поток выполняет I/O операцию (например, requests.get()), он вызывает системный вызов. На время этого вызова GIL отпускается, позволяя другим потокам выполняться.
CPU-bound (многопоточность не помогает)
import threading
import time
def cpu_task(n):
total = 0
for i in range(n):
total += i
return total
n = 100_000_000
# Однопоточное
start = time.time()
cpu_task(n)
print(f"Single thread: {time.time() - start:.2f}s") # 5 сек
# Двухпоточное
start = time.time()
t1 = threading.Thread(target=cpu_task, args=(n,))
t2 = threading.Thread(target=cpu_task, args=(n,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Two threads: {time.time() - start:.2f}s") # 10 сек! (медленнее из-за overhead)
# GIL не отпускается при CPU вычислениях
# Потоки просто конкурируют за GIL
# Результат: последовательное выполнение + overhead переключения
Решение 1: multiprocessing (для CPU-bound)
from multiprocessing import Pool
import time
def cpu_task(n):
total = 0
for i in range(n):
total += i
return total
n = 100_000_000
# Многопроцессность (разные процессы = разные интерпретаторы = разные GIL)
start = time.time()
with Pool(processes=2) as pool:
results = pool.map(cpu_task, [n, n])
print(f"Two processes: {time.time() - start:.2f}s") # ~5 сек (параллельно!)
# Каждый процесс имеет свой GIL
# Процесс 1 выполняет на CPU 0
# Процесс 2 выполняет на CPU 1 одновременно
Решение 2: asyncio (для I/O-bound)
import asyncio
import aiohttp
import time
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in [url1, url2, url3]]
results = await asyncio.gather(*tasks)
return results
# asyncio — это кооперативная многозадачность (не вытесняющая)
# Только один поток выполняется в один момент
# Но переключение происходит явно (await), не прерывая GIL
start = time.time()
asyncio.run(main())
print(f"Asyncio: {time.time() - start:.2f}s") # ~1 сек (параллельно)
Решение 3: Cython или ctypes (отпустить GIL)
# Cython пример
# cython_module.pyx
cdef extern from "math.h":
double sqrt(double x) nogil
def compute_sqrt(double n):
cdef double result
with nogil: # Выпустить GIL на время вычисления
result = sqrt(n)
return result
Инструменты для профилирования GIL
import threading
import time
def monitor_gil():
"""Показать, как часто потоки переключаются"""
import sys
switch_count = 0
last_frame = None
def trace_calls(frame, event, arg):
nonlocal switch_count, last_frame
if event == 'return':
if last_frame is not None and last_frame[0] != frame[0]:
switch_count += 1
last_frame = frame
return trace_calls
sys.settrace(trace_calls)
time.sleep(1)
sys.settrace(None)
print(f"GIL switches in 1 second: {switch_count}")
monitor_gil()
Таблица: Когда использовать что
| Задача | Инструмент | Почему |
|---|---|---|
| I/O-bound (сеть, файлы) | threading | GIL отпускается при I/O, параллелизм работает |
| I/O-bound асинхронное | asyncio | Более эффективно, меньше overhead |
| CPU-bound | multiprocessing | Разные процессы, разные GIL |
| CPU-bound (NumPy, SciPy) | threading | Эти библиотеки сами отпускают GIL |
| Смешанное | комбинация | Например, asyncio + multiprocessing |
Будущее: PEP 703 (No-GIL Python)
# Python 3.13+ возможно будет без GIL
# Это позволит true параллелизм даже для CPU-bound
# Но пока используй
# - multiprocessing для CPU-bound
# - threading/asyncio для I/O-bound
Краткое резюме
- GIL — это мьютекс, позволяющий только одному потоку выполнять Python код
- Вытесняющая многозадачность — ОС переключает потоки каждые ~5ms
- I/O-bound задачи: GIL отпускается, threading работает эффективно
- CPU-bound задачи: GIL не отпускается, используй multiprocessing
- asyncio — кооперативная многозадачность, часто лучше threading для I/O
Понимание GIL критично для оптимизации производительности Python приложений.