Потоки, процессы, async и GIL

Главный практический выбор: потоки, процессы или async — и при чём тут GIL.

GIL (Global Interpreter Lock) — глобальная блокировка интерпретатора CPython, из-за которой в один момент времени Python-байткод исполняет только один поток.

Три инструмента конкурентности

ИнструментПараллелизмЛучше всего для
asyncioконкурентность в одном потокемного I/O-bound задач (тысячи соединений)
threadingнесколько потоков, но GILI/O-bound, особенно с блокирующими библиотеками
multiprocessingнастоящий параллелизм на ядрахCPU-bound задачи (вычисления)

Что такое GIL и почему он мешает

GIL — это мьютекс, который позволяет исполнять Python-байткод только одному потоку за раз. Он упрощает управление памятью в CPython, но имеет важное следствие: несколько потоков не ускоряют чистые вычисления — они по очереди берут GIL, а реального параллелизма нет.

Но GIL отпускается во время блокирующих операций ввода-вывода (ожидание сети, диска). Поэтому для I/O-bound задач потоки всё-таки полезны: пока один поток ждёт ответа сети (и держит GIL отпущенным), другой работает.

Как выбирать

  • Много I/O, свой код управляет ожиданиемasyncio: тысячи конкурентных операций в одном потоке, минимум накладных расходов.
  • I/O с блокирующими библиотеками, которые не умеют asyncthreading: потоки перекроют ожидания, GIL отпустится на время I/O.
  • Тяжёлые вычисления (CPU-bound)multiprocessing: каждый процесс — свой интерпретатор и свой GIL, поэтому ядра работают по-настоящему параллельно.

Почему процессы обходят GIL

Каждый процесс — это отдельный экземпляр интерпретатора Python со своей памятью и своим GIL. Восемь процессов на восьмиядерном CPU реально считают параллельно. Плата за это — изоляция памяти: данные между процессами нужно передавать через сериализацию (pickle), а это накладные расходы. Поэтому процессы выгодны для крупных вычислительных кусков, а не для мелких частых обменов.

Иллюстрация: модель ускорения

Прикинем, как три стратегии справятся с разными нагрузками. Возьмём 4 задачи; для I/O ожидания перекрываются, для CPU помогает число ядер.

def estimate(kind, n_tasks, per_task, cores):
    if kind == "io":
        # I/O: ожидания перекрываются (async/threading) -> ~ одна задача
        return per_task
    if kind == "cpu-process":
        # CPU на процессах: делим по ядрам
        import math
        return per_task * math.ceil(n_tasks / cores)
    if kind == "cpu-thread":
        # CPU на потоках: GIL не даёт параллелизма -> суммарно
        return per_task * n_tasks
    return per_task * n_tasks

print("I/O (async/threads):   ", estimate("io", 4, 2, 4), "ед.")
print("CPU на потоках (GIL):  ", estimate("cpu-thread", 4, 2, 4), "ед.")
print("CPU на процессах (4 ядра):", estimate("cpu-process", 4, 2, 4), "ед.")

Вывод:

I/O (async/threads):    2 ед.
CPU на потоках (GIL):   8 ед.
CPU на процессах (4 ядра): 2 ед.

Цифры условны, но вывод реален: на CPU-bound нагрузке потоки не дают выигрыша из-за GIL (8 единиц), а процессы используют ядра (2 единицы). На I/O-bound выигрывают и потоки, и async.

Итог

  • GIL не даёт нескольким потокам исполнять Python-байткод параллельно — но отпускается во время I/O.
  • Потоки и asyncio помогают I/O-bound задачам; процессы — CPU-bound.
  • Каждый процесс имеет свой GIL, поэтому multiprocessing даёт настоящий параллелизм ценой накладных расходов на обмен данными.
Проверьте себя
1. Почему несколько потоков не ускоряют чистые вычисления в CPython?
AПотоки в Python вообще не существуют
BGIL позволяет исполнять Python-байткод только одному потоку за раз
CВычисления нельзя поместить в функцию
DПотоки работают медленнее процессов всегда
2. Какой инструмент выбрать для тяжёлых CPU-bound вычислений на многоядерном процессоре?
Aasyncio
Bthreading
Cmultiprocessing
Dни один не поможет
3. Почему потоки всё же полезны для I/O-bound задач, несмотря на GIL?
AGIL отпускается во время блокирующих операций ввода-вывода
BGIL отключается на ночь
CПотоки игнорируют GIL полностью
DI/O-задачи не используют Python
Поддержать проект