ЗАМЕР
bench/gil/measure.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/python/concurrency/gil
- Как запустить
python3.13 bench/gil/measure.py python3.13t bench/gil/measure.py
Запись прогона
Замеры для статьи «GIL: что это на самом деле»
Ничего не моделируется — только прогоны на живых интерпретаторах. Каждый
скрипт печатает JSON в поток вывода; summary.json — собранный вручную свод
тех прогонов, из которых взяты числа статьи.
Почему этот каталог появился поздно
Скрипты лежали в tmp/, а tmp/ стоит в .gitignore — то есть в репозитории
их не было вовсе, и числа статьи нельзя было перезапустить ни на другой машине,
ни на этой же через полгода. Правило «числа в статьях берутся отсюда, а не из
головы» для статей в этот момент было не выполнено, хотя для уроков
выполнялось. Перенос — исправление именно этого.
| Скрипт | Что меряет |
|---|---|
measure.py |
базовое: сборка, sys.getswitchinterval(), счёт в один и два потока, ожидание, zlib |
freethreading_cost.py |
цена свободной сборки на однопоточном коде и там, где она проигрывает |
shared_vs_owned.py |
общий объект против собственного; объекты собираются внутри потока и из значений времени выполнения |
owner_thread.py |
один рабочий поток, объект чужой против своего — проверка biased reference counting (PEP 703) |
interval_not_enforced.py |
«5 мс» — пожелание, а не гарантия: медиана ожидания против интервала на коротких и на длинных операциях |
timeline.py |
кто владел интерпретатором в каждый момент — данные для визуализации |
Запускать на каждом интерпретаторе отдельно:
python3.13 bench/gil/measure.py
python3.13t bench/gil/measure.py
Что этими замерами сравнивать нельзя
Общее правило в силе и здесь: время между версиями не сравнивается. Но у статьи про GIL есть собственная оговорка, и её надо держать в голове отдельно.
Строки вида «+14,4 %» получены сравнением двух сборок — обычной и
свободнопоточной. Формально это то же самое сравнение между сборками, которое
правило запрещает: подтвердить, что обе собраны одинаково и различаются ровно
--disable-gil, нечем — свободнопоточного интерпретатора на машине замеров
нет.
Поэтому такие проценты читаются как наблюдение на одной машине, а не как цена свободных потоков вообще. Именно так они и подписаны в тексте статьи. У официальной оценки («1–8 % на pyperformance») другое происхождение и другой набор нагрузок, и статья прямо говорит, что не опровергает её, а показывает худший случай.
Скрипт
141 строк"""Замеры для статьи про GIL. Ничего не моделируется — только прогоны.
Запускать на каждом интерпретаторе отдельно:
python3.13 gil-measure.py
python3.13t gil-measure.py
"""
import json, os, statistics, sys, threading, time, zlib
REPS = 5
def build():
try:
gil = sys._is_gil_enabled()
except AttributeError:
gil = True
return {
"version": sys.version.split()[0],
"gil_enabled": bool(gil),
"switch_interval_default": sys.getswitchinterval(),
"cpu_count": os.cpu_count(),
}
def spin(n):
# Чистый python-байткод: GIL не отпускается.
x = 0
for i in range(n):
x += i * i
return x
def timed(fn, *a):
t = time.perf_counter()
fn(*a)
return time.perf_counter() - t
def run_threads(target, args_list):
ts = [threading.Thread(target=target, args=a) for a in args_list]
t0 = time.perf_counter()
for t in ts: t.start()
for t in ts: t.join()
return time.perf_counter() - t0
def cpu_bound(n=4_000_000):
"""Одна и та же работа: один поток против двух."""
one = min(run_threads(spin, [(n,)]) for _ in range(REPS))
two = min(run_threads(spin, [(n // 2,), (n // 2,)]) for _ in range(REPS))
return {"work": n, "one_thread_s": one, "two_threads_s": two,
"speedup": one / two}
def io_bound(sleep=0.05, k=8):
"""sleep отпускает GIL — потоки складываются."""
def nap(): time.sleep(sleep)
seq = min(sum(timed(nap) for _ in range(k)) for _ in range(3))
par = min(run_threads(lambda: nap(), [() for _ in range(k)]) for _ in range(3))
return {"calls": k, "sleep_each_s": sleep,
"sequential_s": seq, "threads_s": par, "speedup": seq / par}
def c_extension(size=6_000_000):
"""zlib.compress отпускает GIL внутри C-кода (Py_BEGIN_ALLOW_THREADS)."""
data = bytes(range(256)) * (size // 256)
def work(): zlib.compress(data, 6)
one = min(run_threads(lambda: work(), [()]) for _ in range(3))
two = min(run_threads(lambda: work(), [(), ()]) for _ in range(3))
return {"bytes": len(data), "one_thread_s": one, "two_threads_s": two,
"speedup_vs_double_work": (2 * one) / two}
def latency_under_load(interval, duration=1.5):
"""Классический вопрос: как долго ждёт «быстрый» поток, пока «медленный» считает.
Оба потока — python-байткод. Замеряется, насколько поздно просыпается
поток, который хотел получить управление немедленно.
"""
old = sys.getswitchinterval()
sys.setswitchinterval(interval)
stop = threading.Event()
lags = []
def hog():
while not stop.is_set():
spin(200_000)
def probe():
while not stop.is_set():
t0 = time.perf_counter()
time.sleep(0) # отдать GIL и попросить его обратно
lags.append((time.perf_counter() - t0) * 1000)
h = threading.Thread(target=hog); p = threading.Thread(target=probe)
h.start(); p.start()
time.sleep(duration)
stop.set(); h.join(); p.join()
sys.setswitchinterval(old)
lags.sort()
return {"switch_interval_s": interval, "samples": len(lags),
"median_ms": statistics.median(lags),
"p95_ms": lags[int(len(lags) * 0.95)],
"max_ms": lags[-1]}
def ownership_timeline(duration=0.30, nthreads=2):
"""Кто владел интерпретатором в каждый момент.
Каждый поток крутит счётчик и раз в N итераций записывает
(id потока, время). Между двумя соседними отметками одного потока
интерпретатор принадлежал ему.
"""
stop = threading.Event()
marks = []
lock = threading.Lock()
def worker(tid):
local = []
x = 0
while not stop.is_set():
for _ in range(2000):
x += 1
local.append((tid, time.perf_counter()))
with lock:
marks.extend(local)
ts = [threading.Thread(target=worker, args=(i,)) for i in range(nthreads)]
t0 = time.perf_counter()
for t in ts: t.start()
time.sleep(duration)
stop.set()
for t in ts: t.join()
marks.sort(key=lambda m: m[1])
marks = [(tid, round((t - t0) * 1000, 4)) for tid, t in marks]
# Сколько раз владение переходило от одного потока к другому.
switches = sum(1 for a, b in zip(marks, marks[1:]) if a[0] != b[0])
return {"duration_ms": round((time.perf_counter() - t0) * 1000, 2),
"marks": len(marks), "handoffs": switches,
"sample": marks[:400]}
out = {"build": build()}
out["cpu_bound"] = cpu_bound()
out["io_bound"] = io_bound()
out["c_extension"] = c_extension()
out["latency"] = [latency_under_load(i) for i in (0.005, 0.05, 0.0005)]
out["timeline"] = ownership_timeline()
print(json.dumps(out, ensure_ascii=False))