GIL в Python простыми словами: как работает и что спрашивают на собеседовании

Разбираем Global Interpreter Lock с нуля: история появления, bytecode-переключение, влияние на threading/multiprocessing, обход GIL и free-threaded Python 3.13.

01 марта 2026 г.19 минLexicon Team

GIL — аббревиатура, которую рано или поздно произносит каждый Python-разработчик на собеседовании. Global Interpreter Lock — это не баг, не артефакт плохого дизайна и не временная заглушка. Это осознанное архитектурное решение, принятое в 1992 году и живущее в CPython до сих пор. Понять, почему оно появилось, как работает изнутри и что реально ограничивает, — значит ответить на добрую треть вопросов по многопоточности на Python-интервью.

На junior-уровне достаточно знать определение: «GIL — мьютекс, разрешающий только одному потоку выполнять Python-байткод в каждый момент». Но middle-разработчику интервьюер задаёт уточнения: «Почему threading не ускоряет CPU-bound задачи?», «Как NumPy обходит GIL?», «Что изменилось в Python 3.13?». Именно эти вопросы мы и разберём здесь — от истории появления до практических паттернов и free-threaded режима.

Если вы ещё не смотрели общий обзор, загляните в Python: 50 вопросов на собеседовании junior–middle — там GIL упоминается в разделе «Управление памятью», а здесь мы уходим на уровень глубже.

Python на собеседовании — без стресса

50 вопросов с разбором: GIL, asyncio, ООП и память.

Начать тренировку

1. Откуда взялся GIL: история и причины

1.1 CPython и подсчёт ссылок

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

Это эффективно: нет отдельного GC-цикла для большинства объектов (он есть только для циклических ссылок). Но есть проблема. Инкремент и декремент ob_refcnt — не атомарные операции на уровне железа. Если два потока одновременно манипулируют одним объектом, счётчик может оказаться неверным: объект удалится слишком рано или вообще не удалится, оставшись висеть в памяти навсегда.

import sys

x = []
sys.getrefcount(x)   # 2: переменная x + аргумент getrefcount
y = x
sys.getrefcount(x)   # 3: x + y + аргумент getrefcount
del y
sys.getrefcount(x)   # 2: снова

Без защиты такой код в многопоточной среде мог бы привести к use-after-free и падению интерпретатора.

1.2 Решение 1992 года

Гвидо ван Россум добавил GIL в Python в 1992 году, когда портировал интерпретатор на Windows, которая тогда появилась с поддержкой потоков. Логика была простой: вместо того чтобы защищать каждый счётчик ссылок отдельным мьютексом (что дорого и сложно), защищаем весь интерпретатор одним глобальным замком.

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

1.3 Почему не убрали до сих пор

Причин несколько, и каждая весома.

C-расширения. Тысячи библиотек написаны на C и используют Python C API. Этот API предполагает, что GIL защищает структуры интерпретатора. Убрать GIL без обратной совместимости — значит сломать NumPy, SciPy, lxml, PIL и половину экосистемы.

Производительность однопоточного кода. Fine-grained locking (отдельный мьютекс на каждый объект) медленнее глобального замка из-за накладных расходов на lock/unlock. Без GIL однопоточный Python был бы заметно медленнее — примерно на 20-30% по ранним оценкам.

Сложность реализации. JVM решала аналогичную задачу годами, имея огромную команду инженеров. Для CPython, который разрабатывается сообществом добровольцев, это колоссальный объём работы.

Для сравнения: JVM использует fine-grained locking с volatile-полями и CAS-операциями. Ruby MRI имел собственный GIL вплоть до Ruby 3.0, где его заменили на Ractors. Python пошёл другим путём — добавил экспериментальный free-threaded режим в 3.13.

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

2.1 Eval loop и байткод

CPython выполняет код в главном цикле — ceval.c::_PyEval_EvalFrameDefault. Интерпретатор дизассемблирует Python-код в байткод и выполняет инструкцию за инструкцией в этом цикле.

import dis

def add(a, b):
    return a + b

dis.dis(add)
# RESUME           0
# LOAD_FAST        0 (a)
# LOAD_FAST        1 (b)
# BINARY_OP        0 (+)
# RETURN_VALUE

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

2.2 Механизм переключения

В исходниках CPython GIL реализован через переменную _Py_atomic_int gil_locked и функции take_gil() / drop_gil(). Когда поток хочет начать исполнение, он вызывает take_gil() — блокируется до тех пор, пока GIL не освободится. После выполнения фрагмента — drop_gil().

