GIL в Python: что это, зачем нужен и как его обойти

Python Автор: Среда и версия: CPython 3.14.5 с GIL; Linux; Intel Core i7-12700KF

GIL (Global Interpreter Lock) — мьютекс внутри CPython, который разрешает исполнять байткод только одному потоку одновременно. Дал программе четыре потока и четыре ядра, а считает она со скоростью одного. Зато на ожидании сети или диска лок отпускается: там потоки отрабатывают полностью, и четыре запроса по полсекунды уложатся в те же полсекунды. Дальше всё на одном примере.

Задача сквозная: четыре файла по 8 МБ, каждый надо скачать и посчитать по нему контрольную сумму.

import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

blob = bytes(range(256)) * 32768              # 8 МБ «скачанного файла»

def checksum(data: bytes) -> int:             # CPU: чистый Python
    total = 0
    for byte in data:
        total = (total * 31 + byte) % 1_000_003
    return total

def download(data: bytes) -> bytes:           # I/O: ждём ответ сервера
    time.sleep(0.5)
    return data

def measure(func, workers, pool_cls=ThreadPoolExecutor, tasks=4) -> float:
    start = time.perf_counter()
    with pool_cls(max_workers=workers) as pool:
        list(pool.map(func, [blob] * tasks))
    return time.perf_counter() - start

Все числа ниже я снял на своей машине: Python 3.14.5, обычная сборка с GIL, Linux, Intel Core i7-12700KF, 20 логических ядер. Замеры на сборке без GIL помечены отдельно. Это иллюстрация, а не бенчмарк: абсолютные значения у тебя будут другие, соотношения останутся.

Что такое GIL простыми словами

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

Ещё одна деталь пригодится в конце. GIL защищает внутренности интерпретатора, а не твои данные.

Зачем GIL вообще нужен

Из-за подсчёта ссылок. У каждого объекта в CPython есть счётчик ob_refcnt: сколько имён и контейнеров на него сейчас смотрят. Досчитал до нуля, объект освобождается.

import sys
data = [1, 2, 3]
print(sys.getrefcount(data) - 1)   # 1, единицу вычитаю за временную ссылку,
backup = data                      # которую создаёт сам вызов getrefcount
print(sys.getrefcount(data) - 1)   # 2

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

Защититься можно двумя способами. Сделать каждую операцию со счётчиком атомарной: корректно, но атомарный инкремент дороже обычного, и платит за него весь код, включая однопоточный. Либо взять один глобальный лок на весь интерпретатор: однопоточная программа захватывает его на старте и больше о нём не думает. CPython выбрал второе. Отсюда главный ответ на «почему GIL просто не уберут»: он держит скорость однопоточного кода, а однопоточных программ большинство.

Что происходит с CPU-задачей в потоках

Ничего хорошего. checksum крутит цикл на чистом Python, значит требует GIL на каждой итерации.

for workers in (1, 2, 4):
    print(f"{workers} потоков: {measure(checksum, workers):.2f} c")
потоковвремя
11.10 c
21.12 c
41.11 c

Работа поделилась, время не изменилось. Каждая добавка потоков стоит пары процентов сверху: за передачу лока приходится платить. Ядер в машине двадцать, на время это не влияет никак. Правило отсюда простое: если профиль показывает, что программа жжёт процессор внутри питоновского кода, потоки её не ускорят. Как устроен сам threading, разобрано в статье про потоки.

Почему на вводе-выводе потоки всё-таки работают

Потому что на время ожидания GIL отпускается. time.sleep, чтение сокета, запрос в базу, обращение к диску: перед блокирующим вызовом поток отдаёт лок и забирает обратно после. Тот же цикл замеров, только с download вместо checksum:

потоковвремя
12.00 c
21.00 c
40.50 c

Четыре ожидания по полсекунды уложились в полсекунды: спящий поток не занимает ни GIL, ни процессор. sleep здесь стоит вместо сетевой задержки, но дело не в нём. Тот же замер я повторил на настоящем TCP-сокете с локальным сервером, который отвечает через 0.5 с, и получил 2.00 / 1.00 / 0.50, цифра в цифру.

Потоки не единственный способ ждать много ответов сразу. На сотнях и тысячах одновременных соединений дешевле обходится asyncio: там на каждое соединение приходится корутина, а не отдельный стек потока.

Как обойти GIL процессами

Каждый процесс поднимает свой интерпретатор со своим GIL, поэтому счёт наконец идёт параллельно. В коде меняется одно слово: ThreadPoolExecutor на ProcessPoolExecutor.

