Deep Engineering
Продвинутый·Опубликовано·3.14 · 3.15·30 МИН

Без GIL: гонки, которые он прятал

Один и тот же код на 3.14.10 и 3.14.10t. С GIL c.n += 1 в четыре потока в нашем замере не потерял ни одного приращения из четырёх миллионов, без GIL терял от 26 до 40 %. Гонка в этом коде была всегда: GIL просто не давал ей случиться.

Полное техническое изложение

TL;DR

  • GIL никогда не гарантировал атомарность c.n += 1. Он делал другое: поток уступал управление только в определённых точках проверки, а в нашем опыте — обычный атрибут, встроенный int, CPython 3.14.10 — между чтением и записью такой точки нет. Поэтому гонка была, а потерь не было: 0 из 4 000 000 во всех пяти прогонах. Это результат опыта, а не обещание языка.
  • Без GIL тот же код потерял от 26 до 40 % приращений на 3.14.10t, а d["n"] += 1 — от 56 до 67 %.
  • Проверка, что защиты не было: поставьте вызов функции между чтением и записью — c.n = c.n + one(). Сборка с GIL теряет от 50 до 69 % — сопоставимо со свободной.
  • Ленивый кеш «проверил — записал»: из 5000 ключей с GIL потоки получили разные объекты для одного ключа в 20–24 случаях, без GIL — в 2646–4035 на 3.14.10t. setdefault убирает раскол, блокировка убирает и лишнюю работу.
  • Один итератор на четыре потока раздаёт элементы повторно, а итератор range на 3.14.10t выдаёт даже числа за концом диапазона. На 3.15.2t это исправлено, повторы у итератора списка остались.
  • Одно расширение, не заявившее поддержку свободной сборки (слотом Py_mod_gil или вызовом PyUnstable_Module_SetGIL), включает GIL обратно для всего процесса: ускорение в два потока падает с ×1,96 до ×0,92, а сообщение об этом — одно предупреждение при импорте.

Зачем это знать?

Свободная сборка с 3.14 официально поддерживается, и перенос на неё сервисов перестал быть экспериментом. Ожидание у переноса обычно одно: код станет быстрее или останется прежним. На деле у части кода меняется результат.

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

Ниже пять замеров. Каждый — один и тот же скрипт на трёх интерпретаторах (там, где это имеет смысл): 3.14.10 с GIL, 3.14.10t без GIL и 3.15.2t без GIL следующей версии. Машина — два ядра, потоков в замерах четыре (в замере с расширением — два).

Модель в голове: GIL не делал код атомарным

Распространённое представление: пока есть GIL, одна строка Python выполняется целиком, без вмешательства других потоков. Это неверно. GIL гарантирует, что в каждый момент байткод исполняет один поток, но не то, что поток доведёт до конца строку, которую начал.

Поток отдаёт GIL не где попало. Запрос «уступи» он проверяет в определённых инструкциях: в Python/bytecodes.c проверка (_CHECK_PERIODIC, в RESUME — её вариант _CHECK_PERIODIC_IF_NOT_YIELD_FROM) входит в состав RESUME — входа в функцию, JUMP_BACKWARD — перехода в начало цикла, и CALL — после вызова функции на C; у функции на Python проверка срабатывает в RESUME на входе в неё. Между двумя такими точками поток работает без перерыва, сколько бы инструкций там ни было, — если ни одна из них не отпускает GIL сама (ввод-вывод, ожидание блокировки) и ни одна не запускает чужой код. А запустить его может и простая на вид инструкция: LOAD_ATTR у свойства, BINARY_OP у типа со своим __add__, STORE_ATTR, после которой умирает старое значение с __del__, — всё это исполняет Python-код со своими RESUME и CALL.

Поэтому с GIL вопрос «потеряется ли приращение» на деле был вопросом «есть ли на пути исполнения точка проверки» — для конкретного кода, конкретных типов и конкретной сборки. Это свойство реализации CPython, а не гарантия языка: атомарности += Python не обещает ни с GIL, ни без. Без GIL вопрос другой: два потока исполняют байткод одновременно на разных ядрах, и любая последовательность из нескольких инструкций может перемешаться с чужой.

