Deep Engineering

ЗАМЕР

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

Скрипт

61 строк
"""Байткод двух форм приращения: где у потока есть шанс уступить GIL.

В сборке с GIL поток отдаёт блокировку только в точках проверки
(`_CHECK_PERIODIC` в Python/bytecodes.c): на RESUME при входе в функцию,
после CALL, на JUMP_BACKWARD в конце тела цикла. Скрипт печатает инструкции
между чтением атрибута и записью в него и отмечает, есть ли среди них такая
точка. CALL помечен как точка проверки потому, что вызов её содержит: у
функции на C проверка выполняется в хвосте CALL, у функции на Python — в
RESUME на входе в неё (для one() срабатывает именно эта, и в этот список она
не попадает: она уже в теле вызываемой функции).
"""
import dis

from common import env_line, header

CHECKPOINTS = {"CALL", "RESUME", "JUMP_BACKWARD"}


class Box:
    pass


def one():
    return 1


def plus_equals(c):
    c.n += 1


def call_between(c):
    c.n = c.n + one()


def window(fn):
    """Инструкции от первого LOAD_ATTR n до STORE_ATTR n включительно."""
    ins = [i for i in dis.get_instructions(fn)]
    start = next(k for k, i in enumerate(ins) if i.opname == "LOAD_ATTR" and i.argval == "n")
    end = next(k for k, i in enumerate(ins) if i.opname == "STORE_ATTR" and i.argval == "n")
    return ins[start:end + 1]


def main():
    print(env_line())
    for n, title, fn in [
        (1, "INCREMENT WITH NO CALL BETWEEN READ AND WRITE", plus_equals),
        (2, "INCREMENT WITH A CALL BETWEEN READ AND WRITE", call_between),
    ]:
        header(n, title)
        span = window(fn)
        for i in span:
            mark = "  <- checkpoint" if i.opname in CHECKPOINTS else ""
            arg = f" {i.argrepr}" if i.argrepr else ""
            print(f"  {i.opname}{arg}{mark}")
        hits = sum(1 for i in span if i.opname in CHECKPOINTS)
        print(f"checkpoints between the read and the write: {hits}")


if __name__ == "__main__":
    main()