Чтобы GIL передавался между потоками справедливо, интерпретатор периодически проверяет, есть ли другие потоки, ожидающие замка. Эта проверка происходит через check interval.

2.3 Check interval: от тиков к секундам

В Python 2 и ранних версиях Python 3 check interval измерялся в «тиках» — количестве байткод-инструкций. sys.getcheckinterval() возвращало 100 по умолчанию: каждые 100 инструкций интерпретатор проверял, нужно ли передать GIL.

С Python 3.2 это изменилось. Антуан Питру переделал механизм: теперь sys.getswitchinterval() возвращает время в секундах (0.005 по умолчанию — 5 миллисекунд). Поток, держащий GIL, будет вытеснен через 5 мс, если другой поток ждёт.

import sys

print(sys.getswitchinterval())  # 0.005

# Можно изменить (обычно не нужно)
sys.setswitchinterval(0.001)    # 1 мс — более агрессивное переключение

Старый API sys.getcheckinterval() убран в Python 3.2. Это частый вопрос на интервью: «Как изменился check interval в Python 3.2?»

2.4 I/O освобождает GIL

Ключевой факт, который объясняет, почему threading полезен для I/O-задач: любая блокирующая I/O-операция явно освобождает GIL перед тем, как уйти в ожидание, и захватывает обратно после завершения.

В C API это выглядит так:

// В исходниках CPython (упрощённо):
Py_BEGIN_ALLOW_THREADS   // освобождает GIL
result = recv(fd, buf, len, flags);  // системный вызов
Py_END_ALLOW_THREADS     // захватывает GIL обратно

Это значит: пока один поток ждёт ответ от сети или диска, другой поток может исполнять байткод. GIL не блокирует конкурентный I/O — он блокирует только конкурентное выполнение Python-кода.

2.5 Диаграмма работы двух потоков

Представим двух потоков — Thread-1 и Thread-2:

Thread-1: [==исполнение байткода==][drop_gil]        [take_gil][==исполнение==]...
Thread-2:               [waiting for GIL...][take_gil][==исполнение байткода==]...
           ──────────────────────────────────────────────────────────────────► время

Thread-1 держит GIL, выполняет байткод. Через 5 мс (или при I/O) освобождает. Thread-2, который ждал, захватывает GIL и начинает выполнение. Thread-1 уходит в очередь. По очереди, не параллельно — в этом суть GIL.

3. Влияние GIL на CPU-bound и I/O-bound задачи

3.1 CPU-bound: threading не помогает

CPU-bound задача — та, где процессор занят вычислениями без ожидания. Вычисление факториала, хэшей, обработка изображений в чистом Python. Здесь GIL создаёт настоящую проблему.

Запустим два потока на числодробилке:

import threading
import time

def cpu_task(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

N = 10_000_000

# Последовательно
start = time.time()
cpu_task(N)
cpu_task(N)
print(f"Sequential: {time.time() - start:.2f}s")

# Параллельно (threading)
start = time.time()
t1 = threading.Thread(target=cpu_task, args=(N,))
t2 = threading.Thread(target=cpu_task, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threading:  {time.time() - start:.2f}s")
# Результат: Threading работает примерно так же медленно или медленнее

Почему медленнее? Потому что потоки не работают параллельно — GIL допускает только один за раз. Плюс накладные расходы на постоянный take_gil() / drop_gil() и переключение контекста. В итоге threading на CPU-bound даёт нулевой или отрицательный эффект.

3.2 I/O-bound: threading прекрасно работает

I/O-bound задача — сетевые запросы, чтение файлов, ожидание базы данных. Здесь GIL освобождается на время ожидания, и потоки реально работают конкурентно:

import threading
import time
import urllib.request

urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def fetch(url):
    urllib.request.urlopen(url)

# Последовательно: ~4 секунды
start = time.time()
for url in urls:
    fetch(url)
print(f"Sequential: {time.time() - start:.1f}s")

# Threading: ~1 секунда
start = time.time()
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads: t.start()
for t in threads: t.join()
print(f"Threading:  {time.time() - start:.1f}s")

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

3.3 Итоговая таблица

Тип задачиthreadingmultiprocessing
CPU-bound (чистый Python)Не помогает (GIL)Помогает (N процессов)
I/O-bound (сеть, файлы, БД)ОтличноИзбыточно
asyncio (I/O без потоков)Не нужно

4. Когда threading всё-таки работает

4.1 I/O-сценарии на практике

Самый частый сценарий — конкурентные HTTP-запросы. ThreadPoolExecutor из модуля concurrent.futures даёт удобный API:

from concurrent.futures import ThreadPoolExecutor, as_completed
import urllib.request
import time

def fetch(url):
    with urllib.request.urlopen(url, timeout=5) as response:
        return response.read()

urls = ["https://httpbin.org/get"] * 10

start = time.time()
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(fetch, url): url for url in urls}
    for future in as_completed(futures):
        data = future.result()

print(f"Fetched {len(urls)} URLs in {time.time() - start:.2f}s")

ThreadPoolExecutor управляет пулом потоков: создаёт их при необходимости, переиспользует существующие, корректно завершает при выходе из with-блока.

4.2 Имитация I/O через time.sleep

Для демонстрации работы GIL на интервью часто используют time.sleep как замену реального I/O:

import threading
import time

def worker(name, delay):
    print(f"{name}: start")
    time.sleep(delay)   # GIL освобождается на время sleep
    print(f"{name}: done after {delay}s")

start = time.time()
threads = [
    threading.Thread(target=worker, args=(f"Thread-{i}", 1))
    for i in range(5)
]
for t in threads: t.start()
for t in threads: t.join()

print(f"Total: {time.time() - start:.1f}s")
# Total: ~1.0s — все 5 потоков спали одновременно

time.sleep тоже освобождает GIL: поток уходит в системный вызов, интерпретатор свободен для других потоков.

4.3 Ограничения threading

Threading решает I/O-конкурентность, но не CPU-параллелизм. Есть ещё одно ограничение: shared mutable state. Когда несколько потоков изменяют один объект, нужны явные блокировки (threading.Lock), иначе возможны гонки — и это несмотря на GIL. GIL защищает внутренние структуры интерпретатора, но не делает составные операции атомарными на уровне Python-кода.

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1  # Это НЕ атомарная операция!

threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)  # Часто не 1_000_000 — гонки при переключении GIL

