open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 05 · 01

GIL честно: что он сериализует, зачем он refcounting и что меняет free-threading в 3.13

GIL сериализует байткод, а не I/O и не C-код, отпускающий его; он существует потому, что обновления refcount не атомарны. Потоки сменяются каждые ~5 мс (sys.setswitchinterval). Free-threaded 3.13+ (PEP 703) убирает его — ценой замедления одного потока и ловушки с расширениями.

PY Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Команда наконец выбила бюджет на 32-ядерную машину, чтобы ускорить CPU-тяжёлый скоринговый пайплайн. Решение выглядело очевидным: завернуть скоринг каждой записи в ThreadPoolExecutor(max_workers=32) и смотреть, как растёт throughput. Не вырос. Бенчмарк сдвинулся с 84 секунд до 81 — в пределах шума, — а htop показывал, как тридцать два потока совместно сжигают примерно 105% одного ядра. Хуже того, p99 маленького healthcheck-эндпойнта в том же процессе подскочил с 4 мс до 220 мс: CPU-потоки раз за разом выигрывали блокировку, без которой healthcheck не мог исполниться вовсе. Один инженер настаивал, что потоки в Python «ненастоящие»; другой возражал, что тот же сервис качает файлы в 16 потоков с реальным перекрытием. Оба смотрели на один и тот же Global Interpreter Lock и описывали разные его половины. Что GIL действительно сериализует — и чего он сознательно не трогает — и было всем содержанием инцидента.

Что GIL сериализует на самом деле

GIL — это один мьютекс на процесс внутри CPython: поток обязан держать его, чтобы исполнять байткод Python. Это всё правило целиком, и важны обе половины. Пока один поток крутит байткод цикла for, ни один другой не продвинется ни на одну инструкцию Python — поэтому 32 потока чистопайтоновского скоринга использовали одно ядро. Но блокировка отпускается ровно там, где об этом забывают: каждый блокирующий I/O-вызов стандартной библиотеки (socket.recv, file.read, time.sleep, драйверы БД) сбрасывает GIL перед уходом в ядро и забирает обратно после, так что 16 качающих потоков честно перекрывают свои ожидания. C-расширения умеют то же самое вокруг долгих вычислений: NumPy отпускает GIL внутри больших матричных операций, hashlib — при хешировании буферов больше 2 КБ, zlib и ssl — вокруг сжатия и хендшейков. Честная классификация — по нагрузке: чистопайтоновская CPU-работа в потоках не даёт ничего; I/O-тяжёлая в потоках — нормально; численная работа на C-расширениях в потоках может масштабироваться на удивление хорошо, потому что интерпретатор байткода — лишь один из жильцов вашего CPU.

import threading, time, hashlib

def pure_python(n=10_000_000):
    s = 0
    for i in range(n):       # привязан к байткоду: держит GIL всё время
        s += i
    return s

def c_extension(data=b"x" * 200_000_000):
    return hashlib.sha256(data).hexdigest()  # отпускает GIL во время хеширования

def timed(fn, threads):
    start = time.perf_counter()
    ts = [threading.Thread(target=fn) for _ in range(threads)]
    for t in ts: t.start()
    for t in ts: t.join()
    return time.perf_counter() - start

# Типичные результаты CPython 3.12 на 4 ядрах:
# pure_python  x1: ~0.45 с   x4 потока: ~1.9 с  (сериализация + накладные на переключение)
# c_extension  x1: ~0.55 с   x4 потока: ~0.7 с  (настоящий параллелизм в C)

Зачем он существует: refcounting не атомарен

CPython управляет памятью подсчётом ссылок: у каждого объекта есть счётчик, и почти каждый байткод его трогает — привязка имени инкрементирует, выход из области видимости декрементирует. Py_INCREF — это обычный ob->ob_refcnt++, операция «прочитать-изменить-записать», которая не атомарна. Два потока, инкрементирующие один счётчик, могут переплестись и потерять обновление; потерянный декремент — утечка объекта, потерянный инкремент — освобождение памяти, которая ещё используется, то есть use-after-free в ядре интерпретатора. Лобовые решения плохи: сделать каждую refcount-операцию атомарной инструкцией CPU исторически замедляло однопоточный Python примерно на 50% (Грег Стайн попробовал в 1999-м — патч отклонили ровно за это), а мелкозернистые блокировки на объект добавляют риск дедлоков и накладные расходы на самом горячем пути VM. Один глобальный замок сделал все refcount-операции безопасными бесплатно, сохранил скорость однопоточного кода и — критично — дал тысячам C-расширений простой контракт: пока твой код исполняется и ты не отпустил GIL, никакой другой поток ничего не мутирует. Именно этот контракт, а не ностальгия, растянул удаление на три десятилетия.

Почему это работает

Почему CPython просто не перешёл на трассирующий сборщик мусора и не выкинул refcount? Потому что C API протекает реализацией: расширения зовут Py_INCREF/Py_DECREF напрямую, а подсчёт ссылок даёт мгновенное детерминированное разрушение (файл закрывается, когда умирает последняя ссылка), на которое опирается реальный код. Менять это — ломать экосистему расширений, ту самую, благодаря которой Python победил. Каждая серьёзная попытка убрать GIL (включая Gilectomy) спотыкалась здесь: корректность достижима, но не без замедления однопоточного кода или слома C API. PEP 703 наконец продел нитку в иголку через biased reference counting — быстрые неатомарные операции для потока-владельца, атомарные только для чужих обращений — плюс бессмертные объекты для None, True и маленьких int.

