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")
| потоков | время |
|---|---|
| 1 | 1.10 c |
| 2 | 1.12 c |
| 4 | 1.11 c |
Работа поделилась, время не изменилось. Каждая добавка потоков стоит пары процентов сверху: за передачу лока приходится платить. Ядер в машине двадцать, на время это не влияет никак. Правило отсюда простое: если профиль показывает, что программа жжёт процессор внутри питоновского кода, потоки её не ускорят. Как устроен сам threading, разобрано в статье про потоки.
Почему на вводе-выводе потоки всё-таки работают
Потому что на время ожидания GIL отпускается. time.sleep, чтение сокета, запрос в базу, обращение к диску: перед блокирующим вызовом поток отдаёт лок и забирает обратно после. Тот же цикл замеров, только с download вместо checksum:
| потоков | время |
|---|---|
| 1 | 2.00 c |
| 2 | 1.00 c |
| 4 | 0.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")
| процессов | время |
|---|---|
| 1 | 1.18 c |
| 2 | 0.60 c |
| 4 | 0.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()
| потоков | время |
|---|---|
| 1 | 1.48 c |
| 2 | 0.74 c |
| 4 | 0.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 с GIL | 3.14.3t без GIL |
|---|---|---|
| 1 | 1.10 c | 0.85 c |
| 2 | 1.12 c | 0.42 c |
| 4 | 1.11 c | 0.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 c | 0.37 c |
| 1 КБ | 1.87 c | 1.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.