counter += 1 компилируется в несколько байткод-инструкций: LOAD_GLOBAL, LOAD_CONST, BINARY_OP, STORE_GLOBAL. GIL может переключиться между любой из них.

Python-вопросы в Telegram

Ежедневные разборы GIL, asyncio и паттернов.

Подписаться

5. Как обойти GIL: три реальных подхода

5.1 multiprocessing: отдельные процессы

Самый надёжный способ обойти GIL для CPU-задач — multiprocessing. Каждый процесс — отдельный экземпляр Python-интерпретатора со своим GIL. Никакого разделения глобальной блокировки нет в принципе.

from multiprocessing import Pool
import time

def cpu_task(n):
    return sum(i * i for i in range(n))

if __name__ == "__main__":
    data = [5_000_000] * 4

    # Последовательно
    start = time.time()
    results = [cpu_task(n) for n in data]
    print(f"Sequential: {time.time() - start:.2f}s")

    # Параллельно с 4 процессами
    start = time.time()
    with Pool(processes=4) as pool:
        results = pool.map(cpu_task, data)
    print(f"Multiprocessing: {time.time() - start:.2f}s")
    # Реальное ускорение в ~4x на 4-ядерном CPU

ProcessPoolExecutor из concurrent.futures даёт аналогичный функционал с более удобным API:

from concurrent.futures import ProcessPoolExecutor

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(cpu_task, data))

Накладные расходы. Fork процесса дороже запуска потока: нужно создать новый адресный пространство, скопировать (или COW) память. Передача данных между процессами требует сериализации через pickle. Это значит: для задач с большими объёмами данных между процессами pickle может нивелировать выигрыш от параллелизма.

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

5.2 C-расширения: явное освобождение GIL

Второй подход — использовать библиотеки, написанные на C, которые явно освобождают GIL на тяжёлых операциях. NumPy — главный пример.

Когда NumPy выполняет векторизованную операцию (скалярное произведение, матричное умножение, np.sort), он вызывает Py_BEGIN_ALLOW_THREADS перед вычислением и Py_END_ALLOW_THREADS после. Всё время тяжёлой C-операции GIL свободен, и другие потоки могут выполнять Python-код.

import numpy as np
import threading
import time

def numpy_task(size):
    a = np.random.rand(size, size)
    b = np.random.rand(size, size)
    return np.dot(a, b)   # Освобождает GIL на время matmul

def python_task(n):
    return sum(i * i for i in range(n))   # Держит GIL

