Один важный момент: всё написанное здесь относится к CPython — стандартной реализации Python, которую вы, скорее всего, и используете. Jython и IronPython устроены иначе, там GIL нет. Но в 99% случаев «Python» в продакшне — это CPython.

Перед тем как двигаться дальше, разберём несколько терминов, которые встретятся по тексту.

Segfault (Segmentation Fault)

Программа попыталась обратиться к памяти, которой не владеет — например, к уже освобождённому объекту. Операционная система немедленно убивает процесс сигналом SIGSEGV. В Python это редкость — но именно так упал бы интерпретатор без GIL, если два потока одновременно освободили один и тот же объект.

# Без GIL: thread1 видит refcount=1 -> думает "я последний"
# thread2 одновременно тоже видит refcount=1
# оба делают free(obj) -> второй free = segfault

Опкод (opcode)

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

Reference counting

Механизм управления памятью в CPython. Каждый объект хранит счётчик ob_refcnt — сколько мест на него ссылается. Когда счётчик падает до нуля, объект не��едленно уничтожается. Это быстро и предсказуемо, но требует защиты от одновременного изменения счётчика из разных потоков — именно для этого и нужен GIL.

Мьютекс (mutex)

Mutual Exclusion — примитив синхронизации, который позволяет только одному потоку находиться в защищённой секции кода одновременно. Поток «берёт» (acquires) мьютекс, делает работу, «отдаёт» (releases). Все остальные потоки в это время ждут. GIL — это и есть мьютекс, но глобальный: он защищает весь интерпретатор целиком, а не отдельный ресурс.

Race condition

Состояние гонки — баг, при котором результат зависит от того, в каком порядке потоки успеют выполнить свои операции. Воспроизводится непредсказуемо: иногда всё работает, иногда нет. GIL не защищает от race condition полностью — он лишь делает отдельные опкоды атомарными, но не целые выражения.

Байткод (bytecode)

Промежуточное представление Python‑кода — набор опкодов, который выполняет виртуальная машина CPython. Когда вы запускаете .py файл, Python сначала компилирует его в байткод (кеширует в .pyc), а затем интерпретирует. Именно выполнение байткода и защищает GIL — только один поток может исполнять опкоды в каждый момент времени.

Context switch (переключение контекста)

Операция ОС: сохранить состояние текущего потока (регистры, стек, счётчик инструкций), загрузить состояние другого и передать управление ему. Это не бесплатно — занимает микросекунды и «портит» CPU‑кеш. Слишком частые переключения — GIL thrashing — могут замедлить многопоточный код сильнее, чем однопоточный.

Что такое GIL и зачем он нужен

GIL — это мьютекс внутри CPython, который гарантирует: в каждый момент времени только один поток выполняет Python‑байткод. Не «только один поток запущен» — потоков может быть сколько угодно, но байткод исполняет лишь один.

Зачем это сделали? В Python каждый объект имеет счётчик ссылок (ob_refcnt). Когда он падает до нуля — объект уничтожается. Без GIL два потока могут одновременно декрементировать этот счётчик, получить race condition и либо освободить память раньше времени (segfault), либо не освободить никогда (утечка). GIL — это грубый, но дешёвый способ сделать управление памятью thread‑safe.

Дополнительно: GIL упрощает интеграцию C‑расширений, которые не thread‑safe по природе.

Как GIL работает изнутри

В CPython 3.2+ появился новый механизм GIL (Gil Shedding). До этого был старый — на основе sys.checkinterval (количество опкодов).

Новый GIL (3.2+):

  • Есть таймаут sys.getswitchinterval() — по умолчанию 5 миллисекунд.

  • Поток держит GIL, выполняет байткод.

  • Через 5 мс другой поток выставляет флаг gil_drop_request = 1.

  • Текущий поток видит флаг, сбрасывает GIL, другой поток его захватывает.

  • Если других желающих нет — тот же поток берёт GIL обратно.

Важно: переключение происходит не между опкодами, а по таймеру. Это значит, что поток может выполнить тысячи опкодов за 5 мс без переключения.

sys.setswitchinterval() — зачем и когда менять

Это таймаут в секундах, после которого ожидающий поток может запросить GIL у текущего. По умолчанию — 5 мс.