Документация свободной сборки говорит об этом коротко. Страница о потокобезопасности встроенных типов — уже в первом абзаце: When the GIL is enabled, most operations are implicitly serialized (Когда GIL включён, большинство операций неявно выполняются по очереди), а ниже по тексту: Operations that involve multiple accesses, as well as iteration, are never atomic (Операции из нескольких обращений, как и обход, атомарными не бывают никогда).

Первое: += — гонка, которой не было видно

Четыре потока, каждый миллион раз прибавляет единицу к одному атрибуту. Инвариант, который надо сохранить, простой: итог равен числу сделанных приращений, то есть 4 000 000. Правильный вариант известен заранее — «прочитал — прибавил — записал» под одной блокировкой:

PYTHON
lock = threading.Lock()
 
def work():
    for _ in range(1_000_000):
        with lock:
            c.n += 1

Сам += блокировка не меняет: она не пускает второй поток в промежуток между чтением и записью, пока там первый, — если все потоки берут одну и ту же блокировку. Сколько она стоит — ниже, в «Чем лечится». А теперь вариант без неё — и почему с GIL он не терял ничего.

Условия опыта: c — экземпляр пустого класса Box, n — встроенный int, ни свойств, ни своих __add__ и __del__; интерпретатор CPython 3.14.10, точки проверки — по Python/bytecodes.c того же тега. Вот что лежит между чтением и записью:

1. INCREMENT WITH NO CALL BETWEEN READ AND WRITE
------------------------------------------------
  LOAD_ATTR n
  LOAD_SMALL_INT
  BINARY_OP +=
  SWAP
  STORE_ATTR n
checkpoints between the read and the write: 0

Пять инструкций, и в этих условиях ни одна не проверяет запрос уступить GIL и не запускает Python-код. Значит, в этом опыте другому потоку в сборке с GIL негде вклиниться между LOAD_ATTR и STORE_ATTR. (Скрипт отмечает точки проверки по именам инструкций в теле этой функции — это иллюстрация к опыту, а не разбор всего исполняемого кода.) Так и выходит:

операция3.14.10, с GIL3.14.10t, без GIL3.15.2t, без GIL
c.n += 10 во всех пяти прогонахот 26,1 до 40,4 %от 25,2 до 32,9 %
d["n"] += 10 во всех пяти прогонахот 56,3 до 67,0 %от 28,8 до 67,0 %
c.n = c.n + one()от 49,6 до 69,2 %от 51,3 до 57,4 %от 38,8 до 53,4 %

Доля потерянных приращений из 4 000 000 по пяти прогонам, bench/free-threading-races/lost_update.py.

Первые две строки — то, что видно при переходе: код, который в этом замере не терял ни одного приращения, на 3.14.10t теряет примерно от четверти до двух третей. Сами потери от прогона к прогону различаются: у c.n += 1 на 3.14.10t — в полтора раза, у d["n"] += 1 на 3.15.2t — больше чем вдвое. Это тоже результат: такая ошибка не воспроизводится одним и тем же числом, и прогон без потерь не доказывает, что потерь не бывает.

Гонка была всегда

Третья строка — главное в этом разделе. Форма c.n = c.n + one() делает то же самое, но между чтением и записью стоит вызов:

2. INCREMENT WITH A CALL BETWEEN READ AND WRITE
-----------------------------------------------
  LOAD_ATTR n
  LOAD_GLOBAL one + NULL
  CALL  <- checkpoint
  BINARY_OP +
  LOAD_FAST_BORROW c
  STORE_ATTR n
checkpoints between the read and the write: 1

Появилась точка проверки — и сборка с GIL теряет от 50 до 69 %, сопоставимо со свободной (от 51 до 57 % на 3.14.10t). Механизм такой: поток прочитал c.n, ушёл в one(), там отдал GIL по запросу ждущего потока, и пока он ждал своей очереди, другие потоки прибавляли. Потом первый вернулся и записал своё старое значение плюс один. Вся чужая работа за это время исчезла одной записью.

То есть GIL не исправлял эту гонку. Он сужал окно, в которое она может случиться, до места, где поток уступает управление, — а в c.n += 1 такого места просто не оказалось. Любая правка, которая добавит вызов между чтением и записью (логирование, свойство вместо атрибута, __add__ у своего типа), возвращает гонку и в сборку с GIL.

