Зачем GIL блокирует потоки?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Global Interpreter Lock (GIL) — Зачем он блокирует потоки?
GIL (Global Interpreter Lock) — это механизм в CPython (стандартной реализации Python), который позволяет только одному потоку выполнять Python код одновременно. Это звучит странно, но у этого есть веские причины.
Проблема: управление памятью в CPython
CPython использует reference counting (подсчет ссылок) для управления памятью:
import sys
obj = [1, 2, 3]
print(sys.getrefcount(obj)) # Количество ссылок на объект
obj2 = obj # +1 ссылка
print(sys.getrefcount(obj)) # Увеличилось на 1
del obj2 # -1 ссылка
print(sys.getrefcount(obj)) # Уменьшилось на 1
# Когда refcount == 0, объект удаляется из памяти
Когда refcount становится 0, объект автоматически удаляется из памяти.
Проблема с многопоточностью:
Если два потока одновременно работают с одним объектом, может произойти race condition:
# Два потока, один объект
shared_list = [1, 2, 3]
# Поток 1 читает refcount
refcount = 5 # Прочитано из памяти
# Поток 2 удаляет последнюю ссылку
# Но Поток 1 еще использует refcount!
# Поток 1 уменьшает refcount: 5 → 4
# Но refcount должен был быть 1, и объект должен был удалиться!
# Результат: утечка памяти или use-after-free
Решение: Global Interpreter Lock
GIL — это взаимное исключение (mutex), которое гарантирует, что только один поток может выполнять Python bytecode за раз:
Поток 1 GIL (mutex) Поток 2
│ │ │
└──→ захватить GIL ─→ │ ←─ ждет GIL ←──────┘
│ │
│ выполняет Python code (refcount безопасен)
│
└──→ освобождает GIL ─→ │ ←─ захватывает ─→ выполняет Python code
│
Гарантии GIL:
- Только один поток выполняет Python код
- refcount операции безопасны
- Не нужны дополнительные блокировки для каждого объекта
Пример: проблема без GIL
Представьте, что GIL отсутствует и два потока работают одновременно:
import threading
class BankAccount:
def __init__(self, balance):
self.balance = balance
def deposit(self, amount):
# Без GIL это может быть прервано между чтением и записью!
temp = self.balance # Прочитать
temp += amount # Увеличить
self.balance = temp # Записать
account = BankAccount(100)
def thread_func():
for _ in range(1000000):
account.deposit(1)
# Два потока добавляют по 1 млн раз
t1 = threading.Thread(target=thread_func)
t2 = threading.Thread(target=thread_func)
t1.start()
t2.start()
t1.join()
t2.join()
# Ожидаем: 100 + 2_000_000 = 2_000_100
print(account.balance) # Может быть например 1_234_567 (неправильно!)
Без GIL может произойти:
Поток 1 Поток 2
temp1 = 100 (читает balance)
temp2 = 100 (читает balance)
temp1 += 1 (становится 101)
temp2 += 1 (становится 101)
balance = 101 (записывает)
balance = 101 (записывает)
Результат: balance = 101 (вместо 102!)
Операция потеряна из-за race condition
GIL предотвращает это:
Поток 1 Поток 2
[захватил GIL]
temp1 = 100
temp1 += 1
balance = 101
[отпустил GIL]
[захватил GIL]
temp2 = 101
temp2 += 1
balance = 102
[отпустил GIL]
Результат: balance = 102 (правильно!)
Как GIL работает
CPython использует слабый GIL:
// Упрощённо
while (есть инструкции) {
// Каждые N инструкций проверить, хочет ли другой поток GIL
if (check_interval % инструкция == 0) {
// Отпустить GIL на время
// Позволить другому потоку выполниться
// Заново захватить GIL
}
// Выполнить инструкцию
выполнить_bytecode();
}
Каждые ~5 млн инструкций Python переключается контекст (примерно).
Практические последствия GIL
Многопоточность работает только для I/O:
import threading
import time
def cpu_bound_task():
"""CPU-интенсивная задача"""
total = 0
for i in range(100_000_000):
total += i
return total
def io_bound_task():
"""I/O-интенсивная задача"""
import requests
response = requests.get("https://api.example.com/data")
return response.json()
# CPU-bound с потоками — БЕЗ улучшения
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"2 потока (CPU): {time.time() - start:.2f}s") # ~10 сек
# CPU-bound без потоков — БЫСТРЕЕ!
start = time.time()
cpu_bound_task()
cpu_bound_task()
print(f"Без потоков (CPU): {time.time() - start:.2f}s") # ~10 сек (одинаково)
# I/O-bound с потоками — РАБОТАЕТ, потоки ждут друг друга
start = time.time()
t1 = threading.Thread(target=io_bound_task)
t2 = threading.Thread(target=io_bound_task)
t1.start()
t2.start()
t1.join()
t2.join()
print(f"2 потока (I/O): {time.time() - start:.2f}s") # ~1 сек (параллельно)
# I/O-bound без потоков
start = time.time()
io_bound_task()
io_bound_task()
print(f"Без потоков (I/O): {time.time() - start:.2f}s") # ~2 сек (последовательно)
Почему I/O работает? Когда поток ждет I/O (сеть, диск), он отпускает GIL:
# Внутри requests.get()
GIL.release() # Отпустить
perform_http_request() # Ждем ответ от сервера
GIL.acquire() # Захватить обратно
Решения для CPU-bound задач
1. Multiprocessing (отдельные процессы)
import multiprocessing
def cpu_task():
total = 0
for i in range(100_000_000):
total += i
return total
if __name__ == "__main__":
# Каждый процесс имеет свой Python интерпретатор и свой GIL
# Они работают в параллель на разных ядрах!
with multiprocessing.Pool(processes=2) as pool:
results = pool.map(cpu_task, [1, 2])
print(results)
2. Asyncio (асинхронное программирование)
import asyncio
async def fetch_data(url):
# Асинхронный код, работает с одним потоком
# но выполняет множество операций I/O одновременно
# await отпускает управление, другие корутины работают
result = await get_data_async(url)
return result
async def main():
# 10 запросов параллельно
results = await asyncio.gather(
*[fetch_data(f"https://api{i}.example.com") for i in range(10)]
)
return results
asyncio.run(main())
3. NumPy, Pandas, Cython (работают вне GIL)
import numpy as np
# NumPy операции работают на C уровне и отпускают GIL
arr = np.arange(1_000_000)
result = np.sum(arr) # Не заблокирована GIL
# Cython код может явно отпустить GIL
# cdef void fast_function() nogil:
# # Код здесь работает без GIL
4. Python 3.13+ (GIL можно отключить)
Создатели Python работают над тем, чтобы сделать GIL опциональным:
# Python 3.13+ может запуститься без GIL
python -X gil=0 script.py # Экспериментальная функция
Почему GIL всё ещё существует?
- Совместимость — много C расширений полагаются на GIL
- Производительность — однопоточный код работает быстрее
- Простота — не нужны блокировки для каждого объекта
- История — удалить GIL сложно без серьёзного переписания
Чек-лист для работы с GIL
Для I/O-bound (web, базы, файлы):
- ✅ threading работает
- ✅ asyncio работает (лучше)
- ✅ requests, httpx, aiohttp
Для CPU-bound (вычисления):
- ❌ threading НЕ работает
- ✅ multiprocessing работает
- ✅ NumPy, Pandas (работают вне GIL)
- ✅ asyncio НЕ работает (не подходит для CPU)
Заключение
GIL — это фундаментальная часть CPython, которая:
- Защищает from race conditions при управлении памятью
- Позволяет reference counting быть эффективным
- Делает однопоточный код быстрее
- Но блокирует многопоточность для CPU-bound задач
Понимание GIL критично для выбора правильной модели параллелизма в Python.