На практике setswitchinterval трогают редко. Один реальный кейс: CPU‑bound воркеры с threading + NumPy — там увеличение интервала до 50–100 мс снижает overhead на переключения, потому что NumPy сам отдаёт GIL когда надо. Для I/O‑сервисов — не трогать.

Как устроен GIL внутри CPython

Здесь опишу основы, которых хватит для прохождения большинства интервью, так как полный разбор внутренностей GIL заслуживает отдельной статьи и уже есть на Хабр.

Внутри CPython GIL — это структура _gil_runtime_state в Python/ceval_gil.c. Ключевые поля:

locked — атомарный int, 0 или 1. Взят GIL или нет.

mutex + cond_var — стандартные POSIX примитивы. Ожидающий поток засыпает на cond_var, просыпается когда текущий держатель делает pthread_cond_signal.

eval_breaker — атомарный флаг, который проверяется в главном цикле интерпретатора (ceval.c) после каждого опкода. Если выставлен — поток смотрит почему: нужно ли отдать GIL, обработать сигнал или выполнить что‑то ещё.

gil_drop_request — отдельный флаг, который ожидающий поток выставляет когда хочет GIL. Когда текущий поток видит его через eval_breaker — отдаёт GIL и ждёт на cond_var, пока другой поток его не освободит обратно (чтобы не взять GIL снова раньше ожидающего).

Это важный нюанс: после release текущий поток не берёт GIL сразу обратно — он ждёт, чтобы дать шанс другому. Без этого один активный поток мог бы монополизировать GIL навсегда.

Почему нужны Lock/RLock даже с GIL

GIL гарантирует атомарность одного опкода. Но почти любое полезное действие — это несколько опкодов.

# Выглядит как одна операция — на самом деле три
counter += 1

# Байткод:
# LOAD_GLOBAL  counter   <- поток может быть прерван ЗДЕСЬ
# LOAD_CONST   1
# BINARY_ADD
# STORE_GLOBAL counter   <- и здесь

GIL может переключиться между любыми двумя опкодами. Если два потока делают counter += 1 одновременно — оба прочитают одно значение, оба прибавят 1, оба запишут одно и то же. Итог: вместо +2 получаем +1.

Lock — про��той мьютекс. Один поток держит, остальные ждут.

RLock (Reentrant Lock) — тот же поток может взять его несколько раз без дедлока. Нужен когда защищённый метод вызывает другой защищённый метод того же объекта.

import threading

counter = 0
lock = threading.Lock()

def safe_increment():
    global counter
    with lock:           # acquire
        counter += 1     # теперь атомарно для нашей логики
                         # release — автоматически

# RLock — когда метод вызывает метод
class SafeTree:
    def __init__(self):
        self._lock = threading.RLock()

    def insert(self, value):
        with self._lock:          # берёт RLock первый раз
            self._rebalance()     # вызывает метод, который тоже берёт RLock

    def _rebalance(self):
        with self._lock:          # берёт RLock второй раз — не дедлок
            pass

Правило простое: если несколько потоков читают и пишут одни данные — нужен Lock, независимо от GIL. GIL защищает интерпретатор, а не вашу бизнес‑логику.

CPU‑bound vs I/O‑bound — ключевое разграничение

Это самая важная тема на собеседовании. Нужно понимать не просто «GIL плохой» — а когда он проблема, а когда нет.

I/O‑bound задачи (сеть, диск, БД): GIL не проблема. Когда поток уходит в системный вызов (read, write, select), он сам отдаёт GIL. То есть пока один поток ждёт ответа от Postgres, другой спокойно работает. threading и asyncio отлично справляются.

CPU‑bound задачи (вычисления, парсинг, шифрование): GIL — реальная проблема. Два потока, которые считают числа, не дают друг другу параллельно использовать CPU. Реальное ускорение от многопоточности на CPU‑bound = 0 или даже хуже из‑за overhead на переключение.

# I/O-bound — threading работает хорошо
import threading, requests

def fetch(url):
    r = requests.get(url)  # GIL отдаётся во время ожидания ответа
    return r.status_code

threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]

# CPU-bound — threading НЕ помогает
def heavy_compute(n):
    return sum(i * i for i in range(n))  # GIL держится всё время

