GIL в Python: почему threading не ускоряет вычисления
Вы распараллелили расчёт на четыре потока, а он стал работать не быстрее, а иногда медленнее. Разбираем, как GIL в CPython решает, кто исполняет байт-код, и когда потоки реально помогают.

Классический сюжет: скрипт считает хеши для миллиона строк за 40 секунд. Вы оборачиваете цикл в ThreadPoolExecutor на четыре потока, ждёте ускорения в 3–4 раза, запускаете — и получаете те же 40 секунд, а на некоторых машинах 45. CPU-профиль показывает загрузку одного ядра из восьми. Дело не в вашем коде и не в размере пула. Дело в GIL — механизме внутри интерфейса CPython, который решает, какой поток прямо сейчас исполняет байт-код.
Ниже — про то, как GIL устроен в CPython 3.12, почему на CPU-задачах потоки не дают линейного ускорения, где они всё-таки помогают, и что изменилось в 3.13 с экспериментальной сборкой без GIL.
Что такое GIL и что он на самом деле защищает
GIL (Global Interpreter Lock) — это мьютекс уровня интерпретатора. В любой момент времени байт-код Python исполняет ровно один поток одного процесса, независимо от числа ядер. Не два, не «по чуть-чуть от каждого» — один.
Защищает GIL внутренние структуры CPython, в первую очередь счётчики ссылок. Управление памятью в CPython построено на подсчёте ссылок: у каждого объекта есть поле с числом ссылающихся на него имён. Инкремент и декремент этого счётчика — не атомарные операции на уровне процессора. Если бы два потока меняли счётчик одного объекта без блокировки, вы получали бы утечки памяти и падения с segfault. GIL делает всю работу интерпретатора однопоточной и снимает эту проблему целиком — ценой параллелизма.
GIL не запрещает создавать потоки и не делает их бесполезными. Он запрещает двум потокам одновременно исполнять Python-байт-код. Разница между этими утверждениями и есть вся тема.
Почему это касается именно CPython
GIL — свойство конкретной реализации, а не языка. В CPython (тот интерпретатор, что скачивается с python.org) он есть. В Jython и IronPython его нет — там своя модель памяти. Когда говорят «в Python есть GIL», имеют в виду CPython, и почти все имеют в виду именно его.
Почему потоки не ускоряют вычисления
Возьмём CPU-bound задачу — считаем сумму факториалов в диапазоне. Однопоточная версия и многопоточная на CPython 3.12:
# CPython 3.12
import time
from concurrent.futures import ThreadPoolExecutor
from math import factorial
def heavy(n):
# CPU-bound: чистые вычисления, никакого ожидания
return sum(factorial(i) % 1000 for i in range(n))
tasks = [30000] * 4
start = time.perf_counter()
result = [heavy(n) for n in tasks]
print(f"однопоточно: {time.perf_counter() - start:.2f} c")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
result = list(pool.map(heavy, tasks))
print(f"4 потока: {time.perf_counter() - start:.2f} c")На четырёхъядерной машине оба варианта покажут примерно одинаковое время, а многопоточный нередко окажется чуть медленнее. Почему медленнее — а не просто «так же»: потоки постоянно перехватывают GIL друг у друга, и на переключение уходит лишнее время. Полезной параллельной работы при этом ноль — байт-код всё равно исполняется по одному потоку за раз.
Как именно происходит переключение
Начиная с CPython 3.2 GIL освобождается не по числу исполненных инструкций, а по интервалу времени — по умолчанию 5 миллисекунд. Текущее значение можно посмотреть и изменить:
# CPython 3.12
import sys
print(sys.getswitchinterval()) # 0.005 — секунды
sys.setswitchinterval(0.01) # реже переключатьсяКаждые ~5 мс поток, удерживающий GIL, проверяет запрос на освобождение и, если другие потоки ждут, отдаёт блокировку. На CPU-задаче это означает, что четыре потока просто передают друг другу право работать по кругу, добавляя накладные расходы на синхронизацию.
Где потоки реально помогают
Ключевое различие — I/O-bound против CPU-bound. Пока поток ждёт ответа от сети, диска или базы, ему нечего исполнять, и CPython отпускает GIL. В это время другой поток берёт блокировку и работает. Именно на ожидании потоки дают выигрыш.
Сравните: скачать 20 URL последовательно и в 8 потоков.
# CPython 3.12, нужен пакет requests
import time
from concurrent.futures import ThreadPoolExecutor
import requests
urls = ["https://httpbin.org/delay/1"] * 20
def fetch(url):
# почти всё время поток ждёт ответа — GIL в это время свободен
return requests.get(url, timeout=10).status_code
start = time.perf_counter()
results = [fetch(u) for u in urls]
print(f"последовательно: {time.perf_counter() - start:.1f} c")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(fetch, urls))
print(f"8 потоков: {time.perf_counter() - start:.1f} c")Последовательный вариант отработает около 20 секунд (20 запросов по секунде задержки), многопоточный — примерно 3 секунды. Здесь GIL не мешает: пока восемь потоков висят в ожидании сокета, интерпретатору всё равно нечего исполнять параллельно.
Отдельный случай — C-расширения
Функции из NumPy, pandas, работа с файлами через open(), обращения к базе через драйвер на C — многие такие операции отпускают GIL на время работы нативного кода. Поэтому numpy-вычисления в потоках иногда всё-таки ускоряются: тяжёлая арифметика идёт в C без удержания блокировки. Проверять это надо замером на своей задаче — не все расширения ведут себя одинаково.
Что выбрать под задачу
| Тип нагрузки | Инструмент | Почему |
|---|---|---|
| Сеть, диск, ожидание БД | threading, ThreadPoolExecutor | GIL отпускается на время ожидания, потоки дешевле процессов |
| Много одновременных сетевых запросов | asyncio | Один поток, тысячи корутин, без накладных расходов на потоки |
| Чистые вычисления на Python | multiprocessing, ProcessPoolExecutor | Отдельные процессы — у каждого свой GIL и своё ядро |
| Тяжёлая арифметика через NumPy/C | threading или векторизация | Расширение может отпускать GIL; замерить перед выбором |
Простое правило: если задача упирается в процессор — берите процессы. Если задача упирается в ожидание — берите потоки или asyncio.
Процессы вместо потоков для CPU-задач
Тот же расчёт факториалов, но через процессы:
# CPython 3.12
import time
from concurrent.futures import ProcessPoolExecutor
from math import factorial
def heavy(n):
return sum(factorial(i) % 1000 for i in range(n))
if __name__ == "__main__": # обязательно для ProcessPoolExecutor
tasks = [30000] * 4
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
result = list(pool.map(heavy, tasks))
print(f"4 процесса: {time.perf_counter() - start:.2f} c")Здесь на четырёхъядерной машине вы увидите ускорение, близкое к 3–4 разам. Каждый процесс — отдельный интерпретатор со своим GIL, они работают на разных ядрах по-настоящему параллельно. Плата за это: запуск процесса дороже потока, а данные между процессами передаются через сериализацию (pickle), поэтому мелкие задачи с большим объёмом входных данных могут проиграть на накладных расходах.
Что изменилось в Python 3.13
В CPython 3.13 появилась экспериментальная сборка без GIL — free-threaded build (PEP 703). Это отдельный вариант интерпретатора, а не поведение по умолчанию: обычная сборка 3.13 по-прежнему с GIL. Во free-threaded сборке потоки исполняют байт-код действительно параллельно, и CPU-задачи на потоках ускоряются.
Пока это экспериментальный статус: часть C-расширений несовместима, однопоточная производительность на free-threaded сборке ниже обычной, экосистема адаптируется. Строить продакшен на ней в 2024–2025 рано. Но направление задано: проверьте, доступна ли она в вашей версии, и следите за поддержкой в ключевых для вас библиотеках. Актуальный статус смотрите в документации CPython к вашей версии.
Что делать дальше
Перед выбором инструмента ответьте на один вопрос: ваша задача ждёт или считает. Проверить можно профилировщиком — если процесс висит на системных вызовах чтения/сети, это I/O; если ядро загружено вычислениями, это CPU.
- Для I/O — начните с
asyncio, если библиотеки поддерживают async, иначе сThreadPoolExecutor. - Для CPU —
ProcessPoolExecutor, замеряя накладные расходы на передачу данных. - Для смешанной нагрузки — считайте в процессах, а ожидание внутри процесса раскладывайте на потоки или корутины.
- Замеряйте до и после через
time.perf_counter()на своих данных, а не полагайтесь на теорию — поведение C-расширений предсказать без замера нельзя.
Инвалидация данных между процессами и выбор способа их передачи (shared memory против pickle) — отдельная тема, здесь мы её не разворачиваем.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: PEP 703 – Making the Global Interpreter Lock Optional in CPython, Python docs — threading, concurrent.futures, multiprocessing
Частые вопросы
У меня NumPy-вычисления реально ускорились в потоках. Это противоречит статье?
А если у меня Python 3.13 с отключённым GIL, всё написанное неверно?
Я поставил max_workers=100 для CPU-задачи, а быстрее не стало. Почему?
Чем asyncio лучше потоков для сетевых запросов?
Можно ли вообще отключить GIL в обычном Python?
ProcessPoolExecutor падает с ошибкой при запуске на Windows/macOS. Что не так?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.