# numpy_task в двух потоках будет быстрее, чем последовательно,
# потому что np.dot освобождает GIL
start = time.time()
t1 = threading.Thread(target=numpy_task, args=(1000,))
t2 = threading.Thread(target=numpy_task, args=(1000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threading NumPy: {time.time() - start:.2f}s")

Cython позволяет писать C-расширения на синтаксисе, близком к Python, с явным управлением GIL через декораторы @cython.nogil. Для критически важных по производительности участков кода это стандартная практика.

5.3 Альтернативные интерпретаторы

Есть и более радикальные варианты — другие реализации Python без GIL:

Jython — Python на JVM. Нет GIL, есть настоящий параллелизм через Java-потоки. Но Jython застрял на Python 2.7 и не поддерживает большинство C-расширений (включая NumPy). На практике почти не используется.

PyPy STM (Software Transactional Memory) — экспериментальный режим PyPy с параллелизмом без GIL через STM. Проект активно не развивается.

Реальная альтернатива в экосистеме — это уже следующий раздел.

6. Free-threaded Python 3.13

6.1 PEP 703: Making the GIL Optional

В Python 3.13 появился экспериментальный режим без GIL, описанный в PEP 703. Автор — Сэм Гросс (Sam Gross), который несколько лет работал над патчем nogil для CPython.

Ключевые изменения в free-threaded Python:

  • ob_refcnt защищён через атомарные операции
  • Для часто изменяемых счётчиков используется biased reference counting
  • Сборка мусора переработана под многопоточность
  • Добавлены мьютексы на уровне отдельных объектов для критических операций

6.2 Как попробовать

В Python 3.13 появилась отдельная сборка интерпретатора: python3.13t (суффикс t — threaded). Установить можно через pyenv или официальные бинарники:

# pyenv
pyenv install 3.13t
pyenv local 3.13t

# Проверить режим
python3.13t -c "import sys; print(sys.flags.nogil)"
# True — GIL отключён

Программно проверить можно так:

import sys

if hasattr(sys, 'flags') and sys.flags.nogil:
    print("Running in free-threaded mode")
else:
    print("GIL is active")

6.3 Реальный параллелизм

В free-threaded режиме потоки действительно работают параллельно на нескольких ядрах. CPU-bound задачи с threading начинают масштабироваться:

import threading
import time
import sys

def cpu_task(n):
    return sum(i * i for i in range(n))

N = 5_000_000

# В обычном Python: ~одинаково медленно
# В python3.13t: threading даёт реальное ускорение
start = time.time()
t1 = threading.Thread(target=cpu_task, args=(N,))
t2 = threading.Thread(target=cpu_task, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Time: {time.time() - start:.2f}s")

6.4 Компромиссы и статус

Это не бесплатный обед. Без GIL каждая операция с объектом требует атомарных инструкций или локальных мьютексов — это медленнее, чем просто инкремент integer. По бенчмаркам ранних версий, однопоточный код в free-threaded Python медленнее обычного на 5–10%.

Статус: в Python 3.13 режим помечен как экспериментальный и требует явной opt-in (отдельная сборка). В Python 3.14 работа продолжается — разработчики оптимизируют однопоточную производительность. Многие популярные библиотеки (NumPy, pandas) уже начали адаптироваться.

Главный риск: библиотеки, которые неявно полагались на GIL как на синхронизацию, начинают проявлять гонки. C-расширения, написанные без учёта многопоточности, становятся небезопасными. Переходный период будет долгим.

7. Типичные вопросы на собеседовании с ответами

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

GIL — мьютекс в CPython, разрешающий только одному потоку выполнять байткод в каждый момент. Нужен для защиты ob_refcnt — счётчиков ссылок, через которые CPython управляет памятью. Без GIL одновременное изменение счётчиков из разных потоков привело бы к повреждению памяти.

GIL и потоки: можно ли ускорить CPU-bound программу потоками?

Нет. GIL гарантирует, что только один поток выполняет Python-байткод в каждый момент. Для CPU-bound задач threading не даёт параллелизма — только накладные расходы на переключение. Для CPU-bound нужен multiprocessing или C-расширения.

Почему I/O-bound задачи ускоряются с threading несмотря на GIL?

Потому что при I/O-операциях GIL явно освобождается: через Py_BEGIN_ALLOW_THREADS в C-реализации системных вызовов. Пока один поток ждёт ответа сети или диска, другой поток может выполнять Python-байткод. Потоки конкурентны по I/O, хоть и не параллельны по CPU.

Как обойти GIL для CPU-bound задач?

Три подхода. Первый — multiprocessing: каждый процесс имеет свой GIL, потоки процессов не конкурируют за общий замок. Второй — C-расширения (NumPy, Cython): они явно освобождают GIL во время тяжёлых вычислений. Третий — Python 3.13 в free-threaded режиме (python3.13t).

Чем ProcessPoolExecutor отличается от ThreadPoolExecutor?

ThreadPoolExecutor создаёт потоки в одном процессе — разделяют память и GIL, подходит для I/O-bound. ProcessPoolExecutor создаёт отдельные процессы — каждый со своим GIL и адресным пространством, подходит для CPU-bound, но требует pickle для передачи данных.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# I/O-bound: потоки
with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(fetch_url, urls))

# CPU-bound: процессы
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(cpu_heavy, data))

Что такое check interval?

Интервал, через который интерпретатор проверяет, нужно ли передать GIL ожидающему потоку. До Python 3.2 — sys.getcheckinterval(), измерялся в байткод-тиках (100 по умолчанию). С Python 3.2 — sys.getswitchinterval(), измеряется в секундах (0.005 по умолчанию — 5 мс).

Влияет ли GIL на asyncio?

Нет. asyncio работает в одном потоке: event loop кооперативно переключает корутины при каждом await. GIL не участвует в этой схеме — один поток, один владелец GIL. asyncio даёт I/O-конкурентность без создания потоков вообще.

Почему не убирают GIL?

Три причины. Первая — обратная совместимость: тысячи C-расширений используют Python C API, предполагающий наличие GIL. Вторая — однопоточная производительность: GIL делает управление памятью проще и быстрее, чем fine-grained locking. Третья — сложность реализации: правильный многопоточный сборщик мусора — нетривиальная задача. Python 3.13 начал решать это в free-threaded режиме.

NumPy и GIL: как они связаны?

NumPy написан на C и явно управляет GIL: перед тяжёлой операцией вызывает Py_BEGIN_ALLOW_THREADS, освобождая GIL, после — Py_END_ALLOW_THREADS, возвращая его. Это позволяет двум потокам одновременно выполнять матричные операции NumPy — у обоих есть C-вычисления без GIL.

Что такое free-threaded Python?

Python 3.13 в режиме без GIL — отдельная сборка python3.13t. Реализует PEP 703: атомарный подсчёт ссылок, переработанный GC. Позволяет потокам работать действительно параллельно. Статус: экспериментальный в 3.13, однопоточный код медленнее на ~5–10%.

Что происходит с GIL при вызове C-расширения?

Зависит от расширения. C-функция, вызванная из Python, изначально держит GIL. Если операция долгая (I/O, вычисления, системный вызов), хорошо написанное расширение явно освобождает GIL через Py_BEGIN_ALLOW_THREADS. Плохо написанное расширение держит GIL всё время — блокируя другие потоки.

Как написать параллельную обработку данных на Python?

Зависит от природы задачи. Для I/O-bound — ThreadPoolExecutor или asyncio. Для CPU-bound — ProcessPoolExecutor с Pool.map. Для вычислительно тяжёлых задач с массивами данных — NumPy или Cython с явным освобождением GIL. Выбор инструмента начинается с вопроса: «Где тратится время — в ожидании I/O или в вычислениях?»

8. Итоги: выбор инструмента

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

ЗадачаЛучший инструментПочему
CPU-bound (чистый Python)ProcessPoolExecutorОтдельные процессы, нет общего GIL
CPU-bound (числа, массивы)NumPy + threadingNumPy освобождает GIL для C-операций
I/O-bound (сеть, файлы)ThreadPoolExecutorGIL освобождается на I/O, потоки конкурентны
I/O-bound (высокая нагрузка)asyncioEvent loop без потоков, минимальный overhead
CPU + I/O (смешанное)asyncio + ProcessPoolExecutorOffload CPU в процессы, I/O через event loop

GIL не делает Python плохим языком для конкурентности — он делает его языком, где нужно правильно выбирать инструменты. asyncio прекрасно масштабирует тысячи соединений в одном потоке. multiprocessing даёт настоящий параллелизм для вычислений. NumPy обходит GIL для линейной алгебры. И всё это — без знания деталей реализации GIL в повседневной работе.

Но понять механику GIL важно не для того, чтобы писать лучший код прямо сейчас, а для того, чтобы правильно диагностировать проблемы: «Почему мой многопоточный код не ускоряется?», «Откуда гонки, ведь GIL должен защищать?», «Почему NumPy в потоках работает, а моя функция нет?»

Полный разбор других тем Python-собеседований — в статье Python: 50 вопросов на собеседовании junior–middle.

Потренируйся на реальных вопросах

50 вопросов по Python с разбором — GIL, asyncio, декораторы, память.

Начать тренировку

Автор

Lexicon Team

Читайте также