Сервис переносится на машину с шестнадцатью ядрами, мониторинг рисует нагрузку в сто процентов, но очередь запросов не убывает: выжат один процессор, остальные пятнадцать стоят без дела. В коде честный 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

Перед стартом любого пула задачу полезно прогнать по короткому списку, он закрывает большую часть практических раскладов:

  1. блокирующие вызовы сети, диска и внешних сервисов живут в пуле потоков, размер которого подбирается под число соединений, а не под ядра;
  2. чистый Python с интенсивными вычислениями уходит в пул процессов, аргументы и результаты держатся компактными, иначе pickle съест выигрыш;
  3. тяжёлые C-библиотеки проверяются на отпускание блокировки, внутренняя параллельность NumPy и BLAS часто снимает вопрос до его появления;
  4. сборка без GIL рассматривается при большом числе ядер, готовности зависимостей и приемлемой просадке одиночного потока;
  5. пересмотр алгоритма стоит в очереди первым, снижение сложности с квадратичной до линейной приносит больше, чем любой пул.

Практические выводы для команды под реальной процессорной нагрузкой

GIL наказывает не самим фактом существования, а за неоправданные ожидания. Потоки остаются точным инструментом для ожидания, каким бы ни был счёт ядер: они дёшевы, стартуют мгновенно, делят память без пересылок. Процессы покупают ядра ценой сериализации и памяти, поэтому выигрывают на крупных самостоятельных порциях работы и проигрывают на мелких частых заданиях. Сборка без GIL наконец вышла из лаборатории и в 3.14 несёт статус официальной поддержки, однако это смена платформы, а не бесплатный ускоритель: свой прайс в однопоточной скорости и совместимости никуда не делся.

Рабочий порядок действий для существующего сервиса выглядит скромно и без романтики. Сначала профилирование py-spy и честный ответ, где код проводит время: в ожидании, в нативных вызовах или в байткоде. Потом перенос вычислительных кусков в процессы с контролем размера пересылаемых объектов. В параллель, на отдельной машине, прогон сервиса на сборке 3.14t ради честного замера, а не ради веры. И только затем решение, потому что все успешные истории про параллельность в Python начинаются одинаково: сначала измерили, потом поехали. Картина с одним выжатым ядром и пятнадцатью пустыми говорит сама за себя, надо лишь взглянуть на неё до релиза, а не после.