# Решение для CPU-bound:
from multiprocessing import Pool
with Pool(4) as p:
    results = p.map(heavy_compute, [10**7]*4)  # каждый процесс — свой GIL

Когда C-расширения освобождают GIL

Это важный нюанс, который спрашивают на senior‑уровне. NumPy, pandas, cryptography, lxml — все они в CPU‑тяжёлых операциях явно освобождают GIL с помощью макросов Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS.

// Так выглядит освобождение GIL в C-коде NumPy
Py_BEGIN_ALLOW_THREADS
// вот здесь идёт тяжёлое вычисление на чистом C
matrix_multiply(a, b, result, n);
Py_END_ALLOW_THREADS

Вывод: numpy в многопоточном коде даёт реальный параллелизм, потому что сама тяжёлая работа выполняется без GIL. Поэтому threading + numpy — это нормально. Но threading + чистый Python код на CPU — нет.

asyncio и GIL

asyncio не обходит GIL — он просто не нуждается в параллелизме. Это кооперативная многозадачность в одном потоке. Пока одна корутина ждёт I/O, event loop переключается на другую. GIL при этом вообще не является ограничением, потому что поток один.

import asyncio

async def fetch_data(session, url):
    async with session.get(url) as resp:  # здесь происходит yield to event loop
        return await resp.json()

async def main():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_data(session, url) for url in urls]
        results = await asyncio.gather(*tasks)  # 1000 запросов параллельно, 1 поток

Разница threading vs asyncio для I/O:

  • threading: реальные OS‑потоки, overhead на создание и переключение, легко получить race condition

  • asyncio: один поток, нет overhead, явное управление переключением, нет гонок по данным (но нельзя использовать блокирующие функции)

Thread safety, process safety, async safety

Три разных контекста — три разных проблемы.

тип

проблема

общая память

инструменты защиты

где встречается

Thread safety

race condition между потоками одного процесса

да — напрямую

LockRLockSemaphorequeue.Queuethreading.local()

threadingconcurrent.futures.ThreadPoolExecutor

Process safety

гонки при доступе к общим ресурсам между процессами

только явно

multiprocessing.LockManagerQueue, файловые локи, Redis/Postgres advisory locks

multiprocessing, Celery workers, Gunicorn

Async safety

гонка между корутинами при await — другая корутина меняет состояние пока ты ждёшь

один поток

asyncio.Lockasyncio.Queue, проектирование без общего мутабельного состояния

asyncioaiohttpFastAPI

Важный нюанс по async safety — там нет параллелизма, но гонки всё равно возможны:

# Небезопасно в asyncio — между двумя await другая корутина может изменить баланс
async def transfer(from_acc, to_acc, amount):
    balance = await db.get_balance(from_acc)  # await — точка переключения
    if balance >= amount:
        # ЗДЕСЬ другая корутина может тоже прочитать тот же баланс
        await db.deduct(from_acc, amount)
        await db.add(to_acc, amount)

# Безопасно — транзакция на уровне БД или asyncio.Lock
async def transfer_safe(from_acc, to_acc, amount):
    async with account_lock:
        balance = await db.get_balance(from_acc)
        if balance >= amount:
            await db.deduct(from_acc, amount)
            await db.add(to_acc, amount)

Каждый await — это точка где event loop может переключиться на другую корутину. Всё что между двумя await — атомарно. Всё что пересекает await — нет.

Альтернативы CPython без GIL

multiprocessing — стандартное решение. Каждый процесс — своя копия интерпретатора и свой GIL. Общение через Queue, Pipe, shared memory. Overhead на сериализацию (pickle) между процессами.

Jython, IronPython — нет GIL, есть реальная многопоточность. Но нет большинства C‑расширений, включая NumPy.

PyPy — есть GIL (как в CPython), но JIT‑компиляция делает однопоточный код в 5–10x быстрее.

Python 3.13 — nogil build (PEP 703) — экспериментальная сборка CPython без GIL. Использует per‑object lock вместо глобального. Пока что медленнее обычного CPython в однопоточных сценариях (~5–10% overhead), но даёт реальный параллелизм. В продакшн не идёт.

