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 делает Python thread-safe». Это неправда!

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

ЗадачаThreadingasynciomultiprocessing
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.

Мини-задание

  1. Параллельные 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")
  1. 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
  1. 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 задач.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.