GIL: не «Python не умеет в потоки», а «поток ждёт столько, сколько вы попросили»
Интервал переключения — настройка, которая работает: медиана ожидания следует за ней и на 5 и 50 мс отличается на единицы процентов. Пока не наступает случай, в котором она не работает вовсе — и он измеряется в сотнях миллисекунд.
Полное техническое изложение
TL;DR
- В Python есть правило: код исполняет только один поток за раз. Остальные ждут очереди.
- Ждут они не «сколько получится», а примерно столько, сколько записано в одной настройке. По умолчанию — пять миллисекунд.
- Но если тот, кто сейчас работает, начал одно длинное действие, прервать его некому. Тогда ждать приходится в десятки раз дольше — это измерено.
- Пока программа чего-то ждёт (сеть, диск) и пока считает библиотека вроде
zlib, микрофон свободен: правило есть, но никого не задерживает. В этих случаях потоки помогают, и помогают давно.
Один микрофон на всю переговорную
Представьте совещание. Людей восемь, микрофон один, правило одно: говорит тот, у кого микрофон в руках.
Никто не говорит медленно. Каждый произносит свою фразу с обычной скоростью. Но восемь фраз всё равно займут восемь очередей, а не одну — сколько бы человек в комнате ни сидело.
Это и есть GIL. Микрофон — право исполнять код Python. Люди — потоки.
import threading
def crunch(n):
x = 0
for i in range(n):
x += i * i
# одна и та же работа: 4 000 000 итераций в одном потоке
# против двух потоков по 2 000 000
t1 = threading.Thread(target=crunch, args=(2_000_000,))
t2 = threading.Thread(target=crunch, args=(2_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()Такой код на двухъядерной машине не станет быстрее. Замер: crunch(4_000_000) в одном потоке — 0,1516 с, два потока по половине — 0,1559 с. Второй поток не помог, а немного помешал.
Регламент передачи
Микрофон передают по регламенту: примерно раз в пять миллисекунд. Это не выдумка автора, а настройка, которую видно:
import sys
sys.getswitchinterval() # 0.005И работает она предсказуемо. Если поставить в цикле замер «сколько я ждал своей очереди», получится вот что:
| Настройка | Сколько ждали на самом деле |
|---|---|
| 0,5 мс | 0,72 мс |
| 5 мс | 5,28 мс |
| 50 мс | 50,34 мс |
Что сюда поставили — примерно столько и ждём. На верхних двух строчках промах в единицы процентов. На нижней он больше — 0,72 вместо 0,5, — и причина понятная: чем чаще передают микрофон, тем заметнее время самой передачи.
А теперь сюрприз
Регламент действует между действиями. Отобрать микрофон посреди одного действия некому.
Если работающий поток начал что-то длинное — например, отсортировал список из двух миллионов элементов, — все остальные ждут, пока он закончит. Настройка при этом остаётся прежней, пять миллисекунд, и не значит ничего.
| Чем занят работающий поток | Сколько ждут остальные |
|---|---|
| много коротких действий | 5,3 мс |
| одно умножение огромных чисел | 167,8 мс |
| одна сортировка большого списка | 185,6 мс (в худшем замере — 379,8) |
Разница — в десятки раз. И это не поломка: так и написано в исходниках CPython рядом с самой настройкой. Она «поощряет» переключение через заданное время, но не может его обеспечить, потому что одно действие может длиться сколько угодно.
Точная цитата из Python/ceval_gil.c и полные таблицы замеров — в варианте «Подробно».
Когда микрофон свободен
Самое главное, и это обычно теряется за фразой «потоки в Python бесполезны».
Пока программа ждёт, микрофон она отдаёт. Скачивание страницы, запрос к базе, чтение файла — всё это ожидание. Потоки здесь складываются почти идеально:
import threading, time
def wait_a_bit():
time.sleep(0.05)Восемь таких ожиданий подряд занимают 0,40 с. Восемь потоков — 0,05 с. Ускорение почти в восемь раз, и никаких ядер для этого не нужно: потоки ничего не считают, они ждут.
Некоторые библиотеки тоже отдают микрофон. Пока zlib сжимает, микрофон свободен — его в это время берёт другой поток. Замер: один поток сжимает буфер за 0,0564 с, два потока сжимают по такому же буферу каждый — и укладываются в 0,0553 с. Работы вдвое больше, времени столько же.
Отсюда простое правило выбора:
- программа ждёт — берите потоки, они работают;
- программа считает библиотекой, которая отдаёт микрофон, — тоже потоки;
- программа считает на чистом Python — потоки не помогут, нужны процессы.
А GIL вообще убрали?
Частично и не по умолчанию.
С Python 3.13 существует отдельная сборка, собранная без GIL. В 3.14 она стала официально поддерживаемой — но не стала той, которую вы получаете, скачав Python с сайта. Обычная сборка по-прежнему с GIL, и решение о смене умолчания отложено до отдельного будущего документа, которого пока нет.
Проверить, какая сборка у вас:
import sys
sys._is_gil_enabled() # True в обычной сборке, False в свободнойВ свободной сборке тот же счётный код в два потока дал ускорение ×1,76 на двух ядрах. Не ×2 — за свободу платят: сама по себе такая сборка работает медленнее, и на плотных циклах разница доходила до 14–46 %.
И есть неприятная мелочь, которую стоит знать заранее: если два потока читают одни и те же объекты, в свободной сборке они начинают мешать друг другу. Замер по четырём прогонам: вдвоём выходит ×0,45–0,52, то есть медленнее, чем в одиночку, при вдвое большей работе. В обычной сборке те же прогоны дают ×0,94–1,00 — выигрыша нет, но и проигрыша тоже: потоки просто стоят в очереди.
Так что «уберём GIL и станет быстрее» — не работает как план. Работает другое: понять, чего ваша программа ждёт, а что считает.
TL;DR
- GIL не «замедляет потоки». Он заставляет их ждать очереди, и длительность ожидания задаётся
sys.setswitchinterval. Измерено: при интервале 5 мс медиана ожидания — 5,279 мс, при 50 мс — 50,335 мс, при 0,5 мс — 0,717 мс. На двух верхних значениях промах — единицы процентов, на нижнем уже 43 %: там начинает стоить само переключение. - Ровно до того момента, когда работающий поток входит в одну длинную операцию. Тогда интервал не значит ничего: то же ожидание становится 167,8 мс на умножении больших целых и 379,8 мс в худшем случае на
sorted(). Это не баг — так написано в комментарии рядом с самой константой. - «Потоки в Python бесполезны» — утверждение про чистый байткод и только про него. Измерено на тех же двух ядрах: чистый Python — ×0,97,
time.sleep— ×7,86,zlib.compress— ×2,04. - Свободные сборки — не гипотеза и не эксперимент: PEP 779 сделал их официально поддерживаемыми в 3.14. Но значением по умолчанию они не стали, и PEP про это говорит прямо — решение о фазе III «оставлено будущему PEP».
- Цена свободной сборки реальна и заметна на микротестах сильнее, чем на общем наборе бенчмарков. И у неё есть неочевидная часть: чтение объекта, созданного другим потоком, обходится дороже даже когда никакой конкуренции нет. Измерено: +15,4 % при полном отсутствии второго потока.
Зачем это знать?
Потому что расхожий ответ про GIL описывает ровно один из трёх случаев, разобранных ниже, и молчит про два других.
«Глобальная блокировка интерпретатора не даёт потокам исполняться параллельно, поэтому для CPU-задач нужен multiprocessing» — это заученная формула, а не понимание. Она не объясняет, почему requests в двадцати потоках прекрасно работает, почему numpy считает на всех ядрах, и почему у вашего веб-сервера иногда появляются ответы в сотню-другую миллисекунд на пустом месте — при том что считать столько там нечего.
Ниже — четыре вещи, каждая из которых снята с работающего интерпретатора:
- на что влияет
sys.setswitchinterval(ответ прямее, чем кажется); - когда эта настройка не влияет ни на что;
- в каких случаях GIL вообще не участвует в происходящем;
- что именно изменилось со свободными сборками — и что не изменилось.
Все замеры скорости сделаны на CPython 3.13.7, в двух сборках одного и того же патча: обычной и собранной с --disable-gil (единственное исключение — таблица значений getswitchinterval() ниже, она снята с шести разных сборок). Машина: Linux x86_64, 2 ядра. Два ядра — важная деталь: счётные ускорения ниже упираются в потолок ×2, и я нигде не выдаю это за общий закон. К ожиданию это не относится: там потолок задают не ядра, а число одновременных ожиданий.
Модель в голове
Совещание в переговорной. Людей много, микрофон один, и правило простое: говорит тот, у кого микрофон.
Это не «люди медленно говорят». Каждый говорит с обычной скоростью. Но если восьмерым нужно сказать по фразе, восемь фраз займут восемь интервалов, а не один — сколько бы стульев в комнате ни стояло.
У правила есть три следствия, и все три проверяемы:
- Есть регламент. Микрофон передают примерно раз в пять миллисекунд. Именно «примерно»: договорённость есть, принуждения нет.
- Есть исключение. Пока человек не говорит, а слушает или ищет бумажку в портфеле, микрофон он отдаёт. Ожидание не занимает очередь.
- Есть дыра в регламенте. Если кто-то начал читать вслух длинный документ, отобрать микрофон посреди абзаца некому. Регламент говорит «пять миллисекунд», реальность — «пока не дочитает».
Дальше вся статья — это три этих следствия, переведённые в числа.
Что такое GIL буквально
Определение из глоссария Python описывает механизм, а не метафору: это способ, которым интерпретатор CPython обеспечивает, чтобы байткод в каждый момент исполнял только один поток. Там же сказано, зачем: это делает объектную модель, включая такие типы, как dict, неявно безопасной при одновременном доступе.
И в том же абзаце глоссария стоят две оговорки, мимо которых обычно проходят:
- некоторые модули расширений «спроектированы так, чтобы отпускать GIL при вычислительно тяжёлых задачах вроде сжатия или хеширования»;
- «GIL всегда отпускается при вводе-выводе».
То есть половина утверждения «Python не умеет в параллелизм» опровергнута в самом определении GIL. К этому вернёмся в третьем разделе.
Само переключение живёт в Python/ceval_gil.c. Там же — константа:
#define DEFAULT_INTERVAL 5000Пять тысяч микросекунд, то есть 5 мс. Это то самое значение, которое возвращает sys.getswitchinterval(), и оно одинаково во всех проверенных сборках:
| Сборка | sys.getswitchinterval() |
|---|---|
| 3.10.20 | 0.005 |
| 3.11.15 | 0.005 |
| 3.12.3 | 0.005 |
| 3.13.13 | 0.005 |
| 3.14.7 | 0.005 |
| 3.13.7, сборка без GIL | 0.005 |
Последняя строка стоит отдельного взгляда: в сборке, где GIL выключен, настройка интервала переключения GIL по-прежнему существует и по-прежнему возвращает пять миллисекунд. Влияет ли она там на что-нибудь — вопрос, на который ниже есть измеренный ответ.
Первое: интервал работает буквально
Поставим опыт, который отвечает на вопрос «что вообще меняет setswitchinterval».
Два потока. Первый — «жадный»: считает в цикле и не отпускает интерпретатор добровольно. Второй — «быстрый»: в цикле делает time.sleep(0), то есть отдаёт управление и немедленно просит обратно, и записывает, сколько прошло. Записанное — это и есть время ожидания своей очереди.
def probe():
while not stop.is_set():
t0 = time.perf_counter()
time.sleep(0) # отдать GIL и попросить обратно
lags.append((time.perf_counter() - t0) * 1000)На диаграмме выше три синих столбика — та же зависимость, видимая без всякой статистики. Вот те же числа таблицей:
| Интервал | Медиана ожидания | p95 | Замеров |
|---|---|---|---|
| 0,5 мс | 0,717 мс | 0,858 мс | 2026 |
| 5 мс (по умолчанию) | 5,279 мс | 5,408 мс | 283 |
| 50 мс | 50,335 мс | 50,636 мс | 30 |
На 5 и 50 мс медиана расходится с настройкой на единицы процентов: +5,6 % и +0,67 %. На 0,5 мс расхождение уже +43 % — и это тоже результат, а не погрешность: чем чаще передают управление, тем большую долю кванта занимает сама передача. Настройка задаёт нижнюю границу ожидания, а не точное значение.
Отсюда практический вывод, который редко формулируют явно: GIL не отнимает у вас процессорное время — он добавляет задержку. Если у вас веб-сервер на потоках и один обработчик считает что-то тяжёлое на чистом Python, каждый следующий запрос получает лишнюю задержку — примерно один интервал переключения. Процессор при этом может быть свободен.
То же самое видно и с другой стороны — если смотреть не на ожидание, а на владение.
Медиана отрезка владения при интервале по умолчанию — 5,127 мс. Совпадение с настройкой почти дословное.
Но в этом же окне есть отрезок длиной 10,3 мс — два кванта подряд. И это не погрешность измерения, а вход во вторую часть истории.
Второе: интервал ничего не гарантирует
Рядом с DEFAULT_INTERVAL в исходнике стоит оговорка, которую стоит прочитать целиком: механизм encourages a defined switching period, but doesn't enforce it since opcodes can take an arbitrary time to execute
(поощряет заданный период переключения, но не обеспечивает его, поскольку выполнение одной операции может занять сколь угодно много времени).
Поощряет, но не обеспечивает — потому что одна операция может выполняться сколь угодно долго.
Документация sys.setswitchinterval говорит то же самое другими словами: значение задаёт идеальную длительность кванта, actual value can be higher, especially if long-running internal functions or methods are used
(фактическое значение может быть больше, особенно если используются долго работающие внутренние функции или методы), и — отдельным предложением — The interpreter doesn't have its own scheduler
(у интерпретатора нет собственного планировщика).
Проверим. Тот же опыт, тот же интервал 5 мс, но жадный поток теперь выполняет не миллион коротких операций, а одну длинную:
A = 7 ** 500_000 # 1 403 678 бит
B = 11 ** 500_000 # 1 729 716 бит
def one_big_mul():
A * B # одна операция, прервать её негде| Что делает жадный поток | Медиана ожидания | Максимум |
|---|---|---|
| много коротких операций | 5,293 мс | 6,598 мс |
| одно умножение больших целых | 167,756 мс | 217,305 мс |
один sorted() по 2 000 000 элементов | 185,619 мс | 379,826 мс |
Первая строка — отдельный прогон того же опыта, поэтому она не совпадает дословно с 5,279 мс из таблицы выше.
Медиана третьей строки больше медианы первой в тридцать пять раз. Худший замер там же — 379,826 мс, то есть в семьдесят шесть раз больше настроенного интервала в 5 мс. Сам интервал при этом не менялся ни разу.
Механизм здесь простой и от этого неприятный. Запрос на передачу управления выставляется флагом, а проверяет этот флаг цикл интерпретатора — между операциями. Внутри одной операции проверять некому: sorted() уходит в C-код и возвращается оттуда, когда закончил. Никакой «вытесняющей многозадачности» на этом уровне нет, и sys.setswitchinterval её не добавляет.
Пять миллисекунд — это не гарантия задержки. Это гарантия того, что вас не заставят ждать дольше из-за одного лишь интервала. Всё остальное зависит от того, во что успел войти сосед.
Практический след этого наблюдения такой: если у вас в потоковом сервере где-то есть sorted() по большому списку, регулярное выражение по мегабайтному тексту или арифметика с большими целыми — уменьшение switchinterval не поможет. Помогает только вынести это из потока: в процесс, в очередь, в библиотеку, которая отпускает GIL.
Третье: когда GIL вообще ни при чём
Теперь к оговоркам из глоссария. Три вида работы, две сборки, одинаковый код.
Читать эту таблицу стоит по строкам, а не по колонкам.
Чистый Python — ×0,97. Два потока сделали ту же работу за то же время — точнее, почти на три процента дольше: переключения не бесплатны. Это единственная строка, ради которой существует фраза «GIL мешает потокам».
Ожидание — ×7,86. Восемь time.sleep(0.05) последовательно занимают 0,4016 с, восемь потоков — 0,0511 с. Ядер для этого нужно ноль: потоки не считают, они ждут, а на время ожидания GIL отпущен. Именно поэтому «Python не умеет в потоки» никогда не мешало никому качать сто страниц одновременно.
zlib.compress — ×2,04. Здесь сравнение устроено иначе, и это важно: один поток сжимает буфер в 5 999 872 байта за 0,0564 с, два потока сжимают по такому же буферу каждый — и укладываются в 0,0553 с. Работы вдвое больше, времени столько же. Оба ядра заняты, GIL при этом не удерживается никем.
Почему так — написано в документации C API. Макрос Py_BEGIN_ALLOW_THREADS разворачивается в
{ PyThreadState *_save; _save = PyEval_SaveThread();а парный к нему Py_END_ALLOW_THREADS — в PyEval_RestoreThread(_save); }. Между ними C-код работает, не удерживая интерпретатор. Документация называет и конкретные модули: the standard
(стандартные модули zlib and hashlib modules detach the thread state when compressing or hashing datazlib и hashlib отсоединяют состояние потока на время сжатия или хеширования данных).
Отсюда следует правило, которое стоит держать в голове при выборе библиотеки: вопрос не «на Python это или на C», а «отпускает ли эта функция GIL». Расширение на C, которое честно считает под удерживаемым GIL, для потоков ничем не лучше чистого Python — оно просто быстрее в один поток.
Почему «отпускает GIL» не значит «масштабируется»
В таблице выше zlib.compress дал ×2,04 на двух ядрах — то есть ровно то, чего
и ждут. Но такой ответ получается не всегда, и совет «смотрите на
масштабирование» полезнее превратить в список причин, по которым его не будет.
Три причины документированы прямо; четвёртая, про которую чаще всего говорят, в
первоисточниках не подтверждается — и об этом честно ниже.
Первая: порог, ниже которого GIL не отпускается вообще. Формулировка «модуль отпускает GIL» бывает верна и при этом бесполезна:
To allow multithreading, the Python GIL is released while computing a hash
supplied more than 2047 bytes of data at once in its constructor or update
method.
Чтобы допустить многопоточность, GIL Python отпускается при вычислении хеша, если за один раз в конструктор или в метод update передано более 2047 байт данных.
Две тысячи байт — это порог на один вызов, а не на объём работы. Хеширование мегабайта кусками по килобайту не отпустит GIL ни разу, и масштабирования не будет — при том что «библиотека отпускает GIL» формально правда. Размер куска в таком коде оказывается настройкой параллелизма, и об этом обычно не думают.
Вторая: библиотека уже заняла ядра сама. Для NumPy это записано отдельно:
NumPy itself is normally intentionally limited to a single thread during function
calls, however it does support multiple Python threads running at the same time.
Note that for performant linear algebra NumPy uses a BLAS backend such as
OpenBLAS or MKL, which may use multiple threads that may be controlled by
environment variables such as OMP_NUM_THREADS depending on what is used.
Сам NumPy при вызовах функций обычно намеренно ограничен одним потоком, однако он поддерживает одновременную работу нескольких потоков Python. Учтите, что для производительной линейной алгебры NumPy использует бэкенд BLAS вроде OpenBLAS или MKL, который может использовать несколько потоков, управляемых переменными окружения вроде OMP_NUM_THREADS.
Четыре потока Python поверх BLAS, который сам берёт четыре потока на ядро, — это
переподписка, а не ускорение. Ручка тут не в Python, а в OMP_NUM_THREADS, и
крутить её надо до того, как удивляться числам.
Третья, и она не универсальна: не всякая операция отпускает GIL. Документация NumPy оговаривает границу прямо:
Note that operations that do not release the GIL will see no performance gains
from use of the threading module, and instead might be better served with
multiprocessing. In particular, operations on arrays with dtype=np.object_ do
not release the GIL.
Заметьте, что операции, которые GIL не отпускают, никакого выигрыша от threading не получат, и им может больше подойти multiprocessing. В частности, операции над массивами с dtype=np.object_ GIL не отпускают.
Массив объектов — это массив ссылок на объекты Python, и работать с ними без GIL
нельзя по тому самому правилу, с которого статья начиналась. Один dtype решает,
будет ли threading иметь смысл.
И четвёртая, которой мы не утверждаем. Полосу памяти как причину сублинейности называют
часто, и звучит она правдоподобно. Ни в документации NumPy, ни в C API CPython
такого утверждения нет; своего замера у нас тоже нет — на двух ядрах он был бы
неубедительным. Поэтому здесь остаётся то, что документировано: порог, переподписка
и dtype. Если ваш zlib даёт ×2,04, а не ×2,0, объяснять эту разницу полосой
памяти — гипотеза, а не факт.
Свободные потоки: что уже правда
Здесь легко переусердствовать в обе стороны, поэтому — по датам и по документам.
PEP 703, Sam Gross, статус Final, решение 24 октября 2023, Python 3.13. Добавляет флаг сборки --disable-gil и прямо фиксирует границу: The global interpreter lock will remain the default for CPython builds and python.org downloads
(глобальная блокировка интерпретатора останется значением по умолчанию для сборок CPython и загрузок с python.org). То есть уже в PEP, который «убирает GIL», сказано, что GIL остаётся значением по умолчанию.
PEP 779, Thomas Wouters, Matt Page и Sam Gross, статус Final, решение 16 июня 2025, Python 3.14. Это фаза II: свободная сборка официально поддержана, но по-прежнему опциональна. В разделах What's New для 3.14 это записано одной строкой: PEP 779: Free-threaded Python is officially supported
(PEP 779: свободная от GIL сборка Python официально поддерживается).
Фазы III — сделать свободную сборку значением по умолчанию — не произошло. PEP 779 говорит об этом сам: решение very different, and we expect it will revolve around community support, willingness, and showing clear benefit. That's left for a future PEP
(совсем иным, и мы полагаем, что оно будет строиться вокруг поддержки сообщества, его готовности и наглядно показанной пользы. Это оставлено будущему PEP). На момент написания такого PEP нет.
Что происходит прямо сейчас: 3.15 находится в стадии кандидата в релизы (rc1 вышел 4 августа 2026, финал назначен на 1 октября), и изменения там — про инструменты, а не про смену умолчания: PEP 803 добавляет стабильный ABI для свободных сборок (abi3t), у нового профилировщика Tachyon появляется режим --mode gil, а семейство PyGILState_* объявлено мягко устаревшим — без планов удаления и с сохранением работоспособности существующего кода.
Проверить, где вы находитесь, можно из самого интерпретатора:
import sys, sysconfig
sys._is_gil_enabled() # False в свободной сборке
sysconfig.get_config_var("Py_GIL_DISABLED") # 1 в свободной сборке, 0 в обычной
sys.abiflags # "t" в свободной сборкеИ — обещанный ответ на вопрос из раздела «Что такое GIL буквально»: что делает sys.setswitchinterval в сборке, где GIL выключен. Ответ — ничего. Тот же замер ожидания, три разных интервала:
| Интервал | Медиана ожидания, сборка с GIL | Медиана ожидания, сборка без GIL |
|---|---|---|
| 0,5 мс | 0,717 мс | 0,067 мс |
| 5 мс | 5,279 мс | 0,067 мс |
| 50 мс | 50,335 мс | 0,067 мс |
Правая колонка не реагирует на настройку — три значения совпадают до третьего знака, и это и есть содержательный результат. Настройка сохранилась, возвращает те же 0,005, и на поведение не влияет: очереди, длительностью которой она управляла, больше нет. Оставшиеся 0,067 мс — это уже стоимость самого time.sleep(0) и работы планировщика ОС, а не ожидание чего-либо в интерпретаторе.
И ещё одна деталь, о которой стоит знать заранее: свободная сборка умеет включать GIL обратно. Переменная PYTHON_GIL=1 или ключ -X gil=1 возвращают его. Проверено: PYTHON_GIL=1 python3.13t -c "import sys; print(sys._is_gil_enabled())" печатает True. Это не курьёз — так свободная сборка живёт с расширениями, которые к ней не готовы.
Чего свободная сборка стоит
Здесь начинается место, где легко соврать цифрой, поэтому сначала — источник, потом мои замеры, потом объяснение расхождения.
Официальный HOWTO называет цену прямо: The free-threaded build has additional overhead when executing Python code compared to the default GIL-enabled build
(свободная от GIL сборка несёт дополнительные накладные расходы при выполнении кода на Python по сравнению со сборкой с GIL, принятой по умолчанию), и дальше — диапазон на наборе pyperformance: from about 1% on macOS aarch64 to 8% on x86-64 Linux systems
(примерно от 1 % на macOS aarch64 до 8 % на системах x86-64 Linux).
Мои замеры дали больше. Прежде чем читать проценты, одна оговорка, без которой они соврут: это сравнение двух сборок, а надёжно сравнивать можно только измеренное внутри одного запуска одного интерпретатора. Подтвердить, что обе сборки различаются ровно --disable-gil и ничем больше, здесь нечем. Поэтому числа ниже — наблюдение на одной машине, а не цена свободных потоков вообще.
| Что мерялось | С GIL | Без GIL | Разница |
|---|---|---|---|
| арифметика в цикле, 6 000 000 операций | 0,2306 с | 0,2638 с | +14,4 % |
| чтение элементов списка в цикле в главном потоке, 3 000 000 раз | 0,1124 с | 0,1642 с | +46,1 % |
Это не опровержение официальной цифры, и подавать это как «на самом деле накладные расходы 46 %» было бы враньём. Разница объясняется тем, что мерялось: pyperformance — набор из разнородных программ, где интерпретатор занят не только исполнением байткода. Мои два теста — плотные циклы, состоящие ровно из тех операций, которые в свободной сборке подорожали. Это худший случай, а не средний.
Правильная формулировка звучит скучнее и полезнее: цена зависит от того, чем занята программа, и на плотном чистом байткоде она заметно выше средней по бенчмаркам.
Взамен свободная сборка даёт то, чего с GIL не бывает совсем: на тех же двух ядрах чистый Python в два потока дал ×1,76. Не ×2 — часть съедена той же самой ценой, что и в таблице выше, плюс машина двухъядерная и на ней есть кто-то ещё.
Неочевидная часть цены
А эта часть цены в документации не названа.
PEP 703 использует biased reference counting — подход, опирающийся на наблюдение, что most objects are only accessed by a single thread, even in multi-threaded programs
(к большинству объектов обращается только один поток — даже в многопоточных программах). У объекта есть поток-владелец, и операции со счётчиком ссылок из этого потока идут по быстрому пути, а из чужого — по общему.
Проверим, видно ли это снаружи. Опыт нарочно устроен так, чтобы конкуренции не было вообще: рабочий поток ровно один. Меняется только происхождение данных.
FOREIGN = make(0) # список создан в главном потоке
def worker_reads_foreign():
loop(FOREIGN, 3_000_000) # владелец объектов — другой поток
def worker_reads_own():
items = make(2) # список создан здесь же
loop(items, 3_000_000)| Сборка | Чужие объекты | Свои объекты | Отношение |
|---|---|---|---|
| с GIL | 0,1117 с | 0,1104 с | 1,012 |
| без GIL | 0,2057 с | 0,1783 с | 1,154 |
В сборке с GIL разницы нет — 1,2 % это шум. В свободной сборке чтение чужих объектов стоит на 15,4 % дороже, при том что второго потока не существует и бороться не с кем.
А если второй поток всё-таки появляется и оба читают одни и те же объекты, происходит вот что: два потока по 2 000 000 обращений каждый выполнили работу за 0,6108 с против 0,1376 с у одного потока. То есть вдвоём — медленнее, чем один, при вдвое большей работе итог ×0,45. По четырём прогонам это ×0,45–0,52.
Та же пара измерений в сборке с GIL даёт ×0,94–1,00 по тем же четырём прогонам. Разброс здесь больше самой величины, и это как раз и есть ответ: с GIL два потока на общих объектах не выигрывают ничего, но и не проигрывают — они просто стоят в очереди.
Практический вывод из этих трёх строк один, и он не про GIL: в свободной сборке важно, кто создал объект и кто его трогает. Разделяемое между потоками состояние из «неудобного» превращается в «дорогое», и «просто запустим в свободной сборке и получим ускорение» — не работает как стратегия.
Что с этим делать
Короткая карта решений, каждое привязано к измеренному выше.
Работа — ожидание (сеть, диск, база). Потоки уже работают, и уже давно. Измерено ×7,86 на восьми ожиданиях. GIL здесь не участвует. Свободная сборка ничего не добавит: ×7,74 — то же самое в пределах погрешности.
Работа — тяжёлые вычисления в библиотеке. Проверьте, отпускает ли она GIL. zlib и hashlib — да, это написано в документации; для остальных — смотрите на масштабирование, как в таблице выше. Если отпускает, потоки дадут ускорение по ядрам без всяких свободных сборок.
Работа — чистый Python, и её много. Процессы (multiprocessing, concurrent.futures.ProcessPoolExecutor) остаются рабочим ответом с 2008 года. Свободная сборка — второй ответ, и теперь официально поддержанный, но с оговорками из предыдущего раздела.
Проблема — не пропускная способность, а задержка. Вот здесь sys.setswitchinterval осмысленна, и таблица в начале статьи показывает, что именно вы покупаете и чем платите: уменьшение интервала в десять раз сократило медиану ожидания в 7,4 раза (5,279 → 0,717 мс) и примерно во столько же раз увеличило число передач управления: 283 замера против 2026 за то же время. Не в десять — часть выигрыша съедает сама передача управления. Но если задержку создаёт одна длинная операция, настройка бесполезна — это второй раздел.
И общее. Про GIL полезнее думать не как про «ограничение Python», а как про явную очередь, у которой есть настройка. Очередь видна, измеряется тремя строками кода и ведёт себя предсказуемо ровно до границы, которая тоже описана — в комментарии рядом с константой, задающей интервал.
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- GIL не «замедляет потоки». Он заставляет их ждать очереди, и длительность ожидания задаётся
sys.setswitchinterval. Измерено: при интервале 5 мс медиана ожидания — 5,279 мс, при 50 мс — 50,335 мс, при 0,5 мс — 0,717 мс. На двух верхних значениях промах — единицы процентов, на нижнем уже 43 %: там начинает стоить само переключение. - Ровно до того момента, когда работающий поток входит в одну длинную операцию. Тогда интервал не значит ничего: то же ожидание становится 167,8 мс на умножении больших целых и 379,8 мс в худшем случае на
sorted(). Это не баг — так написано в комментарии рядом с самой константой. - «Потоки в Python бесполезны» — утверждение про чистый байткод и только про него. Измерено на тех же двух ядрах: чистый Python — ×0,97,
time.sleep— ×7,86,zlib.compress— ×2,04. - Свободные сборки — не гипотеза и не эксперимент: PEP 779 сделал их официально поддерживаемыми в 3.14. Но значением по умолчанию они не стали, и PEP про это говорит прямо — решение о фазе III «оставлено будущему PEP».
- Цена свободной сборки реальна и заметна на микротестах сильнее, чем на общем наборе бенчмарков. И у неё есть неочевидная часть: чтение объекта, созданного другим потоком, обходится дороже даже когда никакой конкуренции нет. Измерено: +15,4 % при полном отсутствии второго потока.
На самом деле
- Бесполезными — только для чистого байткода. Измерено на одной и той же машине в два ядра: арифметика в цикле даёт ×0,97, восемь
time.sleep(0.05)— ×7,86,zlib.compress— ×2,04 (два потока сжимают по полному буферу каждый и укладываются в время одного). Две из трёх строк — про то, что GIL в происходящем не участвует: на вводе-выводе он отпускается всегда, аzlibиhashlibотсоединяют состояние потока на время работы, и это записано в документации C API. - Эффект прямой и воспроизводимый. Поток, который просит управление немедленно, ждёт примерно столько, сколько указано: медиана 0,717 мс при интервале 0,5 мс, 5,279 мс при 5 мс, 50,335 мс при 50 мс. На 5 и 50 мс промах — единицы процентов; на 0,5 мс уже 43 %, потому что там ощутима стоимость самой передачи управления. Непредсказуемым при этом становится не эффект настройки, а поведение соседа: если он вошёл в одну длинную операцию, настройка не действует вовсе.
- Он выставляет запрос на переключение через 5 мс, а проверяется этот запрос между операциями. Внутри одной операции проверять некому. Измерено при неизменном интервале в 5 мс: пока жадный поток выполняет много коротких операций, ожидание — 5,293 мс; на одном умножении целых чисел в 1,4 и 1,7 миллиона бит — 167,756 мс; на одном
sorted()по двум миллионам элементов — 185,619 мс, в худшем замере 379,826 мс. ВPython/ceval_gil.cрядом сDEFAULT_INTERVALэто сказано прямым текстом: механизм encourages a defined switching period, but doesn't enforce it. - В 3.13 появилась отдельная сборка с флагом
--disable-gil, и сам PEP 703 фиксирует границу: GIL остаётся значением по умолчанию для сборок CPython и загрузок с python.org. Официально поддержанной свободная сборка стала в 3.14 (PEP 779) — это фаза II, supported but still optional. Фаза III, то есть смена умолчания, в PEP 779 прямо оставлена будущему документу: That's left for a future PEP. Такого PEP на момент написания нет. - Она медленнее в один поток и не всегда быстрее в два. Официальный HOWTO называет диапазон 1–8 % на наборе pyperformance; на плотных циклах измерено больше — +14,4 % на арифметике и +46,1 % на чтении элементов списка, и это худший случай, а не средний. Ускорение при этом реальное: те же вычисления в два потока на двух ядрах дали ×1,76. Но два потока, читающие одни и те же объекты, дали ×0,45–0,52 по четырём прогонам — вдвоём медленнее, чем один; в сборке с GIL те же прогоны дают ×0,94–1,00, то есть ни выигрыша, ни проигрыша.
- Нет: у объекта появляется поток-владелец. PEP 703 использует biased reference counting, опираясь на то, что most objects are only accessed by a single thread. Разницу видно даже без конкуренции. Опыт с ровно одним рабочим потоком: чтение объектов, созданных в другом потоке, заняло 0,2057 с против 0,1783 с для собственных — на 15,4 % дороже. В сборке с GIL те же два случая различаются на 1,2 %, то есть не различаются.
- Внедрять постепенно можно: если расширение не готово, GIL включается обратно — переменной
PYTHON_GIL=1или ключом-X gil=1, иsys._is_gil_enabled()начинает печататьTrue(проверено на 3.13.7t). Свободного исполнения в этом режиме нет — есть работающая программа. В 3.15 к этому добавляется PEP 803: стабильный ABI для свободных сборок (abi3t), из-за которого расширению больше не нужно собираться отдельно под каждую версию.
Что разобрано
- Зачем это знать?
- Модель в голове
- Что такое GIL буквально
- Первое: интервал работает буквально
- Второе: интервал ничего не гарантирует
- Третье: когда GIL вообще ни при чём
- Почему «отпускает GIL» не значит «масштабируется»
- Свободные потоки: что уже правда
- Чего свободная сборка стоит
- Что с этим делать
- Чем измерено
Унести в работу
- Не отказываться от потоков там, где работа ждёт: на вводе-выводе и в расширениях вроде
zlibблокировка отпущена, и потоки помогают. - Не обещать переключения каждые пять миллисекунд: настройка задаёт, как часто владельца ПРОСЯТ уступить, а одно длинное неделимое действие растягивает ожидание в десятки раз.
- Не говорить, что в 3.13 GIL убрали: свободнопоточная сборка — отдельная, и её выбирают при установке.
- Свободнопоточную сборку мерить, а не предполагать: это не просто более быстрый Python, и на общих объектах два потока бывают медленнее одного.
- Расширения переносить постепенно: смешанная сборка работает, и разом переписывать всё не требуется.
Частые заблуждения
«GIL делает потоки в Python бесполезными».
Бесполезными — только для чистого байткода. Измерено на одной и той же машине в два ядра: арифметика в цикле даёт ×0,97, восемь time.sleep(0.05) — ×7,86, zlib.compress — ×2,04 (два потока сжимают по полному буферу каждый и укладываются в время одного). Две из трёх строк — про то, что GIL в происходящем не участвует: на вводе-выводе он отпускается всегда, а zlib и hashlib отсоединяют состояние потока на время работы, и это записано в документации C API.
«sys.setswitchinterval — тонкая настройка, эффект от неё непредсказуем».
Эффект прямой и воспроизводимый. Поток, который просит управление немедленно, ждёт примерно столько, сколько указано: медиана 0,717 мс при интервале 0,5 мс, 5,279 мс при 5 мс, 50,335 мс при 50 мс. На 5 и 50 мс промах — единицы процентов; на 0,5 мс уже 43 %, потому что там ощутима стоимость самой передачи управления. Непредсказуемым при этом становится не эффект настройки, а поведение соседа: если он вошёл в одну длинную операцию, настройка не действует вовсе.
«Интерпретатор переключает потоки каждые 5 мс».
Он выставляет запрос на переключение через 5 мс, а проверяется этот запрос между операциями. Внутри одной операции проверять некому. Измерено при неизменном интервале в 5 мс: пока жадный поток выполняет много коротких операций, ожидание — 5,293 мс; на одном умножении целых чисел в 1,4 и 1,7 миллиона бит — 167,756 мс; на одном sorted() по двум миллионам элементов — 185,619 мс, в худшем замере 379,826 мс. В Python/ceval_gil.c рядом с DEFAULT_INTERVAL это сказано прямым текстом: механизм encourages a defined switching period, but doesn't enforce it
(поощряет заданный период переключения, но не обеспечивает его).
«В Python 3.13 GIL убрали».
В 3.13 появилась отдельная сборка с флагом --disable-gil, и сам PEP 703 фиксирует границу: GIL остаётся значением по умолчанию для сборок CPython и загрузок с python.org. Официально поддержанной свободная сборка стала в 3.14 (PEP 779) — это фаза II, supported but still optional
(поддержана, но по-прежнему необязательна). Фаза III, то есть смена умолчания, в PEP 779 прямо оставлена будущему документу: That's left for a future PEP
(Это оставлено будущему PEP). Такого PEP на момент написания нет.
«Свободная сборка — это просто более быстрый Python».
Она медленнее в один поток и не всегда быстрее в два. Официальный HOWTO называет диапазон 1–8 % на наборе pyperformance; на плотных циклах измерено больше — +14,4 % на арифметике и +46,1 % на чтении элементов списка, и это худший случай, а не средний. Ускорение при этом реальное: те же вычисления в два потока на двух ядрах дали ×1,76. Но два потока, читающие одни и те же объекты, дали ×0,45–0,52 по четырём прогонам — вдвоём медленнее, чем один; в сборке с GIL те же прогоны дают ×0,94–1,00, то есть ни выигрыша, ни проигрыша.
«Без GIL все объекты становятся равноправными».
Нет: у объекта появляется поток-владелец. PEP 703 использует biased reference counting, опираясь на то, что most objects are only accessed by a single thread
(к большинству объектов обращается только один поток). Разницу видно даже без конкуренции. Опыт с ровно одним рабочим потоком: чтение объектов, созданных в другом потоке, заняло 0,2057 с против 0,1783 с для собственных — на 15,4 % дороже. В сборке с GIL те же два случая различаются на 1,2 %, то есть не различаются.
«Свободная сборка не умеет работать со старыми расширениями, поэтому её нельзя внедрять постепенно».
Внедрять постепенно можно: если расширение не готово, GIL включается обратно — переменной PYTHON_GIL=1 или ключом -X gil=1, и sys._is_gil_enabled() начинает печатать True (проверено на 3.13.7t). Свободного исполнения в этом режиме нет — есть работающая программа. В 3.15 к этому добавляется PEP 803: стабильный ABI для свободных сборок (abi3t), из-за которого расширению больше не нужно собираться отдельно под каждую версию.
Проверка знаний
Веб-сервер на потоках. Один обработчик считает что-то тяжёлое на чистом Python. Что произойдёт с остальными запросами при настройках по умолчанию?
Источники и что читать дальше
10 ИСТОЧНИКОВ
- Глоссарий Python — global interpreter lock, free threading, free-threaded buildОфициальная документация. Определение GIL слово в слово, включая две оговорки, вокруг которых строится половина статьи: расширения могут отпускать GIL на тяжёлых вычислениях, а на вводе-выводе он отпускается всегда.https://docs.python.org/3/glossary.html#term-global-interpreter-lock
- Python/ceval_gil.c — DEFAULT_INTERVAL и gil_drop_requestИсходный код CPython. #define DEFAULT_INTERVAL 5000 (микросекунды) и оговорка рядом: интервал «encourages a defined switching period, but doesn't enforce it since opcodes can take an arbitrary time to execute» (поощряет заданный период переключения, но не обеспечивает его, поскольку выполнение одной операции может занять сколь угодно много времени). Тег CPython 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Python/ceval_gil.c
- sys.setswitchinterval и sys.getswitchintervalОфициальная документация. «This floating-point value determines the ideal duration of the timeslices» (Это вещественное значение задаёт идеальную длительность квантов времени) и прямое предупреждение: «The interpreter doesn't have its own scheduler» (У интерпретатора нет собственного планировщика). Значение по умолчанию в документации не названо.https://docs.python.org/3/library/sys.html#sys.setswitchinterval
- C API — Thread State and the Global Interpreter LockОфициальная документация. Во что именно разворачивается Py_BEGIN_ALLOW_THREADS, и прямое указание, что zlib и hashlib отсоединяют состояние потока на время сжатия и хеширования.https://docs.python.org/3/c-api/threads.html
- Python support for free threading (HOWTO)Официальная документация. sys._is_gil_enabled(), переменная PYTHON_GIL и -X gil, и названная цена свободной сборки: «from about 1% on macOS aarch64 to 8% on x86-64 Linux systems» (примерно от 1 % на macOS aarch64 до 8 % на системах x86-64 Linux) на наборе pyperformance.https://docs.python.org/3/howto/free-threading-python.html
- PEP 703 — Making the Global Interpreter Lock Optional in CPythonPEP. Sam Gross, Python 3.13, статус Final, решение 24 октября 2023. Отсюда biased reference counting и прямая фраза, что GIL остаётся значением по умолчанию для сборок CPython и загрузок с python.org.https://peps.python.org/pep-0703/
- PEP 779 — Criteria for supported status for free-threaded PythonPEP. Thomas Wouters, Matt Page, Sam Gross; Python 3.14, статус Final, решение 16 июня 2025. Фаза II: официально поддержано, но по-прежнему опционально. Про фазу III сказано прямо: «That's left for a future PEP» (Это оставлено будущему PEP).https://peps.python.org/pep-0779/
- What's New In Python 3.14Официальная документация. Формулировка «PEP 779: Free-threaded Python is officially supported» (PEP 779: свободная от GIL сборка Python официально поддерживается) — момент, когда сборка перестала считаться экспериментальной.https://docs.python.org/3/whatsnew/3.14.html
- What's New In Python 3.15Официальная документация. PEP 803 («abi3t» — стабильный ABI для свободных сборок), режим --mode gil у профилировщика Tachyon и мягкое устаревание семейства PyGILState. Проверено на предварительной документации 3.15.https://docs.python.org/3.15/whatsnew/3.15.html
- PEP 790 — Python 3.15 Release SchedulePEP. Нужен, чтобы честно назвать статус 3.15 на момент написания: rc1 вышел 4 августа 2026, финальный релиз назначен на 1 октября 2026.https://peps.python.org/pep-0790/