subinterpreters (PEP 554) — несколько интерпретаторов в одном процессе, у каждого свой GIL. В Python 3.12 добавлен interpreters модуль. Перспективно, но API ещё нестабильный.

Как мы решаем это в хайлоад

На практике в финансовом сервисе картина такая:

HTTP‑сервис (FastAPI/aiohttp): asyncio + uvloop. GIL не мешает вообще — всё I/O‑bound: запросы к БД, кешу, внешним API. Тысячи конкурентных запросов в одном потоке.

CPU‑тяжёлые задачи (risk calculations, ML inference): выносим в отдельные workers через Celery + multiprocessing. Либо используем NumPy/pandas (которые сами освобождают GIL) в thread pool.

Streaming/real‑time: Kafka consumer в отдельном процессе, обработка через ProcessPoolExecutor если CPU‑bound.

# Правильный паттерн для смешанной нагрузки
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

# CPU-bound: отдельные процессы
with ProcessPoolExecutor(max_workers=4) as executor:
    risk_results = executor.map(calculate_risk, positions)

# I/O-bound: потоки (или asyncio)
with ThreadPoolExecutor(max_workers=20) as executor:
    db_results = executor.map(fetch_from_db, query_ids)

Concurrency vs Parallelism

Это самая частая путаница в теме многопоточности. Термины звучат похоже, но описывают принципиально разные вещи.

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

Parallelism — про физическое исполнение. Несколько задач буквально выполняются в одну и ту же наносекунду на разных ядрах процессора.

Классическая формулировка от Роба Пайка: concurrency — это про работу с несколькими вещами одновременно, parallelism — про выполнение нескольких вещей одновременно. Concurrency — это дизайн. Parallelism — это рантайм.

Короткая формула для запоминания: parallelism — это всегда concurrency, но concurrency — не всегда parallelism. Можно иметь concurrency без parallelism (один поток переключается между задачами). Нельзя иметь parallelism без concurrency (параллельные задачи по определению concurrent).

Где находятся asyncio, threading и multiprocessing

Теперь по каждому детально.

asyncio — concurrency без parallelism

Один поток, один GIL, одно ядро. Задачи не выполняются одновременно физически — они чередуются в точках await. Пока одна корутина ждёт сеть, event loop переключается на другую. Это кооперативная многозадачность: задачи сами решают когда отдать управление.

Parallelism здесь невозможен по определению — поток один. Но для I/O‑bound задач это не нужно: CPU всё равно простаивает пока ждёт ответа от сети.

threading — concurrency, parallelism зависит от контекста

Несколько OS‑потоков — concurrency есть. Но GIL не даёт двум потокам выполнять Python‑байткод одновременно — parallelism для чистого Python‑кода отсутствует. Это и есть суть проблемы.

Исключение: если поток выполняет C‑расширение, которое явно освобождает GIL (NumPy, cryptography), или ждёт I/O системный вызов — в этот момент другой поток может работать параллельно. parallelism есть, но частичный и зависит от того, что делают потоки.

multiprocessing — concurrency и parallelism одновременно

Несколько процессов, у каждого своя копия интерпретатора и свой GIL. Никакого общего GIL нет — процессы реально выполняются на разных ядрах одновременно. Это единственный способ получить настоящий parallelism для чистого Python‑кода.

Цена — нет общей памяти. Данные между процессами передаются через сериализацию (pickle), что создаёт overhead. Для небольших данных и долгих задач это незаметно. Для задач где нужно часто передавать большие объёмы — боль.

Каверзные вопросы и ответы

«Многопоточность в Python бесполезна из-за GIL» — вы согласны?

Нет. Это упрощение. Threading полезен для I/O‑bound задач — а это 90% нагрузки типичного веб‑сервиса. Пока поток ждёт сеть или диск, он отдаёт GIL и другой поток работает. Бесполезен threading только для CPU‑bound Python‑кода. NumPy и другие C‑расширения явно освобождают GIL, поэтому threading + numpy даёт реальный параллелизм.

Можно ли получить race condition в Python несмотря на GIL?