Пульс в 5 мс: как потоки сменяют друг друга

Внутри eval-цикла работающий поток проверяет флаг между байткодами; отдельный механизм запрашивает сброс каждые 5 миллисекунд по умолчанию — читается и настраивается через sys.getswitchinterval() / sys.setswitchinterval(). (До Python 3.2 это был счётчик байткодов через sys.setcheckinterval(100); временной дизайн его заменил.) Ловушка в том, что отпустить GIL — не то же самое, что честно его передать: кто проснётся первым, решает ОС, и на многоядерных машинах поток, только что отпустивший замок, часто немедленно забирает его обратно — он уже бежит на горячем ядре. Это и есть эффект конвоя из инцидента в начале: CPU-поток раз за разом обгоняет только что готовый I/O-поток, и healthcheck ждёт десятки или сотни миллисекунд своей очереди исполнить несколько байткодов. Уменьшение интервала (sys.setswitchinterval(0.001)) меняет throughput на латентность и остаётся пластырем; настоящее лечение — вообще не гонять CPU-bound работу на потоках интерпретатора: процессы (следующий урок) или free-threaded-сборка.

Викторина

Один поток крутит чистопайтоновский цикл; второй качает файл в 1 ГБ через socket.recv. Заставит ли GIL загрузку ждать цикл?

Free-threaded 3.13+: что меняет PEP 703 и сколько это стоит

Прежде чем катить 3.13t в прод в надежде на мгновенный CPU-параллелизм, стоит точно знать, что вы покупаете — и что это стоит до мержа PR деплоя.

Python 3.13 поставляется с экспериментальной free-threaded сборкой (python3.13t, собрана с --disable-gil): GIL отсутствует, и чистопайтоновские потоки масштабируются по ядрам. Машинерия на замену: biased reference counting (поток-владелец обновляет быстрый локальный счётчик, чужие потоки — атомарными операциями общий), бессмертные объекты (refcount у None, True, маленьких int никогда не меняется, синхронизация им не нужна), пообъектные блокировки для мутации контейнеров и mimalloc, делающий аллокацию потокобезопасной без глобального замка аллокатора. Честные цены, а не версия с кейноута: в 3.13 free-threaded-сборка исполняет однопоточный код примерно на 30–40% медленнее — в основном потому, что специализирующий адаптивный интерпретатор в этой сборке пришлось отключить; 3.14 включает его обратно и спускает накладные до единиц процентов, ближе к целевым 5–8% из PEP 703. Это отдельная сборка — дефолтный python3.13 из дистрибутива по-прежнему с GIL. И ловушка экосистемы: C-расширение обязано задекларировать поддержку free-threading (слот Py_mod_gil); импорт расширения без него молча включает GIL обратно в рантайме, печатая лишь RuntimeWarning. Команды выкатывали 3.13t, не видели масштабирования и пропускали, что одна транзитивная зависимость тихо вернула замок. Проверяйте python -c "import sys; print(sys._is_gil_enabled())" после импорта всего стека — и форсируйте через PYTHON_GIL=0 (тогда несовместимые расширения падают громко).

Викторина

Вы деплоитесь на free-threaded 3.13t, но CPU-потоки всё равно масштабируются как одно ядро. В логах при старте — RuntimeWarning. Самая вероятная причина?

Вспомните перед уходом
  1. 01
    Сформулируйте точно: что GIL сериализует, что его отпускает и зачем он вообще существует.
  2. 02
    Сервис работает на free-threaded Python 3.13t, но потоки не масштабируются. Проведите диагностику и назовите цены, принятые с выбором 3.13t.
Итог

GIL — единственный на процесс мьютекс, который поток обязан держать, чтобы исполнять байткод Python, — не больше и не меньше. «Больше», которое люди воображают: он не сериализует ожидания в ядре и вычисления на C, потому что каждый блокирующий I/O-вызов stdlib и аккуратные C-расширения (NumPy, hashlib свыше 2 КБ, zlib, ssl) его отпускают — поэтому потоковые загрузки перекрываются, а потоковый NumPy может масштабироваться. «Меньше», которое забывают: 32 потока чистопайтоновской арифметики делят одно ядро и часто работают медленнее последовательного кода из-за накладных на переключение. Замок существует потому, что refcounting — ob_refcnt++ почти в каждом байткоде — не атомарен; атомарные инструкции стоят ~50% в однопоточном режиме, пообъектные блокировки дороже, а глобальный замок заодно дал C-расширениям контракт отсутствия конкурентных мутаций, на котором выстроена вся экосистема. Планирование временное: запрос на сброс каждые 5 мс (sys.setswitchinterval), и эффект конвоя — CPU-потоки обгоняют только что проснувшиеся I/O-потоки в гонке за повторный захват — классический латентностный провал. Free-threaded-сборка из PEP 703 (3.13t, --disable-gil) убирает замок с помощью biased reference counting, бессмертных объектов, пообъектных блокировок и mimalloc; честный счёт — ~30–40% однопоточных накладных в 3.13 (специализирующий интерпретатор отключён, 3.14 спускает до единиц процентов), отдельная сборка и тихая ловушка: импорт одного расширения без декларации free-threading включает GIL обратно в рантайме с одним лишь предупреждением. Проверяйте sys._is_gil_enabled(); используйте PYTHON_GIL=0, чтобы падать громко. Теперь, когда потоки на 3.13t не масштабируются, первый ход — sys._is_gil_enabled(), а не профайлер.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.