GIL

Глобальный лок — не отдельное решение, а цена детерминированного разрушения из урока 09. Здесь: что он защищает на самом деле, какие операции атомарны, почему «атомарно» не значит «правильно», и что меняет free-threading.

L1 · 9 минL2 · 11 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
import sys
print(sys.getswitchinterval())
0.005
Пять миллисекунд. Что именно происходит каждые пять миллисекунд?
Фрагмент B
out = []
def appender():
    for i in range(50_000): out.append(i)

# четыре потока разом
200000 элементов — потерь нет
Общий список, четыре потока, никаких блокировок. И всё корректно.
Фрагмент C
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 раз вместо одного
Тот же общий словарь, та же безопасность операций — и всё сломано.
Фрагмент D
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×
Один интерпретатор, одни потоки, разный результат.

2Механизм

Урок 09 показал, откуда GIL взялся: счётчик ссылок не атомарен, и глобальный лок оказался самым дешёвым способом это починить. Здесь — как он устроен и что из этого следует ежедневно.

GIL — обычный мьютекс плюс условная переменная. Держать его обязан любой поток, исполняющий байткод.

Поток отдаёт лок сам: уходя в ожидание, входя в C-код, который его отпустил, или по требованию — когда другой поток попросил и истёк интервал переключения.

Переключение возможно только между инструкциями байткода, а не внутри них.

Следствие 1 · пять миллисекунд — это не квант, а таймаут просьбы

Фрагмент A. Интервал переключения работает не так, как кажется. Поток, желающий получить лок, выставляет флаг-просьбу и ждёт. Держатель проверяет флаг между инструкциями и, если тот стоит дольше интервала, отпускает лок.

Отсюда важное: пока держатель не дошёл до границы инструкции, никакая просьба не действует. Одна долгая операция в C, не отпустившая лок, блокирует всех на всё своё время, сколько бы ни было выставлено интервалов.

Следствие 2 · отдельная операция атомарна, потому что это одна инструкция

Фрагмент B. list.append корректен без блокировок не по волшебству: он выполняется целиком внутри одной инструкции байткода, а переключение между инструкциями — единственная возможная точка. То же для dict[k] = v, set.add, чтения и записи атрибута.

Это удобно и это ловушка, к которой ведёт следующее следствие.

Следствие 3 · атомарность операции не даёт корректности сценария

Фрагмент 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 защищает целостность структур интерпретатора и ничего не обещает про твои инварианты.

Следствие 4 · потоки полезны там, где лок отпускается

Фрагмент D повторяет замер из урока 15, и теперь он объясняется полностью. Чистый Python держит лок всё время — два потока просто чередуются. numpy отпускает лок на время работы в C — потоки идут параллельно и дают 1.9×.

Отсюда рабочий критерий, заменяющий фольклор «потоки в Python бесполезны»: потоки полезны ровно настолько, насколько твоя нагрузка проводит время вне интерпретатора — в ожидании или в C-коде, который лок отпустил.

Следствие 5 · что меняет free-threading

Убрать лок мешает то, ради чего он появился: неатомарный счётчик в каждом объекте плюс тысячи расширений, рассчитывающих на монополию. Сборка без GIL (PEP 703, экспериментально с 3.13) решает это тремя приёмами.

Смещённый подсчёт ссылок: счётчик разделён на локальный для потока-создателя и разделяемый для остальных, так что типичный случай остаётся неатомарным. Бессмертные объекты (PEP 683): у None, True, мелких целых счётчик не двигается вовсе, убирая главные точки конкуренции. Блокировки на объект для контейнеров.

Цена — другой ABI и отдельные колёса (урок 16), плюс замедление однопоточного кода. И главное для практики: код, чья корректность держалась на неявной атомарности из следствия 2, там ломается — а такого кода написано много.

3Границы модели

Где сказанное перестаёт держать

Что именно атомарно — деталь реализации 🔧. Список «атомарных операций» кочует по интернету, но он про конкретный компилятор байткода конкретной версии. Опираться на него в коде нельзя; правильный ответ — блокировка или очередь.

GIL не про безопасность данных. Он не защищает твои структуры от логических гонок и не заменяет транзакции. Он гарантирует лишь, что сам интерпретатор не развалится.

Один GIL на интерпретатор, а не на процесс. С 3.12 (PEP 684) субинтерпретаторы могут иметь по собственному локу — параллелизм в одном процессе без потоков и без общей памяти. Пока с ограничениями и малой поддержкой расширений.

4Корень

Корень R3: временем жизни управляет счётчик ссылок. GIL — не самостоятельное архитектурное решение, а следствие: из трёх способов сделать счётчик потокобезопасным (атомарные операции, лок на объект, один глобальный лок) выбран третий как единственный дешёвый и совместимый с уже написанными расширениями.

