Без GIL: гонки, которые он прятал
Один и тот же код на 3.14.10 и 3.14.10t. С GIL c.n += 1 в четыре потока в нашем замере не потерял ни одного приращения из четырёх миллионов, без GIL терял от 26 до 40 %. Гонка в этом коде была всегда: GIL просто не давал ей случиться.
Полное техническое изложение
TL;DR
- В обычном Python потоки по очереди держат один маркер и пишут на общей доске по одному. В новой, «свободной» сборке маркер есть у каждого, и пишут все сразу.
- Код, который считал что-то на общей доске, в обычной сборке годами работал без ошибок. В свободной он может терять часть счёта — в замере терял от 26 до 40 %.
- Ошибка в этом коде была всегда. Просто прежний порядок не давал ей случиться — и это видно, если чуть поменять код: тогда теряет и обычная сборка.
- Чинится это не переходом обратно, а тем, чтобы у каждого потока был свой счёт, или очередью, или явной блокировкой.
- Одна старая библиотека может тихо вернуть «один маркер на всех» для всей программы. Это надо проверять при запуске.
Одна доска, один маркер
Представьте вахтёров у четырёх дверей одного здания. На стене общая доска, на ней число — сколько человек вошло. Когда входит человек, вахтёр подходит к доске, читает число, прибавляет в уме единицу и пишет новое число поверх старого.
Вахтёр здесь — это поток, а доска — общие данные программы.
В обычном Python у вахтёров один маркер на всех. Писать может только тот, у кого он в руке. Передают маркер не в любой момент, а только в паузах — между двумя вошедшими или когда вахтёр отвлёкся, например пошёл позвать кого-то. Пока он читает число, прибавляет и пишет, пауз нет, и маркер у него никто не забирает.
Поэтому в таком коротком действии счёт на доске выходит верным. Не потому что так обещано, а потому что между «прочитал» и «записал» вахтёр здесь ни разу не отвлекается.
Правило «один маркер на всех» в Python называется GIL. На схемах ниже места, где маркер можно передать, отмечены как «точки проверки».
В свободной сборке Python маркер есть у каждого. Четыре вахтёра подходят к доске одновременно. Двое читают одно и то же число, оба прибавляют единицу, оба пишут — и один вошедший потерялся.
Это измерено
Четыре потока, каждый миллион раз прибавляет единицу. Должно получиться 4 000 000.
import threading
class Counter:
pass
c = Counter()
c.n = 0
def work():
for _ in range(1_000_000):
c.n += 1
threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(c.n) # в замере: с одним маркером — 4000000; с маркером у каждого — меньшеВ замере для статьи (тот же цикл, потоки стартуют одновременно) обычный Python 3.14.10 дал ровно 4 000 000 в пяти прогонах из пяти. Свободная сборка 3.14.10t потеряла от 26 до 40 % счёта, и каждый раз по-разному. Сколько потеряется в следующий раз, заранее не сказать — а один прогон без потерь ничего не доказывает.
Ошибка была и раньше
Самое важное в этой истории: обычная сборка не была правильной. Она просто не давала ошибке случиться.
Проверить это просто. Пусть вахтёр между «прочитал» и «записал» на секунду
отвлечётся — позовёт кого-нибудь. В коде это значит вызвать функцию посреди
сложения: c.n = c.n + one(). Теперь маркер успевает уйти к другому вахтёру,
тот пишет свои числа, а первый возвращается и пишет поверх своё, давно
устаревшее.
Обычная сборка в этом варианте потеряла от 50 до 69 % счёта — примерно столько же, сколько свободная. Значит, защиты не было. Было везение, что в самом коротком варианте вахтёр не отвлекался.
Две таблички с одним именем
Та же беда у запасов, которые делают по первому требованию. Допустим, у входа нужно повесить табличку с именем гостя, если её ещё нет. Вахтёр смотрит: таблички нет, идёт её делать. Пока он её делает, маркер иногда уходит к другому вахтёру — тот заглядывает, таблички нет, и он тоже идёт делать. Висят две таблички, и каждый вахтёр уверен, что его — правильная. С одним маркером так бывает редко, с маркером у каждого — сплошь и рядом.
В программе место, где висят таблички, называется кешем: туда складывают готовое, чтобы не делать дважды. Две разные таблички на одно имя — ошибка, которую не видно. Если в кеше лежат замки (то, что пускает к общим данным только одного), два потока держат два разных замка «на одного человека», и замок ничего не защищает.
Чем дольше делается табличка, тем чаще это случается без общего маркера. На пяти тысячах имён с долгим изготовлением обычная сборка ошиблась в 20–24 случаях, свободная — в нескольких тысячах.
Одна старая библиотека — и снова один маркер
В свободной сборке есть страховка от старых библиотек, которые не готовы к тому, что маркер у каждого. Если такую библиотеку подключить, Python без всякой ошибки возвращает правило «один маркер на всех» — для всей программы сразу — и пишет об этом одну строку предупреждения, которую легко потерять.
Программа при этом работает, просто перестаёт получать то, ради чего ставили свободную сборку: два потока снова считают не вдвое быстрее, а с той же скоростью, что один.
Что из этого следует
- У каждого потока — свой счёт, а сложить в конце. Это и правильно, и быстрее всего в замере.
- Если общее всё-таки нужно, договориться, что к доске подходят по одному:
«прочитал — изменил — записал» закрывать замком (
threading.Lock). - Работу раздавать потокам через очередь (
queue.Queue), а не одной общей стопкой заданий, из которой каждый тянет сам: в свободной сборке одно и то же задание достаётся нескольким потокам сразу. - При запуске программы проверять, что «маркер у каждого» действительно
включён:
sys._is_gil_enabled()должен вернутьFalse.
Подробный разбор — с инструкциями интерпретатора, итераторами, которые выдают числа за пределами диапазона, и двумя настройками, которые меняют результат без всякой гонки, — во вкладке «Подробно».
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. Правильный вариант известен заранее — «прочитал — прибавил — записал» под одной блокировкой:
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, с GIL | 3.14.10t, без GIL | 3.15.2t, без GIL |
|---|---|---|---|
c.n += 1 | 0 во всех пяти прогонах | от 26,1 до 40,4 % | от 25,2 до 32,9 % |
d["n"] += 1 | 0 во всех пяти прогонах | от 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; 0 | 0,538 |
| 3.14.10 | свой счётчик у потока | 0; 0; 0; 0; 0 | 0,090 |
| 3.14.10t | блокировка вокруг += | 0; 0; 0; 0; 0 | 1,005 |
| 3.14.10t | свой счётчик у потока | 0; 0; 0; 0; 0 | 0,051 |
| 3.15.2t | блокировка вокруг += | 0; 0; 0; 0; 0 | 0,873 |
| 3.15.2t | свой счётчик у потока | 0; 0; 0; 0; 0 | 0,049 |
В сборке 3.14.10t блокировка честно убирает потери, но четыре потока стоят в очереди за одной блокировкой четыре миллиона раз: 1,005 с против 0,538 с той же блокировки в сборке с GIL. Свой счётчик у каждого потока и сумма в конце не только правильны, но и быстрее всех вариантов в замере: 0,051 с без GIL против 0,090 с с GIL. Общего изменяемого состояния нет — делить нечего. (Часть выигрыша — от того, что в замере такой счётчик — локальная переменная, без обращения к атрибуту.)
Второе: проверка, потом действие
Ленивый кеш пишут так годами:
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–24 | 4288–4734 | 0 в обеих сборках |
| блокировка с повторной проверкой | 0 | 0 | 0 в обеих сборках |
bench/free-threading-races/check_then_act.py, по пять прогонов, 5000 ключей.
setdefault в списке безопасных операций словаря на странице о
потокобезопасности: вставляет, только если ключа нет, и возвращает то, что в
словаре оказалось. Раскола после него нет, но make() по-прежнему вызывается
лишний раз — без GIL это тысячи лишних вызовов на 5000 ключей. Если построение дорогое или
имеет побочные эффекты (открывает соединение, пишет в базу), нужна
блокировка: она убирает и раскол, и лишнюю работу.
Третье: один итератор на несколько потоков
Простой способ раздать работу потокам — дать им один итератор:
it = iter(tasks)
def worker():
for task in it:
handle(task)С GIL это работает: next() у встроенного итератора — одна операция на C,
и потоки разбирают элементы без повторов. Документация свободной сборки про
этот случай предупреждает отдельным разделом: threads may see duplicate or missing
elements
(потоки могут увидеть повторяющиеся или пропущенные элементы). Замер — четыре потока, миллион элементов:
| источник | 3.14.10, с GIL | 3.14.10t | 3.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_gil | GIL выключен, ×1,97 | GIL выключен, ×1,90 |
| расширение без слота | GIL выключен, ×1,96 | GIL включён, ×0,92 |
то же при PYTHON_GIL=0 | GIL выключен, ×1,88 | GIL выключен, ×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, с GIL | 3.14.10t, без GIL |
|---|---|---|
ContextVar в новом потоке | (unset) | req-42 |
Decimal(1) / Decimal(3) в потоке, запущенном внутри localcontext(prec=6) | 0.3333333333333333333333333333 | 0.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.
Что с этим делать
- Проверить, что GIL действительно выключен.
sys._is_gil_enabled()— после того как загружены расширения, в лог или в метрику. При обычном выборе GIL одно расширение, не заявившее поддержку свободной сборки (Py_mod_gilилиPyUnstable_Module_SetGIL), включает GIL для всего процесса, и сообщает об этом только предупреждение. - Искать «прочитал — изменил — записал» на общем состоянии.
+=на атрибуте, элементе словаря, глобальной переменной. С GIL такой код в замере не терял, пока между чтением и записью не было вызова, — но верным не был и тогда; без GIL он теряет. - Ленивые кеши — через
setdefaultили блокировку.setdefault, если лишнее построение дёшево и без побочных эффектов; блокировка с повторной проверкой, если построение дорогое или если в кеше лежат блокировки. - Работу раздавать очередью, а не общим итератором. Итератор, общий для нескольких потоков, без синхронизации раздаёт элементы повторно — и на 3.14, и на 3.15.
- Счётчики — свои у каждого потока. Это не только правильно, но и быстрее: общего изменяемого состояния нет, делить нечего.
- Перед переносом прогнать тесты на свободной сборке с несколькими
потоками. Часть гонок из этой статьи на сборке с GIL не видна вовсе:
c.n += 1— ноль потерь из четырёх миллионов пять раз подряд; другие видны в доли процента. И чистый прогон на свободной сборке — тоже не доказательство: гонка проявляется не в каждом запуске.
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- 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, а сообщение об этом — одно предупреждение при импорте.
На самом деле
- 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 случаях. Свободная сборка не создаёт гонку, а перестаёт её прятать.
- Одиночные операции над встроенными контейнерами защищены их собственными блокировками: страница о потокобезопасности перечисляет
lst.append(x),d[key] = value,d.setdefault(key, v),s.add(x)как безопасные из нескольких потоков. Небезопасны операции из нескольких обращений:+=, «проверил — сделал», обход при чужих изменениях. Это документированные гарантии конкретных операций CPython 3.14.10, а не правило языка, и у них есть оговорки: например, ключ с__eq__на Python может дать другому потоку изменить словарь во время сравнения. Составной инвариант защищают явно — руководство по свободной сборке советуетthreading.Lockи другие примитивы синхронизации. - Он решает раскол: после него все потоки получают один и тот же объект для ключа — ноль ключей с расколом в обеих сборках. Но
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.100.3333333333333333333333333333, а на 3.14.10t —0.333333: поток, запущенный внутриdecimal.localcontext(prec=6), наследует точность. Вернуть прежнее поведение можно ключами-X thread_inherit_context=0 -X context_aware_warnings=0.
Что разобрано
- Зачем это знать?
- Модель в голове: GIL не делал код атомарным
- Первое: `+=` — гонка, которой не было видно
- Второе: проверка, потом действие
- Третье: один итератор на несколько потоков
- Что свободная сборка гарантирует, а что нет
- Четвёртое: одно расширение возвращает GIL всему процессу
- Пятое: тот же код — другой результат
- Что с этим делать
- Чем измерено
Унести в работу
- Проверять, что 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.
Проверка знаний
Счётчик запросов в веб-сервисе на потоках: self.count += 1. На сборке с GIL за год ни одного расхождения. Что можно сказать о нём на свободной сборке 3.14t?
Источники и что читать дальше
7 ИСТОЧНИКОВ
- 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
- 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
- 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
- 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
- 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
- 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
- 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