Deep Engineering

ЗАМЕР

bench/free-threading-races/check_then_act.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/concurrency/free-threading-races

Запись прогона

Гонки без 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). Числа потерь от прогона к прогону разные — это и есть содержание замера, поэтому печатаются все пять прогонов, а не среднее. Времена — лучшее из пяти.

Скрипт

114 строк
"""Проверка, потом действие: ленивый кеш из нескольких потоков.

    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()