Да, легко. GIL гарантирует атомарность отдельных байткод‑операций, но не атомарность последовательностей операций. Например, x += 1 — это LOAD, ADD, STORE: три опкода. GIL может переключиться между ними. Классический пример: два потока делают counter += 1 по 100 000 раз, а в итоге counter < 200000. Решение — threading.Lock()queue.Queue, или atomic типы (например, из atomics библиотеки).

Что произойдёт, если убрать GIL из CPython прямо сейчас?

Нужно будет заменить единый GIL per‑object locks для каждого объекта — иначе reference counting сломается. Это:
1) overhead на каждую операцию с объектами,
2) риск дедлоков,
3) поломка всех C‑расширений, которые предполагают наличие GIL. Именно это и реализует PEP 703 (nogil build в 3.13) — и в однопоточных сценариях это даёт ~5–10% деградацию. Также все C‑расширения нужно пересобрать с учётом thread safety.

asyncio использует несколько потоков?

Нет. По умолчанию asyncio работает в одном потоке. Event loop кооперативно переключается между корутинами. GIL вообще не является ограничением — поток один. Исключение: loop.run_in_executor() запускает блокирующий код в ThreadPoolExecutor или ProcessPoolExecutor — вот там уже вступает в игру GIL (для threads) или отсутствие общей памяти (для processes).

Почему иногда многопоточный код работает МЕДЛЕННЕЕ однопоточного?

Несколько причин:
1. GIL thrashing — потоки постоянно соревнуются за GIL, тратя время на ожидание и переключение контекста.
2. False sharing в CPU кешах — когда разные потоки работают с данными в одной cache line.
3. Overhead на создание потоков и синхронизацию. Для CPU‑bound задач с маленьким объёмом работы на поток — многопоточность даёт чистый минус.

Как coroutine в asyncio отдаёт управление event loop?

Через await. Когда корутин доходит до await some_io(), он возвращает управление event loop (буквально — yield управление). Event loop регистрирует callback на I/O событие (через epoll/kqueue) и переключается на следующую готовую корутину. Когда I/O завершается — корутин возобновляется с того же места. Если в корутине нет await — он держит event loop заблокированным, как если бы это был один большой синхронный вызов. Это называется blocking the event loop и это распространённый баг.

Чем отличается GIL в Python 2 и Python 3?

В Python 2 GIL переключался каждые N байткод‑инструкций (по умолчанию 100, sys.setcheckinterval). Это создавало проблему: на multicore CPU потоки могли «биться» — один поток пытался взять GIL, сразу получал отказ, снова пытался — создавая convoy effect.
В Python 3.2 Antoine Pitrou переписал GIL на таймер (5ms), добавил механизм явного запроса GIL от ожидающего потока. Это убрало convoy effect и сделало переключение более предсказуемым.

Как проверить, является ли операция атомарной в Python?

Через dis модуль — посмотреть на байткод. Одна байткод‑инструкция атомарна с точки зрения GIL (переключение происходит между инструкциями). Атомарны: list.append()list.pop(), присваивание переменной (STORE_NAME). Неатомарны: x += 1 (три инструкции), dict[key] = dict.get(key, 0) + 1. Но полагаться на это в продакшн‑коде — плохая практика: детали реализации могут измениться. Используйте threading.Lock явно.

GIL — не баг и не ошибка проектирования. Это осознанный компромисс, который Гвидо ван Россум принял в 1992 году: простота реализации и производительность в однопоточном режиме в обмен на ограничения многопоточности. Тогда многоядерных процессоров в массовом доступе не существовало. Решение было разумным.

Проблема в том, что мир изменился, а GIL остался.

Сегодня, в 2026 году, у дата‑сайентиста в ноутбуке 12 ядер. Финансовый сервис обрабатывает 50 000 транзакций в секунду. ML‑модели считают на кластерах из сотен машин. И всё это — на Python, который физически не может использовать больше одного ядра для чистого Python‑кода одновременно.

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

Работа над устранением GIL идёт. PEP 703 в Python 3.13 — это не эксперимент ради эксперимента, это результат многолетних попыток решить задачу, которую сам Гвидо когда‑то назвал почти невозможной. Посмотрите, как развивается дискуссия в core dev community — там до сих пор нет консенсуса, насколько допустима деградация однопоточной производительности ради настоящего параллелизма.

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