Хронологически это 1992 год, машины одноядерные, а язык задумывался как клей для C-библиотек. Решение было очевидно правильным и оставалось таким лет пятнадцать. Проблемой оно стало не потому, что было ошибкой, а потому, что изменилось железо.

Попытки убрать лок предпринимались с 1999 года (проект Гвидо, давший двукратное замедление однопоточного кода) и все упирались в одно: счётчик ссылок торчит в публичном C API, а вокруг него выросла экосистема. То, что сделало Python языком численных вычислений, зафиксировало глобальный лок на тридцать лет — и PEP 703 стал возможен только тогда, когда нашли способ не ломать эту экосистему целиком.

5Аналогия

Точная аналогия — атомарный счётчик в std::shared_ptr, и она полезна тем, что показывает развилку целиком. Обе реализации решают одну задачу: сделать подсчёт ссылок безопасным при многопоточности. C++ выбрал атомарные операции и платит на каждом копировании умного указателя во всех программах, включая однопоточные. CPython выбрал один лок и платит невозможностью параллелить байткод.

Ни один вариант не бесплатен, и это стоит держать в голове, когда GIL называют недоработкой: альтернатива исполнена в соседнем языке и имеет свою цену, которую там тоже регулярно ругают.

Второй полезный образ — большой лок ядра в ранних SMP-версиях Unix и Linux: один лок на всё ядро, чтобы не переписывать код под многопроцессорность сразу. Работало, пока ядер было мало; убирали десятилетиями, по подсистеме за раз. Free-threading — ровно эта же история, только применённая к интерпретатору.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему GIL не убрали за двадцать лет?

Потому что убирать надо было не GIL, а одно из двух решений под ним — и оба уже вросли в экосистему.

GIL не самостоятельное решение. Он стоит на пересечении двух других: счётчик ссылок как модель памяти (R3) и C API как способ расширения (R6). Счётчик неатомарен, а каждое расширение на C двигает его без блокировки — значит нужен глобальный лок, иначе счётчики поедут и объекты начнут освобождаться дважды.

Попытки были, и все упирались в одно и то же:

  • Атомарные счётчики — самое очевидное. Замедляет однопоточный код на 30–50%: атомарная операция дороже обычной записи, а счётчик двигается на каждой операции.
  • Мелкогранулярные блокировки (проект «free threading» Грега Стайна, 1999) — работало, но давало примерно двукратное замедление на одном потоке.
  • Транзакционная память (PyPy STM) — не вышло за пределы эксперимента.

Каждый раз получалось: выигрыш у меньшинства, которое считает на Python в потоках, за счёт большинства, у которого один поток. Гвидо сформулировал условие прямо: однопоточная производительность не должна пострадать.

PEP 703 закрыл вопрос через двадцать лет — но ценой отдельной сборки с отдельным ABI, отложенным освобождением и biased reference counting. Не переключатель, а вторая версия интерпретатора. Это тоже R9: сломать существующие расширения нельзя.

GIL есть — почему всё равно нужны блокировки?

Потому что 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 или очередь.

Free-threading уже есть — стоит ли переходить?

Пока нет, если только вы не упираетесь именно в CPU-bound Python в потоках и не готовы проверять всю зависимость целиком.

Что даёт (PEP 703, сборка python3.13t и дальше): настоящий параллелизм чистого Python-кода по ядрам. То, чего не было тридцать лет.

Чем платят прямо сейчас:

  • Однопоточная производительность ниже — счётчики ссылок стали дороже, часть оптимизаций отключена. Разрыв сокращают, но он есть.
  • Отдельный ABI — нужны колёса, собранные под free-threading. Матрица зависимостей своя, и она заметно беднее.
  • Расширения надо проверять на потокобезопасность. Код, который тридцать лет полагался на GIL как на неявную блокировку, теперь может ломаться — и это касается не только C, но и Python-библиотек, где «атомарность» держалась на удаче.
  • Ваш собственный код тоже. Гонки, которые GIL делал маловероятными, станут обычными.

Когда переходить. Когда профиль показывает, что время уходит в питоновские вычисления, и процессы не подходят из-за объёма передаваемых данных (урок 20). Во всех остальных случаях сначала стоит проверить более дешёвые пути: numpy отпускает GIL и даёт 1.9× на двух ядрах уже сейчас, процессы работают везде, а asyncio решает задачу ожидания.

Разумная стратегия на ближайшее время — собирать расширения так, чтобы они работали в обеих сборках, и следить за матрицей колёс своих зависимостей.