Чем лечится и сколько стоит

Замер bench/free-threading-races/lost_update.py, блок 4: пять прогонов по четыре потока, потери и лучшее время из пяти.

сборкаисправлениепотери за пять прогоновлучшее время, с
3.14.10блокировка вокруг +=0; 0; 0; 0; 00,538
3.14.10свой счётчик у потока0; 0; 0; 0; 00,090
3.14.10tблокировка вокруг +=0; 0; 0; 0; 01,005
3.14.10tсвой счётчик у потока0; 0; 0; 0; 00,051
3.15.2tблокировка вокруг +=0; 0; 0; 0; 00,873
3.15.2tсвой счётчик у потока0; 0; 0; 0; 00,049

В сборке 3.14.10t блокировка честно убирает потери, но четыре потока стоят в очереди за одной блокировкой четыре миллиона раз: 1,005 с против 0,538 с той же блокировки в сборке с GIL. Свой счётчик у каждого потока и сумма в конце не только правильны, но и быстрее всех вариантов в замере: 0,051 с без GIL против 0,090 с с GIL. Общего изменяемого состояния нет — делить нечего. (Часть выигрыша — от того, что в замере такой счётчик — локальная переменная, без обращения к атрибуту.)

Второе: проверка, потом действие

Ленивый кеш пишут так годами:

PYTHON
def get(key):
    if key not in cache:
        cache[key] = make(key)
    return cache[key]

Между проверкой и записью стоит вызов make(), то есть точки проверки есть и в сборке с GIL — всё время работы make(). Окно одно и то же в обеих сборках. Разница в том, как часто в него попадает второй поток. С GIL для этого нужно переключение. В этом замере потоки ничего не ждут и не блокируются, и GIL переходит по запросу ждущего потока, а ориентир для запроса — интервал переключения, по умолчанию 5 мс. Это цель планирования, а не нижняя граница: поток, который блокируется на вводе-выводе или ждёт блокировку, отдаёт GIL раньше, а фактический интервал бывает и длиннее. Без GIL потоки внутри окна работают одновременно: два потока на разных ядрах проверили ключ, оба не нашли, оба строят.

Четыре потока, 5000 ключей. С пустым make() окна почти нет ни там, ни там. С make() на тысячу итераций лишних вызовов make() с GIL — 20–24, без GIL — 2682–4038 на 3.14.10t. И почти всё это — ключи, для которых потоки получили разные объекты: 20–24 с GIL и 2646–4035 без GIL.

Раскол считается по самим объектам: каждый поток держит всё, что получил, до конца прогона, и только после этого объекты сравниваются по id(). Хранить одни id нельзя: id уникален лишь на время жизни объекта, и адрес умершего объекта, вытесненного из кеша, может достаться новому — тогда два разных объекта выглядели бы одним.

Раскол опаснее лишней работы. Если в кеше лежат блокировки «по ключу» — locks[user_id], — раскол значит, что два потока держат две разные блокировки одного пользователя, то есть взаимного исключения нет. Ошибки при этом не видно: каждая блокировка честно захвачена.

Два исправления на самом широком окне:

исправлениелишних вызовов make(), с GILбез GIL (3.14.10t)ключей с расколом
cache.get(key), при промахе — cache.setdefault(key, make(key))15–244288–47340 в обеих сборках
блокировка с повторной проверкой000 в обеих сборках

bench/free-threading-races/check_then_act.py, по пять прогонов, 5000 ключей.

setdefault в списке безопасных операций словаря на странице о потокобезопасности: вставляет, только если ключа нет, и возвращает то, что в словаре оказалось. Раскола после него нет, но make() по-прежнему вызывается лишний раз — без GIL это тысячи лишних вызовов на 5000 ключей. Если построение дорогое или имеет побочные эффекты (открывает соединение, пишет в базу), нужна блокировка: она убирает и раскол, и лишнюю работу.

Третье: один итератор на несколько потоков

Простой способ раздать работу потокам — дать им один итератор:

PYTHON
it = iter(tasks)
 
def worker():
    for task in it:
        handle(task)

С GIL это работает: next() у встроенного итератора — одна операция на C, и потоки разбирают элементы без повторов. Документация свободной сборки про этот случай предупреждает отдельным разделом: threads may see duplicate or missing elements (потоки могут увидеть повторяющиеся или пропущенные элементы). Замер — четыре потока, миллион элементов:

источник3.14.10, с GIL3.14.10t3.15.2t
iter(range(N))без повторовповторов 9 207–54 405, значений за концом диапазона 954–57 022без повторов
iter(list(...))без повтороввыдано 2,32–2,64 млн вместо 1 млнвыдано 2,30–2,78 млн вместо 1 млн

bench/free-threading-races/shared_iterator.py, по пять прогонов.

У итератора range на 3.14.10t есть значения, которых в источнике нет вовсе: range(1_000_000) выдал числа от миллиона и выше — до 57 022 штук за прогон, и ни одного отрицательного. Это не «повтор или пропуск», о которых предупреждает документация, а гонка внутри самого итератора: следующее значение и число оставшихся хранятся в двух полях, которые обновляются не вместе. В трекере CPython это cpython#129068; исправлено в 3.15 и в 3.14 не перенесено — замер на 3.15.2t чистый.

Итератор списка ведёт себя так же и в 3.15 — ровно как предупреждает документация: общий итератор без синхронизации может выдавать элементы повторно. В замере каждый элемент получили в среднем двое-трое потоков из четырёх. Раздавать работу нужно очередью: тот же замер через queue.SimpleQueue даёт ровно миллион без повторов во всех трёх сборках.

Что свободная сборка гарантирует, а что нет

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

  • одиночные операции не портят контейнер: lst.append(x), lst.pop(), d[key] = value, del d[key], d.pop(key), d.setdefault(key, v), s.add(x);
  • чтение одного элемента атомарно: lst[i], d[key], d.get(key), key in d;
  • атомарными не бывают никогда «операции из нескольких обращений» — d[k] += 1, c.n += 1, «проверил — сделал», обход при чужих изменениях.

Важно, что это за страница. В редакции документации, которая соответствует нашей сборке 3.14.10, руководство по свободной сборке за гарантиями встроенных типов отсылает именно к ней. Это документированные гарантии конкретных операций CPython, а не правило языка Python, и они не превращают последовательность «проверил — вычислил — записал» в одну атомарную операцию — об этом разделы выше. У самих гарантий есть оговорки: операции словаря сравнивают ключи через __eq__, и если у ключа он написан на Python, во время сравнения словарь может изменить другой поток. Совет руководства — где возможно, пользоваться threading.Lock и другими примитивами синхронизации, а не полагаться на внутренние блокировки встроенных типов.

Четвёртое: одно расширение возвращает GIL всему процессу

У свободной сборки есть страховка от расширений на C, которые не готовы к работе без GIL. Расширение должно явно заявить о готовности, и способ зависит от того, как модуль инициализируется: при многофазной инициализации — слотом Py_mod_gil со значением Py_MOD_GIL_NOT_USED, при однофазной — вызовом PyUnstable_Module_SetGIL. Если не заявило, то при обычном выборе GIL (без PYTHON_GIL=0 и -X gil=0) импорт will raise a warning and enable the GIL at runtime (выдаст предупреждение и включит GIL во время работы).

Чтобы увидеть это без сторонних пакетов, в замере два крошечных расширения на C, оба с многофазной инициализацией. Они отличаются строкой слотов и именем модуля. Счёт на чистом Python в два потока против одного, до импорта и после:

импортдо импортапосле импорта
расширение со слотом Py_mod_gilGIL выключен, ×1,97GIL выключен, ×1,90
расширение без слотаGIL выключен, ×1,96GIL включён, ×0,92
то же при PYTHON_GIL=0GIL выключен, ×1,88GIL выключен, ×1,93

bench/free-threading-races/gil_reenable.py, 3.14.10t, лучшее из пяти прогонов. В первой и третьей строках GIL не включался, и разница между «до» и «после» — в пределах разброса замера: ×1,97 и ×1,90, ×1,88 и ×1,93, в разные стороны. На 3.15.2t готовое расширение дало ×1,82 до импорта и ×1,89 после.

