Сервис переносится на машину с шестнадцатью ядрами, мониторинг рисует нагрузку в сто процентов, но очередь запросов не убывает: выжат один процессор, остальные пятнадцать стоят без дела. В коде честный ThreadPoolExecutor на восемь рабочих, каждый поток занят работой, и от такой картины только обиднее. Виновник известен каждому питонисту по имени: GIL, глобальная блокировка интерпретатора CPython. В учебниках она упоминается мельком, в продакшене же превращается в конкретные проценты упущенной производительности, непонятно откуда растущие тайминги ответов и долгие вечера у профилировщика. Ниже разбор механики блокировки, замеры, показывающие, что потоки на вычислительной работе не просто бесполезны, а местами вредны, честная цена обхода через процессы и статус сборок Python без GIL, добравшихся к версии 3.14 до официальной поддержки.
Устройство GIL и одна блокировка на весь байткод интерпретатора
Глобальная блокировка живёт в CPython не из вредности. Интерпретатор управляет памятью через подсчёт ссылок: у каждого объекта есть счётчик, растущий при присваивании имени и падающий при потере ссылки, ноль означает мгновенное освобождение памяти. Счётчики трогает почти каждая инструкция. Два потока, одновременно работающие с одним объектом, разнесли бы счётчик и преждевременно освободили бы живую память. Проектировщики CPython выбрали простое решение: один мьютекс на весь интерпретатор, исполнение байткода Python разрешено ровно одному потоку.
Как же тогда потоки вообще сосуществуют. Работающий поток регулярно оглядывается, не просит ли кто-нибудь блокировку. Интервал задаётся функцией sys.setswitchinterval и по умолчанию равен 5 миллисекундам:
import sys
print(sys.getswitchinterval()) # 0.005
Схема появилась в Python 3.2. До неё переключение шло по счётчику инструкций, и захватившая блокировку C-функция могла удерживать её сколь угодно долго, а голодные потоки копили очереди. Сейчас ожидание честное: ждущий поток дождётся своей очереди не дольше интервала переключения. Для вычислительного кода суть не меняется. Два потока с тяжёлым чистым Python делят одно ядро, доплачивая временем за сами переключения и остывшие кэши процессора. Параллелизм не возникает, выходит разделение времени с накладными расходами.
Замеры пула потоков на вычислительной задаче вместо ожидаемого ускорения
Дискуссию о GIL закрывает один эксперимент на полчаса. Берётся счётная функция без обращений к сети и диску, прогоняется четыре раза подряд, потом те же четыре доли отдаются пулу из четырёх потоков:
import time
from concurrent.futures import ThreadPoolExecutor
def crunch(n: int) -> int:
total = 0
for i in range(n):
total += i * i % 7
return total
if __name__ == "__main__":
n = 5_000_000
t0 = time.perf_counter()
[crunch(n // 4) for _ in range(4)]
one = time.perf_counter() - t0
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(crunch, [n // 4] * 4))
four = time.perf_counter() - t0
print(f"последовательно {one:.2f} с, потоками {four:.2f} с")
На четырёхядерной машине с обычной сборкой CPython 3.12 типичный результат выглядит примерно так: последовательный проход 3.2 секунды, пул потоков 3.9 секунды. Четыре потока работают медленнее одного. Причина называется эффектом конвоя: единственный исполняющий поток постоянно прерывается, очередь на блокировку растёт, каждое переключение сбрасывает контекст кэшей, а освобождение GIL и повторный захват добавляют свои микросекунды к каждому циклу. Чем больше потоков, тем хуже. Восьмиядерный сервер с восемью рабочими потоками на такой задаче может удвоить время против одиночного исполнения, и это не экзотика, а предсказуемая физика одной блокировки.
Отсюда неприятное следствие для продакшена: пул потоков, поднятый на всякий случай для смешанной нагрузки, где попадаются вычислительные вставки вроде парсинга больших XML или пересчёта JSON-дерева, тихо отбирает процессор у сетевых операций и растягивает латентность. Никакой тревоги в мониторинге, потоки же заняты. Просто заняты во вред.
Откуда у потоков репутация полезных при той же самой блокировке
Было бы ошибкой вычеркнуть threading из инструментария. Блокировка освобождается всякий раз, когда поток уходит в ожидание внешнего события: системный вызов чтения из сокета, ожидание ответа хранилища, пауза time.sleep. В эти моменты байткод не исполняется, и второй поток спокойно занимает процессор. Поэтому сотня медленных HTTP-вызовов в десять потоков сокращается с минуты до нескольких секунд, и GIL ничему не мешает.
Простейшая проверка без внешних зависимостей показывает это на живых числах: пятьдесят пауз по полсекунды последовательно занимают двадцать пять секунд, пул из десяти потоков укладывает их примерно в три.
import time
from concurrent.futures import ThreadPoolExecutor
def nap(i: int) -> int:
time.sleep(0.5)
return i
if __name__ == "__main__":
t0 = time.perf_counter()
[nap(i) for i in range(50)]
print(f"последовательно {time.perf_counter() - t0:.2f} с")
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
list(pool.map(nap, range(50)))
print(f"пул потоков {time.perf_counter() - t0:.2f} с")
Второй источник спокойствия за потоки: C-расширения. Код на C или Rust внутри библиотеки умеет явно отпустить блокировку на время тяжёлой работы. Так работает hashlib поверх OpenSSL, отпускающий блокировку для порций больше 2047 байт, так поступают компрессоры zlib, bz2 и lzma, так работает NumPy над процедурами BLAS, которые сами распараллеливаются на все ядра. Проверить поведение библиотеки можно по её исходникам: ищется вызов Py_BEGIN_ALLOW_THREADS. Если честная работа уходит в нативный код, потоки снова становятся осмысленным инструментом.
Тонкость, на которой спотыкаются даже опытные команды: граница между "потоки помогают" и "потоки топят" проходит не по признаку задачи, а по доле времени в чистом Python. Сервис, который тянет сотни файлов и складывает их в zipfile, летает на потоках, пока архивация идёт в zlib. Стоит добавить в цикл парсинг JSON-ответов на сотни мегабайт, и всё возвращается к одной блокировке, потому что парсер json работает в пространстве интерпретатора. Профилировщик py-spy в режиме dump быстро показывает, где именно нить застряла: в нативном вызове или в байткоде.
Многопроцессность как рабочий путь и её честная цена сериализации и памяти
Классический обход GIL: несколько процессов вместо потоков. У каждого процесса собственный интерпретатор, собственная блокировка, собственная память. Оркестрировать их удобно тем же интерфейсом:
import time
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
n = 20_000_000
t0 = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
list(pool.map(crunch, [n // 4] * 4))
print(f"четыре процесса: {time.perf_counter() - t0:.2f} с")
Первая строка такого кода с if __name__ не бюрократия: без неё дочерний процесс на старте заново импортирует модуль и пытается поднять пул внутри себя, пока родитель ждёт. На платформах Windows и macOS защита обязательна, на Linux выручает случайно, и вылавливать такой каскад по логам то ещё удовольствие.
На тех же четырёх ядрах тот же объём работы занимает около 1.2 секунды против 3.2 у последовательного кода. Ускорение 2.7 раза, а не идеальные четыре. Разницу съедают три статьи расходов. Первая: старт процессов, каждый поднимает интерпретатор и импортирует модули заново. Вторая: каждый аргумент и каждый результат проходит через pickle, большие словари и массивы сериализуются целиком, иногда дольше самой задачи. Третья: пересылки по каналам плодят копии, память расходуется кратно числу процессов.
С fork связана отдельная история. Классический на Linux fork() дешёв и любим за скорость, но комбинация fork с уже работающими потоками ломает блокировки, и с Python 3.12 такое сочетание сопровождается предупреждением о будущей удалённости. Частично проблему снимает общая память: модуль multiprocessing.shared_memory, доступный с Python 3.8, позволяет передавать NumPy-массивы без копирования, и на обработке изображений это меняет расклад радикально. Обратная сторона: сегменты shared_memory нужно убирать вручную, утечки здесь не прощаются сборщиком мусора.
Сборка Python без GIL от эксперимента в 3.13 до официальной поддержки в 3.14
Сама блокировка перестала быть константой. PEP 703, написанный Сэмом Гроссом и принятый руководящим советом проекта в 2023 году, описал отдельную сборку CPython без GIL: подсчёт ссылок стал смещённым и отложенным, сборщик циклов переработан, словари и списки получили потокобезопасные операции. План расписали на три фазы: экспериментальная сборка, затем официально поддерживаемая, затем сборка по умолчанию.
Первая фаза случилась в Python 3.13, вышедшем в октябре 2024 года: отдельный бинарник python3.13t, пометка об экспериментальном статусе при старте, отдельный тег колёс cp313t на PyPI. Вторая фаза пришла с Python 3.14 в октябре 2025 года, критерии зафиксированы в PEP 779: сборка без GIL переведена в разряд официально поддерживаемых. Проверить конкретный интерпретатор можно одной строкой:
python3.14t -c "import sysconfig, sys; print(sysconfig.get_config_var('Py_GIL_DISABLED'), sys._is_gil_enabled())"
Значение 1 и False в ответе означают, что блокировки нет и четыре потока действительно займут четыре ядра. Плата двоякая. Однопоточная производительность такой сборки ниже обычной: в ранних сборках 3.13 разница исчислялась десятками процентов, к выпуску 3.14 её удалось заметно сжать, но не обнулить. Экосистема подтягивается постепенно: C-расширения должны выпускать отдельные колёса под свободные от GIL сборки, и для нишевых пакетов их ещё приходится ждать или собирать самим. Прагматичный вывод: для нового сервиса на чистом Python и проверенных зависимостях сборка 3.14t уже годится для испытаний в тестовом контуре, для старого проекта с десятком экзотических расширений переезд пока остаётся отдельным проектом, а не выходными.
Чек-лист выбора между потоками процессами и сборкой без GIL
Перед стартом любого пула задачу полезно прогнать по короткому списку, он закрывает большую часть практических раскладов:
- блокирующие вызовы сети, диска и внешних сервисов живут в пуле потоков, размер которого подбирается под число соединений, а не под ядра;
- чистый Python с интенсивными вычислениями уходит в пул процессов, аргументы и результаты держатся компактными, иначе pickle съест выигрыш;
- тяжёлые C-библиотеки проверяются на отпускание блокировки, внутренняя параллельность NumPy и BLAS часто снимает вопрос до его появления;
- сборка без GIL рассматривается при большом числе ядер, готовности зависимостей и приемлемой просадке одиночного потока;
- пересмотр алгоритма стоит в очереди первым, снижение сложности с квадратичной до линейной приносит больше, чем любой пул.
Практические выводы для команды под реальной процессорной нагрузкой
GIL наказывает не самим фактом существования, а за неоправданные ожидания. Потоки остаются точным инструментом для ожидания, каким бы ни был счёт ядер: они дёшевы, стартуют мгновенно, делят память без пересылок. Процессы покупают ядра ценой сериализации и памяти, поэтому выигрывают на крупных самостоятельных порциях работы и проигрывают на мелких частых заданиях. Сборка без GIL наконец вышла из лаборатории и в 3.14 несёт статус официальной поддержки, однако это смена платформы, а не бесплатный ускоритель: свой прайс в однопоточной скорости и совместимости никуда не делся.
Рабочий порядок действий для существующего сервиса выглядит скромно и без романтики. Сначала профилирование py-spy и честный ответ, где код проводит время: в ожидании, в нативных вызовах или в байткоде. Потом перенос вычислительных кусков в процессы с контролем размера пересылаемых объектов. В параллель, на отдельной машине, прогон сервиса на сборке 3.14t ради честного замера, а не ради веры. И только затем решение, потому что все успешные истории про параллельность в Python начинаются одинаково: сначала измерили, потом поехали. Картина с одним выжатым ядром и пятнадцатью пустыми говорит сама за себя, надо лишь взглянуть на неё до релиза, а не после.