if __name__ == "__main__":                     # для процессов обязательно
    for workers in (1, 2, 4):
        print(f"{workers}: {measure(checksum, workers, ProcessPoolExecutor):.2f} c")
процессоввремя
11.18 c
20.60 c
40.32 c

Ускорение почти линейное. Плата видна уже на одном процессе: 1.18 с против 1.10 с у потока, разницу съели запуск интерпретатора и передача данных. Аргументы и результаты ходят через pickle, так что гонять между процессами гигабайтные массивы дороже, чем посчитать их на месте.

Как обойти GIL C-расширением

Код на C умеет отпустить GIL на время счёта: макросы Py_BEGIN_ALLOW_THREADS и Py_END_ALLOW_THREADS снимают лок на участке, где не трогают питоновские объекты. Так делает hashlib. Считаем ту же контрольную сумму, только через sha256:

import hashlib

def sha(data: bytes) -> str:
    digest = hashlib.sha256()
    for _ in range(100):
        digest.update(data)
    return digest.hexdigest()
потоковвремя
11.48 c
20.74 c
40.37 c

Те же потоки, тот же пул, ускорение вчетверо. numpy ведёт себя так же: сортировка четырёх массивов по 20 млн float заняла у меня 0.96 с в один поток и 0.30 с в четыре. Тяжёлую арифметику выгоднее отдавать библиотеке, которая считает в C, а не выписывать циклом на Python.

Как часто потоки меняются местами

За это отвечает интервал переключения. sys.getswitchinterval() на моей 3.14.5 возвращает 0.005, то есть пять миллисекунд. Значение задаёт не период смены, а порог терпения: ждущий поток просит передать ему GIL, когда текущий владелец продержал лок дольше интервала. Отдаёт он лок не мгновенно, а на ближайшей точке проверки, поэтому одна длинная операция внутри C-кода тянет заметно дольше порога.

Крутить sys.setswitchinterval() в надежде разогнать CPU-код бесполезно, скорее наоборот. С интервалом в микросекунду мой замер на четырёх потоках дал медиану 1.32 с против 1.15 с на дефолтных пяти миллисекундах: потоки чаще дёргаются за локом и тратят время на саму передачу. Уменьшать интервал осмысленно в одном случае — когда фоновый CPU-поток душит отзывчивость потока, который обслуживает запросы.

Что происходит с free-threading и PEP 703

Free-threaded сборка CPython работает вообще без GIL. В 3.13 она появилась как экспериментальная, документация формулирует прямо: «This is an experimental feature and therefore is not enabled by default». В 3.14 её статус поднял PEP 779, и в What’s New появилась строка «Free-threaded Python is officially supported».

Сборкой по умолчанию она не стала ни там, ни там. Ставится она отдельным бинарником python3.14t: галочка в инсталляторе на Windows и macOS, свой пакет в дистрибутивах Linux (в Fedora это python3.14-freethreading), либо --disable-gil при сборке из исходников. Проверить, что у тебя запущено:

import sys, sysconfig
print(sys._is_gil_enabled())                          # True на обычной сборке
print(sysconfig.get_config_var("Py_GIL_DISABLED"))    # 0 на обычной сборке

Флаг -X gil=0 обычную сборку не переключит, интерпретатор даже не стартует: Fatal Python error: config_read_gil: Disabling the GIL is not supported by this build. Тот же checksum на free-threaded сборке 3.14.3t (готовый бинарник python-build-standalone), та же машина:

потоков3.14.5 с GIL3.14.3t без GIL
11.10 c0.85 c
21.12 c0.42 c
41.11 c0.22 c

Потоки наконец масштабируются. Штраф на однопоточном коде документация 3.14 оценивает в «roughly 5-10%», но по этой паре сборок его не измерить: free-threaded бинарник собран Clang, системный — GCC, так что сравнивать честно можно только масштабирование внутри одной колонки. Дальше начинается неприятное. Без GIL код становится по-настоящему параллельным, и гонки, которые раньше прятались, вылезают наружу.

На чём ловят на собеседовании

«GIL делает код потокобезопасным». Не делает. Он гарантирует одно: байткод в каждый момент крутит один поток. Атомарности он не даёт даже на одной инструкции — CALL и BINARY_OP над объектом с питоновским __add__ исполняют произвольный код и сами становятся точками передачи лока. Строка processed += 1 и вовсе разворачивается в четыре инструкции: LOAD_GLOBAL, LOAD_SMALL_INT, BINARY_OP, STORE_GLOBAL.

import threading

processed = 0

def worker(n=200_000):
    global processed
    for _ in range(n):
        processed += 1

