MEASUREMENT
bench/free-threading-races/check_then_act.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Гонки без GIL: замеры для статьи
Проверяется утверждение: код, который в сборке с GIL годами работал правильно, в свободной сборке ломается — хотя гонка в нём была всегда. GIL не делал операции атомарными; он лишь не давал потоку уступить управление в середине короткой последовательности инструкций.
Один и тот же код запускается на трёх интерпретаторах:
| сборка | что это |
|---|---|
3.14.10 |
обычная сборка, GIL включён |
3.14.10t |
свободная сборка той же версии, GIL выключен |
3.15.2t |
свободная сборка следующей версии: в ней исправлена гонка итератора range (cpython#129068) |
Как запустить
uv python install 3.14.10 3.14.10t 3.15.2t
./run.sh # записывает runs/<сборка>.txt
Нужен компилятор C (cc): gil_reenable.py собирает два крошечных
расширения из ext/ под тот интерпретатор, которым запущен.
| Скрипт | Что меряет |
|---|---|
bytecode.py |
инструкции между чтением и записью в двух формах приращения и есть ли среди них точка, где поток может уступить GIL |
lost_update.py |
сколько приращений из 4 000 000 теряется: c.n += 1, d["n"] += 1, c.n = c.n + one(); два исправления и их цена |
check_then_act.py |
ленивый кеш «проверил — записал»: два отдельных показателя — лишние вызовы make() (extra make() calls) и ключи, для которых потоки получили разные объекты (keys split); setdefault и блокировка. Раскол считается по самим объектам: каждый полученный объект удерживается до конца опыта, id сравниваются после join всех потоков |
shared_iterator.py |
один итератор range или списка на четыре потока: повторы, пропуски, значения вне источника; очередь как исправление |
gil_reenable.py |
импорт расширения многофазной инициализации без слота Py_mod_gil включает GIL для всего процесса: предупреждение и ускорение до и после (однофазный модуль заявляет поддержку вызовом PyUnstable_Module_SetGIL; здесь не меряется) |
context_flags.py |
thread_inherit_context и context_aware_warnings: тот же код даёт другой результат |
runs/3.14.10t-old-flags.txt — context_flags.py в свободной сборке с
ключами -X thread_inherit_context=0 -X context_aware_warnings=0: так
возвращается поведение сборки с GIL.
Машина замеров
2 vCPU (Intel Xeon; модель и частота — во второй строке каждой записи, текущие
записи сняты на 2.10GHz), Linux, интерпретаторы из python-build-standalone
(uv python install). Числа потерь от прогона к прогону разные — это и есть
содержание замера, поэтому печатаются все пять прогонов, а не среднее.
Времена — лучшее из пяти.
Script
114 lines"""Проверка, потом действие: ленивый кеш из нескольких потоков.
if key not in cache:
cache[key] = make(key)
return cache[key]
Четыре потока проходят одни и те же 5000 ключей с общего старта. Печатаются
два отдельных показателя:
- extra make() calls — лишних вызовов make: сколько раз значение для ключа
строилось сверх одного раза на ключ;
- keys split — ключей с расколом: для скольких ключей потоки получили
РАЗНЫЕ объекты.
Первое не определяет второе: лишнее построение, результат которого выброшен
(так делает setdefault), раскола не даёт.
КАК СЧИТАЕТСЯ РАСКОЛ. Каждый поток сохраняет в seen[i][k] сам полученный
объект, а не его id(), и все ссылки живут до конца опыта; id сравниваются
только после join всех потоков. Сохранять одно число id нельзя: id уникален
лишь на время жизни объекта (library/functions.html#id), объект, вытесненный
из кеша чужой записью, умирает, и его адрес может достаться следующему
объекту. Тогда два разных объекта с непересекающимися временами жизни
выглядят в seen одним, и раскол недосчитывается. Удерживая объекты, мы
сравниваем только одновременно живые — у них id заведомо различны.
Второе опаснее первого: если в кеше лежат блокировки «по ключу», раскол
значит, что два потока держат разные блокировки одного ключа, то есть
взаимного исключения нет вовсе.
`work` — сколько итераций пустого цикла делает make, то есть ширина окна между
проверкой и записью. Блок 2 — два исправления на самом широком окне.
"""
import threading
from common import RUNS, THREADS, env_line, fmt_int, header, run_threads
KEYS = 5_000
def spin(work):
s = 0
for i in range(work):
s += i
return s
def trial(work, mode):
cache = {}
made = [] # list.append атомарен в обеих сборках (library/threadsafety)
lock = threading.Lock()
seen = [dict() for _ in range(THREADS)]
def make(k):
spin(work)
made.append(k)
return object()
def naive(i):
for k in range(KEYS):
if k not in cache:
cache[k] = make(k)
seen[i][k] = cache[k] # сам объект, не id: см. шапку
def with_setdefault(i):
for k in range(KEYS):
v = cache.get(k)
if v is None:
v = cache.setdefault(k, make(k))
seen[i][k] = v
def with_lock(i):
for k in range(KEYS):
v = cache.get(k)
if v is None:
with lock:
v = cache.get(k)
if v is None:
v = cache[k] = make(k)
seen[i][k] = v
target = {"naive": naive, "setdefault": with_setdefault, "lock": with_lock}[mode]
run_threads(target, args_for=lambda i: (i,))
# Все потоки завершены, а seen по-прежнему держит каждый полученный объект:
# сравниваются id одновременно живых объектов.
extra_make_calls = len(made) - KEYS
split_keys = sum(1 for k in range(KEYS) if len({id(s[k]) for s in seen}) > 1)
return extra_make_calls, split_keys
def show(label, results):
extra_make_calls = [e for e, _ in results]
split_keys = [s for _, s in results]
print(f"{label:<12} extra make() calls: {extra_make_calls}; keys split: {split_keys}")
def main():
print(env_line())
print(f"threads {THREADS}, keys {fmt_int(KEYS)}, runs {RUNS}")
print("keys split: every object a thread got is kept alive until all threads")
print("have joined, then compared by id()")
header(1, "NAIVE CACHE: EXTRA BUILDS AND SPLIT KEYS")
for work in (0, 100, 1000):
show(f"work={work}", [trial(work, "naive") for _ in range(RUNS)])
header(2, "FIXES AT THE WIDEST WINDOW (1000 ITERATIONS)")
show("setdefault", [trial(1000, "setdefault") for _ in range(RUNS)])
show("lock", [trial(1000, "lock") for _ in range(RUNS)])
if __name__ == "__main__":
main()