GIL и threading: когда потоки помогают
Python имеет потоки через модуль threading. Но есть особенность - GIL (Global Interpreter Lock), которая делает Python-потоки нестандартными по сравнению с Go или C++. Понимание GIL критично для правильного выбора между threading, asyncio и multiprocessing. В этом уроке - что такое GIL, как threading работает в Python и когда он полезен.
Что такое GIL
GIL это блокировка интерпретатора CPython. Гарантия: в каждый момент времени только один поток исполняет Python байт-код. Это касается стандартной реализации CPython (другие - Jython, IronPython - без GIL).
import threading
def cpu_task():
sum(x ** 2 for x in range(10_000_000))
# Два потока
t1 = threading.Thread(target=cpu_task)
t2 = threading.Thread(target=cpu_task)
import time
start = time.perf_counter()
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Parallel: {time.perf_counter() - start:.2f}s")
# Последовательно
start = time.perf_counter()
cpu_task()
cpu_task()
print(f"Sequential: {time.perf_counter() - start:.2f}s")
Результат: время примерно одинаковое! Из-за GIL потоки не выполняются параллельно для CPU-bound задач.
Зачем GIL нужен
GIL упрощает:
- Reference counting (механизм управления памятью CPython)
- Атомарность многих операций
- Интеграцию с C-расширениями (большинство C-кода не thread-safe сам по себе)
Цена - ограничение параллелизма. Это компромисс из дизайна CPython 1990-х. Альтернативы (без GIL) обсуждались десятилетиями, но требуют переписать значительную часть Python.
GIL отдаётся для IO
Хорошая новость: GIL отдаётся при системных вызовах (read/write file, socket, sleep). Это делает threading полезным для IO-bound задач:
import threading
import requests
def fetch(url):
return requests.get(url).text
urls = ["url1", "url2", "url3"]
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
# Запросы выполняются параллельно - GIL не блокирует во время сетевых ожиданий
Поэтому threading подходит для IO-bound, но не для CPU-bound. Однако для IO-bound asyncio обычно лучше (легче по памяти, понятнее код).
threading - базовое использование
import threading
import time
def worker(name):
for i in range(3):
print(f"{name}: {i}")
time.sleep(0.5)
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
t1.join()
t2.join()
print("Done")
Thread(target=callable, args=tuple) создаёт поток. .start() запускает, .join() ждёт завершения.
Daemon threads
t = threading.Thread(target=worker, daemon=True)
t.start()
Daemon thread завершается автоматически когда главный поток завершается. Без daemon программа ждёт всех потоков. Полезно для background-задач (heartbeat, logging).
Lock - критические секции
import threading
counter = 0
lock = threading.Lock()
def increment(n):
global counter
for _ in range(n):
with lock:
counter += 1 # атомарно благодаря lock
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # 1_000_000 - гарантированно благодаря lock
Без lock counter += 1 не атомарен (read + add + write), потоки могут переписывать значения друг друга. Lock делает критическую секцию атомарной.
GIL гарантирует одну инструкцию байт-кода в момент времени. Но counter += 1 это три инструкции (LOAD, ADD, STORE), между которыми GIL может переключиться на другой поток. Ровно такая же гонка бывает в Go, там её ловят детектором гонок.
Без явных locks многопоточный код может иметь race conditions. Используй threading.Lock, RLock, Semaphore для синхронизации.
RLock - reentrant lock
import threading
lock = threading.RLock()
def outer():
with lock:
print("outer")
inner()
def inner():
with lock: # тот же поток может захватить снова
print("inner")
Обычный Lock - поток может захватить только один раз. RLock - тот же поток может re-acquire. Полезно когда функция с lock вызывает другую функцию тоже с lock.
Semaphore
import threading
# Максимум 3 одновременно скачивают
download_sem = threading.Semaphore(3)
def download(url):
with download_sem:
# реальная загрузка
...
Семафор как Lock, но с N разрешениями. Аналог asyncio.Semaphore, но для потоков.
Event - сигнал готовности
import threading
ready = threading.Event()
def wait_for_ready():
print("waiting")
ready.wait() # блокирует пока кто-то не set
print("ready!")
def set_ready():
import time
time.sleep(2)
ready.set()
t = threading.Thread(target=set_ready)
t.start()
wait_for_ready()
Event - простой mechanism: «случилось/не случилось». Можно ждать (wait), установить (set), сбросить (clear).
Queue - thread-safe очередь
import threading
import queue
q = queue.Queue()
def producer():
for i in range(5):
q.put(i)
q.put(None) # sentinel
def consumer():
while True:
item = q.get()
if item is None:
break
print(f"Got: {item}")
producer_t = threading.Thread(target=producer)
consumer_t = threading.Thread(target=consumer)
producer_t.start()
consumer_t.start()
producer_t.join()
consumer_t.join()
queue.Queue - thread-safe FIFO. Идеально для producer/consumer паттерна между потоками. Также есть LifoQueue и PriorityQueue.
ThreadPoolExecutor - пул потоков
from concurrent.futures import ThreadPoolExecutor
def fetch(url):
return f"data from {url}"
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch, urls))
ThreadPoolExecutor управляет пулом, переиспользует потоки. Удобнее ручного создания потоков для batch-задач.
submit даёт Future объект:
with ThreadPoolExecutor() as executor:
futures = [executor.submit(fetch, url) for url in urls]
for future in futures:
print(future.result()) # блокирует до готовности
concurrent.futures.as_completed(futures) для получения по мере готовности (аналог asyncio.as_completed).
When threading vs asyncio vs multiprocessing
| Задача | Threading | asyncio | multiprocessing |
|---|---|---|---|
| IO-bound (HTTP, БД) | Хорошо | Лучше | Излишне |
| CPU-bound | Не помогает (GIL) | Не помогает | Лучше |
| Mixed IO + light CPU | Хорошо | Хорошо | Подходит |
| Тысячи concurrent | Тяжело (память на потоки) | Лучше всего | Тяжело |
| Простота кода | Сложно (locks, race) | Сложно (cooperative) | Проще |
Правило:
- Чистый IO-bound: asyncio (если код можно сделать async)
- Mixed sync IO + хочется потоков: ThreadPoolExecutor
- CPU-bound: multiprocessing (урок 38)
Распространённые ошибки
1. Думать что GIL делает код thread-safe
# НЕБЕЗОПАСНО даже с GIL
counter = 0
def bad():
global counter
for _ in range(100_000):
counter += 1 # race condition между потоками
Используй Lock, atomic types или другие mechanisms синхронизации.
2. Использовать threading для CPU-bound
# Не даёт прироста из-за GIL
threads = [threading.Thread(target=heavy_calc) for _ in range(10)]
Используй multiprocessing для CPU-bound.
3. Забыть join()
t = threading.Thread(target=work)
t.start()
# программа завершается до окончания work
Без join() главный поток может завершиться раньше worker'ов. Или используй daemon=True если нужно именно прекратить с main.
4. Не обрабатывать exceptions в потоке
def task():
raise Exception("boom")
t = threading.Thread(target=task)
t.start()
t.join()
# исключение НЕ пробрасывается в главный поток
Исключения в потоках теряются по умолчанию. Используй try/except внутри thread-функции, или ThreadPoolExecutor который сохраняет exceptions в Future.
GIL free-threaded build (Python 3.13+)
В Python 3.13 появился experimental free-threaded build без GIL (PEP 703). Это всё ещё experiment, требует пересборки. В 2026 году осваивается как опция, но обычные дистрибутивы Python пока с GIL.
В будущем (5+ лет) GIL может стать опциональным или удалиться. До тех пор - живём с ограничениями.
Сравнение с Go
Go не имеет GIL - горутины параллельно используют все CPU. Это даёт настоящую параллельность для CPU-bound задач без обходных путей:
func cpuTask() {
sum := 0
for i := 0; i < 10_000_000; i++ {
sum += i * i
}
}
go cpuTask()
go cpuTask()
// Реально параллельно на разных CPU
В Python для этого нужен multiprocessing. Go в этом аспекте проще для concurrent CPU-bound work.
Мини-задание
- Параллельные HTTP-запросы:
import threading
import requests
import time
def fetch(url):
print(f"Start {url}")
response = requests.get(url)
print(f"Done {url}")
return response.text[:50]
urls = ["https://example.com", "https://example.com", "https://example.com"]
start = time.perf_counter()
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Done in {time.perf_counter() - start:.2f}s")
- Lock для безопасного счётчика:
import threading
counter = 0
lock = threading.Lock()
def increment(n):
global counter
for _ in range(n):
with lock:
counter += 1
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Counter: {counter}") # 500_000 - правильно благодаря Lock
- ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def task(n):
time.sleep(1)
return n * 2
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(task, i) for i in range(5)]
for future in as_completed(futures):
print(future.result())
Что дальше
Освоили threading и GIL. В следующем уроке (последнем в Модуле 7) - multiprocessing: настоящая параллельность через отдельные процессы, обход GIL для CPU-bound задач.