Потоки, процессы, async и GIL
Главный практический выбор: потоки, процессы или async — и при чём тут GIL.
GIL (Global Interpreter Lock) — глобальная блокировка интерпретатора CPython, из-за которой в один момент времени Python-байткод исполняет только один поток.
Три инструмента конкурентности
| Инструмент | Параллелизм | Лучше всего для |
asyncio | конкурентность в одном потоке | много I/O-bound задач (тысячи соединений) |
threading | несколько потоков, но GIL | I/O-bound, особенно с блокирующими библиотеками |
multiprocessing | настоящий параллелизм на ядрах | CPU-bound задачи (вычисления) |
Что такое GIL и почему он мешает
GIL — это мьютекс, который позволяет исполнять Python-байткод только одному потоку за раз. Он упрощает управление памятью в CPython, но имеет важное следствие: несколько потоков не ускоряют чистые вычисления — они по очереди берут GIL, а реального параллелизма нет.
Но GIL отпускается во время блокирующих операций ввода-вывода (ожидание сети, диска). Поэтому для I/O-bound задач потоки всё-таки полезны: пока один поток ждёт ответа сети (и держит GIL отпущенным), другой работает.
Как выбирать
- Много I/O, свой код управляет ожиданием →
asyncio: тысячи конкурентных операций в одном потоке, минимум накладных расходов. - I/O с блокирующими библиотеками, которые не умеют async →
threading: потоки перекроют ожидания, 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 даёт настоящий параллелизм ценой накладных расходов на обмен данными.