Важно, что GIL включается для всего процесса и до конца его жизни: одна зависимость где-нибудь в глубине дерева импортов — и сервис, ради которого ставили свободную сборку, работает как обычная сборка, только примерно на 5–10 % медленнее в один поток (так оценивает цену свободной сборки What's New в Python 3.14). Сообщение об этом — одно предупреждение RuntimeWarning при импорте, которое легко потерять в логах запуска.

PYTHON_GIL=0 оставляет GIL выключенным. Этот выход подсказывает само предупреждение — с оговоркой at your own risk (на свой страх и риск). Расширение, которое не заявило готовность, могло рассчитывать на GIL как на защиту своего состояния — с PYTHON_GIL=0 этой защиты нет. Поэтому третья строка таблицы — диагностический опыт, а не исправление: неготовое расширение чинят в нём самом (объявить поддержку, когда оно к ней готово) или заменяют.

Пятое: тот же код — другой результат

Последняя группа изменений — не гонки, а два флага, у которых в свободной сборке другие значения по умолчанию. What's New в Python 3.14 говорит о каждом из них одними словами: defaults to true for the free-threaded build and false for the GIL-enabled build (по умолчанию включён в свободной сборке и выключен в сборке с GIL).

что проверяется3.14.10, с GIL3.14.10t, без GIL
ContextVar в новом потоке(unset)req-42
Decimal(1) / Decimal(3) в потоке, запущенном внутри localcontext(prec=6)0.33333333333333333333333333330.333333
warnings.warn в соседнем потоке, пока главный держит simplefilter("error")исключениепредупреждение

bench/free-threading-races/context_flags.py.

Первые две строки — thread_inherit_context: поток стартует с копией контекста того, кто вызвал start(). Отсюда и request_id, и точность decimal: настройка, сделанная в главном потоке, приезжает в рабочий. Третья — context_aware_warnings: в сборке с GIL catch_warnings меняет общий для процесса список фильтров, и фильтр одного потока действует на все остальные.

Здесь свободная сборка ведёт себя разумнее: контекст наследуется, фильтры не протекают между потоками. Но код, который на старое поведение опирался, даёт другой результат без единой гонки. Вернуть его можно двумя ключами: -X thread_inherit_context=0 -X context_aware_warnings=0 — с ними 3.14.10t печатает ровно то же, что сборка с GIL.

Что с этим делать

  1. Проверить, что GIL действительно выключен. sys._is_gil_enabled() — после того как загружены расширения, в лог или в метрику. При обычном выборе GIL одно расширение, не заявившее поддержку свободной сборки (Py_mod_gil или PyUnstable_Module_SetGIL), включает GIL для всего процесса, и сообщает об этом только предупреждение.
  2. Искать «прочитал — изменил — записал» на общем состоянии. += на атрибуте, элементе словаря, глобальной переменной. С GIL такой код в замере не терял, пока между чтением и записью не было вызова, — но верным не был и тогда; без GIL он теряет.
  3. Ленивые кеши — через setdefault или блокировку. setdefault, если лишнее построение дёшево и без побочных эффектов; блокировка с повторной проверкой, если построение дорогое или если в кеше лежат блокировки.
  4. Работу раздавать очередью, а не общим итератором. Итератор, общий для нескольких потоков, без синхронизации раздаёт элементы повторно — и на 3.14, и на 3.15.
  5. Счётчики — свои у каждого потока. Это не только правильно, но и быстрее: общего изменяемого состояния нет, делить нечего.
  6. Перед переносом прогнать тесты на свободной сборке с несколькими потоками. Часть гонок из этой статьи на сборке с GIL не видна вовсе: c.n += 1 — ноль потерь из четырёх миллионов пять раз подряд; другие видны в доли процента. И чистый прогон на свободной сборке — тоже не доказательство: гонка проявляется не в каждом запуске.

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

Унести в работу

  • Проверять, что GIL действительно выключен, после загрузки расширений: sys._is_gil_enabled() в лог или метрику. При обычном выборе GIL импорт расширения, не заявившего поддержку свободной сборки (слотом Py_mod_gil при многофазной инициализации или PyUnstable_Module_SetGIL при однофазной), включает GIL для всего процесса.
  • Не считать x += 1 на общем объекте безопасным только потому, что с GIL он ни разу не потерял приращения: в нашем замере без GIL тот же код терял десятки процентов, а прогон без потерь корректности не доказывает.
  • Ленивый кеш писать через setdefault или блокировку с повторной проверкой, а не «проверил — записал»: иначе потоки получают разные объекты для одного ключа.
  • Раздавать работу потокам очередью, а не общим итератором: без синхронизации итератор выдаёт элементы повторно и на 3.14, и на 3.15.
  • Перед переносом на свободную сборку прогнать многопоточные тесты именно на ней: часть гонок из этой статьи на сборке с GIL не проявляется вовсе — ноль потерь из четырёх миллионов пять раз подряд.

Частые заблуждения

Утверждение

«Пока есть GIL, одна строка Python выполняется атомарно».

На самом деле

GIL гарантирует, что байткод в каждый момент исполняет один поток, но не то, что поток доведёт начатую строку до конца. Уступить управление поток может в точках проверки — на входе в функцию, после вызова, в конце тела цикла. В опыте статьи — обычный атрибут, встроенный int, CPython 3.14.10 — в c.n += 1 такой точки между чтением и записью нет, поэтому замер даёт ноль потерь из 4 000 000. Это объясняет опыт, но не делает += атомарным: свойство или свой __add__ добавят точку проверки. В c.n = c.n + one() она есть — и та же сборка с GIL теряет от 50 до 69 % приращений.

Утверждение

«Свободная сборка ломает правильный код».

На самом деле

Код из этой статьи был неправильным и с GIL: гонка в нём есть всегда, GIL лишь сужал окно, в которое она может случиться. Это видно по ленивому кешу: с GIL потоки получили разные объекты для одного ключа в 20–24 случаях из 5000 — редко, но не ноль. Без GIL на 3.14.10t — в 2646–4035 случаях. Свободная сборка не создаёт гонку, а перестаёт её прятать.

Утверждение

«Без GIL небезопасно вообще всё: list.append и запись в словарь тоже надо закрывать блокировкой».

На самом деле

Одиночные операции над встроенными контейнерами защищены их собственными блокировками: страница о потокобезопасности перечисляет lst.append(x), d[key] = value, d.setdefault(key, v), s.add(x) как безопасные из нескольких потоков. Небезопасны операции из нескольких обращений: +=, «проверил — сделал», обход при чужих изменениях. Это документированные гарантии конкретных операций CPython 3.14.10, а не правило языка, и у них есть оговорки: например, ключ с __eq__ на Python может дать другому потоку изменить словарь во время сравнения. Составной инвариант защищают явно — руководство по свободной сборке советует threading.Lock и другие примитивы синхронизации.

Утверждение

«setdefault полностью решает гонку ленивого кеша».

На самом деле

Он решает раскол: после него все потоки получают один и тот же объект для ключа — ноль ключей с расколом в обеих сборках. Но make(key) всё равно вычисляется до вызова setdefault, и на 3.14.10t на 5000 ключей пришлось 4288–4734 лишних вызова make(), результат которых выбрасывался. Если построение дорогое или с побочными эффектами, нужна блокировка с повторной проверкой: в замере у неё ноль и раскола, и лишних построений.

Утверждение

«Если расширение не поддерживает свободную сборку, импорт упадёт с ошибкой».

На самом деле

Импорт проходит. При обычном выборе GIL свободная сборка включает его обратно — для всего процесса и до конца его жизни — и печатает одно предупреждение RuntimeWarning. Замер на 3.14.10t: до импорта такого расширения счёт в два потока даёт ×1,96, после — ×0,92, а sys._is_gil_enabled() возвращает True. Программа работает, просто уже как обычная сборка.

Утверждение

«Свободная сборка отличается только скоростью; результат программы тот же».

На самом деле

Два флага в 3.14 по умолчанию включены только в свободной сборке: thread_inherit_context и context_aware_warnings. Один и тот же код даёт на 3.14.10 0.3333333333333333333333333333, а на 3.14.10t — 0.333333: поток, запущенный внутри decimal.localcontext(prec=6), наследует точность. Вернуть прежнее поведение можно ключами -X thread_inherit_context=0 -X context_aware_warnings=0.

Проверка знаний

Вопрос 1 из 4

Счётчик запросов в веб-сервисе на потоках: self.count += 1. На сборке с GIL за год ни одного расхождения. Что можно сказать о нём на свободной сборке 3.14t?

Источники и что читать дальше

7 ИСТОЧНИКОВ

  1. Python support for free threading (HOWTO), CPython 3.14.10Официальная документация. Редакция тега v3.14.10 — той же версии, что сборки замеров. Раздел Thread safety: встроенные dict, list и set защищены внутренними блокировками; за гарантиями раздел отсылает к странице Thread Safety Guarantees и советует, где возможно, пользоваться threading.Lock, а не полагаться на внутренние блокировки. Раздел Iterators: при общем итераторе «threads may see duplicate or missing elements» (потоки могут увидеть повторяющиеся или пропущенные элементы). Тег CPython 3.14.10.https://github.com/python/cpython/blob/v3.14.10/Doc/howto/free-threading-python.rst
  2. Thread Safety Guarantees — встроенные типы в свободной сборке, CPython 3.14.10Официальная документация. Документированные гарантии конкретных операций list, dict и set в CPython (в v3.14.10 страница лежит в Doc/builtins/). Отсюда три фразы статьи: «When the GIL is enabled, most operations are implicitly serialized» (Когда GIL включён, большинство операций неявно выполняются по очереди), d.setdefault(key, v) в списке безопасных операций и «Operations that involve multiple accesses, as well as iteration, are never atomic» (Операции из нескольких обращений, как и обход, атомарными не бывают никогда). Там же оговорка: операции словаря сравнивают ключи через __eq__, и для ключей с __eq__ на Python словарь во время сравнения может изменить другой поток. Тег CPython 3.14.10.https://github.com/python/cpython/blob/v3.14.10/Doc/builtins/threadsafety.rst
  3. C API Extension Support for Free Threading (HOWTO)Официальная документация. Расширение обязано явно заявить, что может работать без GIL — слотом Py_mod_gil или вызовом PyUnstable_Module_SetGIL; «otherwise importing the extension will raise a warning and enable the GIL at runtime» (иначе импорт расширения выдаст предупреждение и включит GIL во время работы).https://docs.python.org/3.14/howto/free-threading-extensions.html
  4. What's New In Python 3.14 — Free-threaded mode improvementsОфициальная документация. Флаги context_aware_warnings и thread_inherit_context: о каждом сказано «defaults to true for the free-threaded build and false for the GIL-enabled build» (по умолчанию включён в свободной сборке и выключен в сборке с GIL). Там же — цена свободной сборки на однопоточном коде, «roughly 5-10%» (примерно 5–10 %).https://docs.python.org/3.14/whatsnew/3.14.html
  5. Python/bytecodes.c — _CHECK_PERIODIC в RESUME, CALL и JUMP_BACKWARDИсходный код CPython. Тег v3.14.10 — та же версия, что сборки замеров. Места, где поток проверяет запрос уступить GIL: микрооперация _CHECK_PERIODIC входит в состав CALL (после вызова функции на C) и JUMP_BACKWARD (конец тела цикла), её вариант _CHECK_PERIODIC_IF_NOT_YIELD_FROM — в состав RESUME (вход в функцию). В инструкциях LOAD_ATTR, BINARY_OP и STORE_ATTR его нет. Это свойство реализации, а не языка: такая инструкция всё равно может запустить Python-код (свойство, свой __add__, __del__ у вытесненного значения), и тогда точка проверки — внутри него. Тег CPython 3.14.10.https://github.com/python/cpython/blob/v3.14.10/Python/bytecodes.c
  6. sys.setswitchinterval — интервал переключения потоковОфициальная документация. Интервал задаёт «the ideal duration of the "timeslices"» (желаемую длительность „кванта“) потоков, и «the actual value can be higher» (фактическое значение может быть больше). Это цель планирования, а не наименьшее время между передачами GIL: поток, который блокируется на вводе-выводе или ждёт блокировку, отдаёт GIL раньше.https://docs.python.org/3.14/library/sys.html#sys.setswitchinterval
  7. cpython#129068 — Race in concurrent iteration over range iteratorsИсточник. Гонка в итераторе range: следующее значение и число оставшихся хранятся в двух полях, которые обновляются не вместе. Исправлено в основной ветке, то есть в 3.15; в 3.14 не перенесено. Замер статьи это подтверждает: на 3.14.10t итератор выдаёт значения за концом диапазона, на 3.15.2t — нет.https://github.com/python/cpython/issues/129068