Итог. GIL — мьютекс, который обязан держать любой поток, исполняющий байткод, и следствие решения из урока 09: неатомарный счётчик ссылок плюс тысячи расширений, рассчитывающих на монополию. Интервал переключения — не квант времени, а таймаут просьбы: держатель проверяет флаг между инструкциями, поэтому долгая операция в C, не отпустившая лок, блокирует всех целиком. Отдельные операции вроде list.append атомарны просто потому, что укладываются в одну инструкцию, — но атомарность операции не даёт корректности сценария: между проверкой и записью поток вытесняется, и наивный кэш вызывает дорогую функцию восемь раз вместо одного. Потоки полезны ровно настолько, насколько нагрузка проводит время вне интерпретатора: чистый цикл не ускоряется вовсе, numpy даёт 1.9×, потому что отпускает лок. Free-threading убирает лок ценой другого ABI и ломает код, чья корректность держалась на неявной атомарности.
Дальше — по желанию
контрфактуалC, Java и GoРучные локи против одного глобального — и что каждый выбор стоит

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}×")
нагрузкаодна задачадве в двух потокахвывод
цикл на Python0.24 с0.48 слок держится — потоки не помогут
операция numpy0.121 с0.125 слок отпускается — масштабировать потоками

Как читать результат. Ускорение близко к числу потоков — нагрузка уходит из интерпретатора, бери ThreadPoolExecutor. Ускорения нет — работа в байткоде; тогда по порядку: алгоритм, векторизация, процессы, расширение.

Почему сначала потоки, а не сразу процессы. Процессы дают параллелизм всегда, но данные едут через pickle: массив в сто мегабайт — это сериализация туда и обратно на каждую задачу. Потоки делят память бесплатно, и на численной нагрузке часто выигрывают именно поэтому.

Тот же тест стоит прогнать на конкретной библиотеке: отпускает ли GIL — решение её автора, и по документации это редко понятно (урок 15).

💻 терминалПроверь в REPLЧетыре фрагмента плюс switch interval, sys.settrace и субинтерпретаторы

Прогони фрагменты, сверяясь с предсказанием. Затем:

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 четыре-пять инструкций на одну строку, перестаёшь верить в атомарность «простых» выражений.

исходникиceval_gil.h и модуль threadingГде лок живёт и как выглядит просьба его отдать

Python/ceval_gil.h. Весь GIL умещается в один файл: структура с мьютексом и условной переменной, функции взятия и отдачи, флаг gil_drop_request и логика интервала. После следствия 1 читается за десять минут и снимает всю мистику.

Найди там же eval_breaker — флаг, который интерпретатор проверяет между инструкциями. Через него проходят не только просьбы отдать лок, но и обработка сигналов и вызовы отложенных функций. Это буквально та самая «граница инструкции», о которой говорит следствие 1.

Lib/threading.py. Модуль почти целиком на Python поверх низкоуровневого _thread. Полезно посмотреть на Lock и RLock: видно, что настоящий примитив синхронизации — это объект ОС, а не что-то, связанное с GIL. Два разных механизма, которые часто путают.

PEP 703. Не код, но обязательное чтение: там перечислено всё, что пришлось изменить, чтобы убрать лок, — и по этому списку видно, насколько глубоко он был вплетён.

L2Как устроен лок и что видно в замерахМьютекс, eval_breaker, стоимость переключения, поведение на I/O

Из 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 — какая доля времени проводится с захваченным локом. Если она близка к ста процентам при нескольких потоках — упёрлись именно в лок.

L3Где это в исходниках CPythonceval_gil.h, ceval.c, PEP 703 и 684

Тег v3.11.15.

  • Python/ceval_gil.h — реализация лока целиком: take_gil, drop_gil, интервал, флаг просьбы.
  • Python/ceval.c — цикл интерпретатора и проверка eval_breaker между инструкциями: то самое место, где происходит переключение.
  • Include/internal/pycore_gil.h — структура состояния лока.
  • PEP 703 — free-threading; PEP 684 — отдельный лок на субинтерпретатор.
связиКуда это ведётL09, L15, L20 · контраст с shared_ptr в C++ и горутинами Go
корень R3 · Подсчёт ссылок как модель памяти
← основа L09 · Подсчёт ссылок — там GIL выводится из неатомарности счётчика
← основа L15 · Граница с C — отпускание лока в расширении и почему numpy параллелится
← основа L16 · Пакеты — free-threading как отдельный ABI
↔ контраст Атомарный счётчик в std::shared_ptr: та же задача, другая ветка развилки
↔ контраст Большой лок ядра в ранних SMP-системах — та же история, тот же способ выхода
→ дальше L20 · Конкурентность — дерево решений: потоки, asyncio, процессы, субинтерпретаторы
источникиЧто почитатьPEP 703 🟥 · ceval_gil.h 🟧 · threading 🟦