Глобальный лок — не отдельное решение, а цена детерминированного разрушения из урока 09. Здесь: что он защищает на самом деле, какие операции атомарны, почему «атомарно» не значит «правильно», и что меняет free-threading.
Зафиксируй вывод и причину до того, как откроешь ответ.
import sys
print(sys.getswitchinterval())0.005Пять миллисекунд. Что именно происходит каждые пять миллисекунд?out = []
def appender():
for i in range(50_000): out.append(i)
# четыре потока разом200000 элементов — потерь нетОбщий список, четыре потока, никаких блокировок. И всё корректно.cache = {}
def expensive(k):
time.sleep(0.01) # любой I/O
calls.append(k)
return k * 2
def naive(k):
if k not in cache:
cache[k] = expensive(k)
return cache[k]
# восемь потоков зовут naive(7)expensive() вызвана 8 раз вместо одногоТот же общий словарь, та же безопасность операций — и всё сломано.import time, threading
import numpy as np
def py_work(n=8_000_000): # каждая итерация требует GIL
t = 0
for i in range(n):
t += i
return t
M = np.random.rand(1600, 1600)
def np_work(): # BLAS считает вне интерпретатора
return M @ M
def compare(fn, label, repeat=3):
fn() # прогрев
def wall(k):
best = float("inf")
for _ in range(repeat):
ths = [threading.Thread(target=fn) for _ in range(k)]
t0 = time.perf_counter()
for t in ths: t.start()
for t in ths: t.join()
best = min(best, time.perf_counter() - t0)
return best
one, two = wall(1), wall(2)
print(f"{label}: одна {one:.2f} c → две в потоках {two:.2f} c → ускорение {2*one/two:.2f}×")
compare(py_work, "Python")
compare(np_work, "numpy ")Python: одна 0.24 c → две в потоках 0.48 c → ускорение 0.99×
numpy : одна 0.12 c → две в потоках 0.12 c → ускорение 1.98×Один интерпретатор, одни потоки, разный результат.Урок 09 показал, откуда GIL взялся: счётчик ссылок не атомарен, и глобальный лок оказался самым дешёвым способом это починить. Здесь — как он устроен и что из этого следует ежедневно.
GIL — обычный мьютекс плюс условная переменная. Держать его обязан любой поток, исполняющий байткод.
Поток отдаёт лок сам: уходя в ожидание, входя в C-код, который его отпустил, или по требованию — когда другой поток попросил и истёк интервал переключения.
Переключение возможно только между инструкциями байткода, а не внутри них.
Фрагмент A. Интервал переключения работает не так, как кажется. Поток, желающий получить лок, выставляет флаг-просьбу и ждёт. Держатель проверяет флаг между инструкциями и, если тот стоит дольше интервала, отпускает лок.
Отсюда важное: пока держатель не дошёл до границы инструкции, никакая просьба не действует. Одна долгая операция в C, не отпустившая лок, блокирует всех на всё своё время, сколько бы ни было выставлено интервалов.
Фрагмент B. list.append корректен без блокировок не по волшебству: он выполняется целиком внутри одной инструкции байткода, а переключение между инструкциями — единственная возможная точка. То же для dict[k] = v, set.add, чтения и записи атрибута.
Это удобно и это ловушка, к которой ведёт следующее следствие.
Фрагмент C — главный в уроке. Каждая операция здесь безопасна: проверка вхождения атомарна, запись в словарь атомарна. Сломана последовательность: между «проверил, что нет» и «записал» поток может быть вытеснен, и восемь потоков делают одну и ту же дорогую работу восемь раз.
Причём именно вызов expensive с любым ожиданием внутри и создаёт окно: уходя в I/O, поток отпускает лок гарантированно. То есть чем «правильнее» написан код с точки зрения асинхронности, тем шире окно гонки.
lock = threading.Lock()
def cached(k):
if k not in cache: # быстрая проверка без лока
with lock:
if k not in cache: # повторная проверка под локом
cache[k] = expensive(k)
return cache[k]
# → expensive() вызвана 1 раз
Двойная проверка — не украшение: первая позволяет не брать лок на горячем пути, вторая закрывает окно. Правило: GIL защищает целостность структур интерпретатора и ничего не обещает про твои инварианты.
Фрагмент D повторяет замер из урока 15, и теперь он объясняется полностью. Чистый Python держит лок всё время — два потока просто чередуются. numpy отпускает лок на время работы в C — потоки идут параллельно и дают 1.9×.
Отсюда рабочий критерий, заменяющий фольклор «потоки в Python бесполезны»: потоки полезны ровно настолько, насколько твоя нагрузка проводит время вне интерпретатора — в ожидании или в C-коде, который лок отпустил.
Убрать лок мешает то, ради чего он появился: неатомарный счётчик в каждом объекте плюс тысячи расширений, рассчитывающих на монополию. Сборка без GIL (PEP 703, экспериментально с 3.13) решает это тремя приёмами.
Смещённый подсчёт ссылок: счётчик разделён на локальный для потока-создателя и разделяемый для остальных, так что типичный случай остаётся неатомарным. Бессмертные объекты (PEP 683): у None, True, мелких целых счётчик не двигается вовсе, убирая главные точки конкуренции. Блокировки на объект для контейнеров.
Цена — другой ABI и отдельные колёса (урок 16), плюс замедление однопоточного кода. И главное для практики: код, чья корректность держалась на неявной атомарности из следствия 2, там ломается — а такого кода написано много.
Что именно атомарно — деталь реализации 🔧. Список «атомарных операций» кочует по интернету, но он про конкретный компилятор байткода конкретной версии. Опираться на него в коде нельзя; правильный ответ — блокировка или очередь.
GIL не про безопасность данных. Он не защищает твои структуры от логических гонок и не заменяет транзакции. Он гарантирует лишь, что сам интерпретатор не развалится.
Один GIL на интерпретатор, а не на процесс. С 3.12 (PEP 684) субинтерпретаторы могут иметь по собственному локу — параллелизм в одном процессе без потоков и без общей памяти. Пока с ограничениями и малой поддержкой расширений.
Корень R3: временем жизни управляет счётчик ссылок. GIL — не самостоятельное архитектурное решение, а следствие: из трёх способов сделать счётчик потокобезопасным (атомарные операции, лок на объект, один глобальный лок) выбран третий как единственный дешёвый и совместимый с уже написанными расширениями.
Хронологически это 1992 год, машины одноядерные, а язык задумывался как клей для C-библиотек. Решение было очевидно правильным и оставалось таким лет пятнадцать. Проблемой оно стало не потому, что было ошибкой, а потому, что изменилось железо.
Попытки убрать лок предпринимались с 1999 года (проект Гвидо, давший двукратное замедление однопоточного кода) и все упирались в одно: счётчик ссылок торчит в публичном C API, а вокруг него выросла экосистема. То, что сделало Python языком численных вычислений, зафиксировало глобальный лок на тридцать лет — и PEP 703 стал возможен только тогда, когда нашли способ не ломать эту экосистему целиком.
Точная аналогия — атомарный счётчик в std::shared_ptr, и она полезна тем, что показывает развилку целиком. Обе реализации решают одну задачу: сделать подсчёт ссылок безопасным при многопоточности. C++ выбрал атомарные операции и платит на каждом копировании умного указателя во всех программах, включая однопоточные. CPython выбрал один лок и платит невозможностью параллелить байткод.
Ни один вариант не бесплатен, и это стоит держать в голове, когда GIL называют недоработкой: альтернатива исполнена в соседнем языке и имеет свою цену, которую там тоже регулярно ругают.
Второй полезный образ — большой лок ядра в ранних SMP-версиях Unix и Linux: один лок на всё ядро, чтобы не переписывать код под многопроцессорность сразу. Работало, пока ядер было мало; убирали десятилетиями, по подсистеме за раз. Free-threading — ровно эта же история, только применённая к интерпретатору.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что убирать надо было не GIL, а одно из двух решений под ним — и оба уже вросли в экосистему.
GIL не самостоятельное решение. Он стоит на пересечении двух других: счётчик ссылок как модель памяти (R3) и C API как способ расширения (R6). Счётчик неатомарен, а каждое расширение на C двигает его без блокировки — значит нужен глобальный лок, иначе счётчики поедут и объекты начнут освобождаться дважды.
Попытки были, и все упирались в одно и то же:
Каждый раз получалось: выигрыш у меньшинства, которое считает на Python в потоках, за счёт большинства, у которого один поток. Гвидо сформулировал условие прямо: однопоточная производительность не должна пострадать.
PEP 703 закрыл вопрос через двадцать лет — но ценой отдельной сборки с отдельным ABI, отложенным освобождением и biased reference counting. Не переключатель, а вторая версия интерпретатора. Это тоже R9: сломать существующие расширения нельзя.
Потому что GIL защищает внутренности интерпретатора, а не вашу логику. Граница атомарности — инструкция байткода, а не строка кода.
x += 1 — это как минимум четыре инструкции: прочитать, загрузить константу, сложить, записать. Между любыми двумя интерпретатор может отдать GIL другому потоку.
Более коварный случай — «проверить и сделать», где гонка проявляется не потерей данных, а лишней работой:
import threading
cache = {}
calls = 0
def expensive(key):
global calls
calls += 1
return key * 2
def get(key):
if key not in cache: # проверили
cache[key] = expensive(key) # ...и только потом записали
return cache[key]
ts = [threading.Thread(target=get, args=("k",)) for _ in range(8)]
for t in ts: t.start()
for t in ts: t.join()
print(calls) # → обычно больше единицы
Восемь потоков одновременно не нашли ключ и восемь раз посчитали. Данные не испорчены — просто работа сделана восемь раз вместо одного. В проде это выглядит как восемь одинаковых запросов к базе на один промах кеша.
И отдельно: список «атомарных операций», кочующий по интернету 🔧 — он описывает компилятор байткода конкретной версии. Специализация в 3.11 уже сдвинула границы инструкций. Опираться на него нельзя; правильный ответ всегда Lock или очередь.
Пока нет, если только вы не упираетесь именно в CPU-bound Python в потоках и не готовы проверять всю зависимость целиком.
Что даёт (PEP 703, сборка python3.13t и дальше): настоящий параллелизм чистого Python-кода по ядрам. То, чего не было тридцать лет.
Чем платят прямо сейчас:
Когда переходить. Когда профиль показывает, что время уходит в питоновские вычисления, и процессы не подходят из-за объёма передаваемых данных (урок 20). Во всех остальных случаях сначала стоит проверить более дешёвые пути: numpy отпускает GIL и даёт 1.9× на двух ядрах уже сейчас, процессы работают везде, а asyncio решает задачу ожидания.
Разумная стратегия на ближайшее время — собирать расширения так, чтобы они работали в обеих сборках, и следить за матрицей колёс своих зависимостей.
list.append атомарны просто потому, что укладываются в одну инструкцию, — но атомарность операции не даёт корректности сценария: между проверкой и записью поток вытесняется, и наивный кэш вызывает дорогую функцию восемь раз вместо одного. Потоки полезны ровно настолько, насколько нагрузка проводит время вне интерпретатора: чистый цикл не ускоряется вовсе, numpy даёт 1.9×, потому что отпускает лок. Free-threading убирает лок ценой другого ABI и ломает код, чья корректность держалась на неявной атомарности.C не даёт никаких гарантий вообще: разделяемые данные защищает программист, и цена ошибки — гонка данных с неопределённым поведением. Python со своим GIL находится на противоположном полюсе: гарантирована целостность интерпретатора, но не твоих инвариантов. Иными словами, GIL убирает целый класс аварий (порча внутренних структур) и оставляет весь класс логических гонок из следствия 3.
Показательно, что C++ с его std::shared_ptr столкнулся с той же задачей и выбрал первый вариант из трёх: счётчик там атомарный. И заплатил ровно тем, чего боялись в CPython, — атомарный инкремент дороже обычного, и в однопоточном коде за это платят все. Это буквально альтернативная ветка того же решения, исполненная в соседнем языке.
Java пошла третьим путём: трассирующая сборка вместо счётчика, поэтому глобальный лок не нужен, потоки настоящие. Цена — недетерминированное разрушение и паузы сборщика (урок 09).
Go сделал параллелизм основой: вытесняющий планировщик, горутины по ядрам, а гонки ловятся детектором в тестах. И честно оставил ту же проблему из следствия 3: -race существует потому, что логические гонки никуда не исчезают.
Ленивая инициализация или мемоизация без блокировки. Локально работает, под нагрузкой делает лишнюю работу — иногда очень дорогую.
_conn = None
def get_connection():
global _conn
if _conn is None:
_conn = create_connection() # секунда, идёт по сети
return _conn
Классический ленивый синглтон. Проверка атомарна, присваивание атомарно — и всё равно при одновременном старте десяти воркеров создаётся десять соединений. Девять из них потом потеряются, но пул на той стороне уже занят, а лимит подключений может быть исчерпан.
Ревью пропускает, потому что паттерн выглядит канонично и в однопоточном тесте безупречен. Проявляется только при одновременном первом обращении — то есть на старте под нагрузкой, что делает воспроизведение особенно неприятным.
import threading
_conn = None
_lock = threading.Lock()
def get_connection():
global _conn
if _conn is None: # быстрый путь без лока
with _lock:
if _conn is None: # закрываем окно
_conn = create_connection()
return _conn
Проверено замером на восьми потоках: наивная версия вызывает создание 8 раз, версия с двойной проверкой — 1.
Ещё проще там, где применимо: functools.lru_cache сам берёт лок вокруг промаха, а модульная переменная, инициализированная на верхнем уровне модуля, защищена блокировкой импорта (урок 17). Правило: любая последовательность «проверил — сделал» над общим состоянием требует лока, независимо от того, атомарны ли её части.
Вместо спора «в Python потоки бесполезны» — одна проверка на своей нагрузке.
import time, threading
def bench(fn, arg, n=2):
t = time.perf_counter()
for _ in range(n): fn(arg)
seq = time.perf_counter() - t
th = [threading.Thread(target=fn, args=(arg,)) for _ in range(n)]
t = time.perf_counter()
for x in th: x.start()
for x in th: x.join()
par = time.perf_counter() - t
print(f"последовательно {seq:.2f} c | потоки {par:.2f} c | ускорение {seq/par:.2f}×")
| нагрузка | одна задача | две в двух потоках | вывод |
|---|---|---|---|
| цикл на Python | 0.24 с | 0.48 с | лок держится — потоки не помогут |
| операция numpy | 0.121 с | 0.125 с | лок отпускается — масштабировать потоками |
Как читать результат. Ускорение близко к числу потоков — нагрузка уходит из интерпретатора, бери ThreadPoolExecutor. Ускорения нет — работа в байткоде; тогда по порядку: алгоритм, векторизация, процессы, расширение.
Почему сначала потоки, а не сразу процессы. Процессы дают параллелизм всегда, но данные едут через pickle: массив в сто мегабайт — это сериализация туда и обратно на каждую задачу. Потоки делят память бесплатно, и на численной нагрузке часто выигрывают именно поэтому.
Тот же тест стоит прогнать на конкретной библиотеке: отпускает ли GIL — решение её автора, и по документации это редко понятно (урок 15).
Прогони фрагменты, сверяясь с предсказанием. Затем:
import sys
sys.setswitchinterval(1e-6)
# Прогони пример с наивным кэшем снова. Стало хуже? А если поставить 1.0?
import dis
def inc(d, k):
d[k] = d.get(k, 0) + 1
dis.dis(inc)
# Сколько инструкций? В скольких местах возможно переключение?
import threading, time
def spin():
t = time.perf_counter()
while time.perf_counter() - t < 1: pass
# Запусти два таких потока. Почему это ровно две секунды?
import sysconfig
print(sysconfig.get_config_var("Py_GIL_DISABLED"))
# На обычной сборке False. Что изменится в ответах выше на free-threading?
Второй вопрос — самый показательный: увидев в dis четыре-пять инструкций на одну строку, перестаёшь верить в атомарность «простых» выражений.
Python/ceval_gil.h. Весь GIL умещается в один файл: структура с мьютексом и условной переменной, функции взятия и отдачи, флаг gil_drop_request и логика интервала. После следствия 1 читается за десять минут и снимает всю мистику.
Найди там же eval_breaker — флаг, который интерпретатор проверяет между инструкциями. Через него проходят не только просьбы отдать лок, но и обработка сигналов и вызовы отложенных функций. Это буквально та самая «граница инструкции», о которой говорит следствие 1.
Lib/threading.py. Модуль почти целиком на Python поверх низкоуровневого _thread. Полезно посмотреть на Lock и RLock: видно, что настоящий примитив синхронизации — это объект ОС, а не что-то, связанное с GIL. Два разных механизма, которые часто путают.
PEP 703. Не код, но обязательное чтение: там перечислено всё, что пришлось изменить, чтобы убрать лок, — и по этому списку видно, насколько глубоко он был вплетён.
Из L1 ты знаешь схему. Здесь — детали, которые видны инструментами.
GIL — структура с мьютексом, условной переменной, счётчиком переключений и флагом просьбы. Поток, желающий получить лок, ждёт на условной переменной с таймаутом, равным интервалу переключения; по истечении он выставляет gil_drop_request. Держатель видит флаг через eval_breaker на границе инструкции и отпускает лок.
Отсюда два наблюдаемых эффекта. Первый: переключение стоит дороже, чем кажется — это пробуждение потока ОС, и на многоядерных машинах два CPU-bound потока могут работать медленнее одного из-за возни с локом. Второй: интервал не гарантирует отзывчивости, потому что зависит от того, как быстро держатель дойдёт до границы инструкции.
import sys, dis
print(sys.getswitchinterval())
# → 0.005
def inc(d, k):
d[k] = d.get(k, 0) + 1
dis.dis(inc)
# → около десятка инструкций: LOAD_FAST, LOAD_METHOD, CALL,
# BINARY_OP, STORE_SUBSCR — переключение возможно между любыми
Про I/O. Функции стандартной библиотеки, уходящие в системный вызов, обёрнуты в Py_BEGIN_ALLOW_THREADS (урок 15). Поэтому поток, читающий сокет, лок не держит, и сотня таких потоков работает нормально — весь смысл потоков в Python держится на этом.
Что мерить при подозрении на проблему с GIL: py-spy dump покажет, где стоят потоки прямо сейчас; py-spy top --gil — какая доля времени проводится с захваченным локом. Если она близка к ста процентам при нескольких потоках — упёрлись именно в лок.
Тег v3.11.15.
Python/ceval_gil.h — реализация лока целиком: take_gil, drop_gil, интервал, флаг просьбы.Python/ceval.c — цикл интерпретатора и проверка eval_breaker между инструкциями: то самое место, где происходит переключение.Include/internal/pycore_gil.h — структура состояния лока.std::shared_ptr: та же задача, другая ветка развилкиPython/ceval_gil.h — выборочно: take_gil и drop_gil. Двести строк, снимающих мистику.threading — хватит разбора выше; идти за примитивами синхронизации.