ts = [threading.Thread(target=worker) for _ in range(4)]
for t in ts: t.start()
for t in ts: t.join()
print(processed)     # ожидаем 800000

На обычной сборке 3.14.5 я получил ровно 800000 в шестнадцати прогонах подряд: восемь на дефолтном интервале, восемь с интервалом в микросекунду. Дело не в везении. Запрос на передачу GIL интерпретатор проверяет не между любыми инструкциями, а только на некоторых: обратный переход цикла, вход в кадр, вызов. В этом цикле такая точка одна, JUMP_BACKWARD, и стоит она уже после STORE_GLOBAL, так что перебить инкремент посередине негде. Стоит появиться вызову между чтением и записью, и окно открывается: processed = processed + one(), где one() возвращает единицу, из тех же четырёх потоков дал 800000, 663490, 766508 на дефолтном интервале и 326023, 405291, 298109 с интервалом в микросекунду.

На free-threaded сборке 3.14.3t исходный цикл с processed += 1 напечатал 255787, 245816, 255772. Потерялись почти три четверти. Вывод из пары замеров такой: GIL не делает подобный код корректным, он делает поломку непредсказуемой. Инкремент в одну строку уцелел, тот же инкремент через вызов уже теряется, а без GIL разваливается и первый. threading.Lock вокруг processed += 1 даёт честные 800000 на обеих сборках. А вот проверка перед действием ломается независимо от сборки: if key not in cache: cache[key] = compute() из двух потоков спокойно вычислит значение дважды, потому что лок отпускается на вызове между проверкой и записью. Тот же изъян сидит внутри @functools.cache, и это одна из ловушек в разборе декораторов.

«Взял C-библиотеку, значит GIL отпущен». Отпущен, когда работы достаточно много. hashlib снимает лок только на крупных буферах: на мелких порциях снять и вернуть его дороже, чем посчитать хеш. Тот же sha256 по тем же 8 МБ, разница только в размере порции:

порция1 поток4 потока
8 МБ1.48 c0.37 c
1 КБ1.87 c1.88 c

Кормишь библиотеку килобайтными кусками и теряешь сразу и параллелизм, и скорость.

«Поставлю потоков по числу ядер». Двадцать задач checksum на моих двадцати ядрах: один поток управился за 5.43 с, двадцать потоков за 5.80 с. Полный набор ядер отработал медленнее одного: работы столько же, а претендентов на лок стало двадцать, и время ушло на передачи. Число потоков подбирают под количество одновременных ожиданий. Под ядра подбирают число процессов.

«В Python есть GIL». GIL живёт в CPython, в спецификации языка его нет. У Jython и IronPython лока нет вовсе, у PyPy он есть.

Частые вопросы

Как коротко ответить про GIL на собеседовании

Тремя предложениями. GIL — мьютекс в CPython, который пускает исполнять байткод один поток за раз; нужен он потому, что подсчёт ссылок не потокобезопасен, а один глобальный лок дешевле атомарного счётчика в каждом объекте. Из-за него CPU-задачи в потоках не ускоряются, зато на вводе-выводе лок отпускается, и там потоки отрабатывают полностью. Обходят его процессами, C-расширениями или free-threaded сборкой.

Что пришлось сделать, чтобы убрать GIL

Перестроить подсчёт ссылок. В PEP 703 это смесь трёх механизмов: biased reference counting с раздельными локальным и разделяемым счётчиками, отложенный подсчёт для функций, модулей и объектов кода, бессмертные объекты для None, малых чисел и интернированных строк. Однопоточный код всё равно вышел медленнее на те самые «roughly 5-10%».

Как понять, что программа упирается именно в GIL

Замерь ту же работу в один поток и в четыре. Время совпало, а процессор загружен примерно на одно ядро — значит упёрлась. Если же время упало и нагрузка расползлась по всем ядрам, лок исправно отпускается и тормозит что-то другое.

Стоит ли переходить на free-threaded сборку прямо сейчас

Смотри на зависимости. C-расширение обязано объявить совместимость слотом Py_mod_gil; если слот не выставлен, интерпретатор при импорте останавливает потоки, включает GIL обратно и печатает предупреждение с именем модуля. Одно старое расширение возвращает тебя к исходной точке. Плюс выигрыш появляется только у чистого Python-кода: если горячая часть и так уходит в numpy или hashlib, GIL там отпускается и без новой сборки.

Что учить дальше

GIL задаёт границу между тремя способами делать несколько дел сразу, и выбирать между ними придётся на каждой задаче. Разбор с критериями выбора собран в сравнении потоков, процессов и asyncio; если сами потоки пока не уложились, начни с разбора threading.

Источники