Сборщик мусора и GiL

Сборщик мусора и GiL #

1. Как определяется момент освобождения памяти объекта? #

Как определяется момент освобождения памяти объекта #

В Python момент освобождения памяти объекта определяется тем, остались ли на объект ссылки.

Объект живёт, пока на него кто-то ссылается:

a = [1, 2, 3]
b = a

Здесь список [1, 2, 3] имеет как минимум две ссылки:

a ─┐
   ├──> [1, 2, 3]
b ─┘

Пока существует хотя бы одна ссылка, объект не может быть освобождён.

Когда объект становится кандидатом на удаление #

Объект может быть освобождён, когда он становится недостижимым.

Пример:

a = [1, 2, 3]

a = None

После этого имя a больше не ссылается на список.

a ──> None

[1, 2, 3]  # больше недоступен

Если других ссылок на этот список нет, объект можно удалить.

Как это работает в CPython #

В стандартной реализации Python — CPython — основной механизм такой:

у каждого объекта есть счётчик ссылок

Когда появляется новая ссылка — счётчик увеличивается.

a = []
b = a

У списка теперь больше ссылок.

Когда ссылка исчезает — счётчик уменьшается.

del a

Когда счётчик ссылок становится равен 0, объект обычно освобождается сразу.

Упрощённо:

reference count == 0
объект больше никому не нужен
память можно освободить

Пример #

a = [1, 2, 3]
b = a

del a

Список ещё жив, потому что на него ссылается b.

del b

Теперь ссылок больше нет, и объект может быть удалён.

Важно: это не только del #

del не удаляет объект напрямую. Он удаляет имя/ссылку.

a = [1, 2, 3]

del a

Это значит:

удалить имя a
уменьшить количество ссылок на объект

Сам объект удаляется только тогда, когда на него больше нет ссылок.

Циклические ссылки #

Есть ситуация, где одного счётчика ссылок недостаточно.

Пример:

a = []
b = []

a.append(b)
b.append(a)

Получается цикл:

a -> list1 -> list2
     ↑       ↓
     └───────┘

Даже если удалить внешние имена:

del a
del b

объекты всё ещё ссылаются друг на друга.

list1 <──> list2

У них счётчик ссылок не равен нулю, но из программы они уже недоступны.

Для таких случаев в Python есть garbage collector, который ищет циклический мусор и удаляет его.

__del__ и момент уничтожения #

У объекта может быть специальный метод:

class FileWrapper:
    def __del__(self):
        print("Объект удаляется")

__del__ вызывается перед уничтожением объекта, но полагаться на него для важных действий не стоит.

Плохо:

class Resource:
    def __del__(self):
        self.close()

Лучше использовать контекстный менеджер:

with open("file.txt") as file:
    data = file.read()

Почему: точный момент вызова __del__ может зависеть от реализации Python и ситуации с garbage collector.

Главное отличие Python от C/C++ #

В Python обычно не нужно вручную освобождать память.

В C/C++ можно явно управлять памятью:

выделил память
использовал
освободил

В Python обычно так:

создал объект
используешь объект
объект стал недоступен
Python сам освобождает память

Коротко #

Момент освобождения памяти определяется так:

объект живёт, пока на него есть ссылки

В CPython:

счётчик ссылок стал 0
объект обычно удаляется сразу

Для циклических ссылок:

объекты ссылаются друг на друга
но недоступны из программы
их удаляет garbage collector

Главная идея:

не имя удаляет объект,
а отсутствие всех ссылок делает объект доступным для освобождения


2. Что такое GIL (Global Interpreter Lock) #

Что такое GIL #

GIL — это Global Interpreter Lock, глобальная блокировка интерпретатора.

В обычной сборке CPython GIL нужен для того, чтобы только один поток одновременно выполнял Python bytecode. То есть потоков может быть много, но в конкретный момент Python-код исполняет только один из них. ( Python documentation)

Упрощённо:

Thread 1 ──┐
Thread 2 ──┼──> GIL ───> Python interpreter
Thread 3 ──┘

В один момент времени внутрь проходит только один поток.

Зачем нужен GIL #

Главная причина — упрощение внутренней работы CPython.

CPython управляет объектами через счётчики ссылок:

объект живёт, пока на него есть ссылки

Если несколько потоков одновременно будут менять внутренние структуры интерпретатора и счётчики ссылок, можно получить повреждение состояния памяти.

GIL защищает внутренние структуры CPython от одновременного доступа из нескольких потоков.

Что GIL означает на практике #

Допустим, есть CPU-bound задача:

def count():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

Если запустить такую задачу в нескольких потоках:

from threading import Thread


threads = [
    Thread(target=count),
    Thread(target=count),
]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

Это не даст нормального ускорения на нескольких ядрах в обычном CPython, потому что Python-код всё равно исполняется под GIL.

То есть:

CPU-bound + threading + обычный CPython
обычно нет настоящего параллелизма Python-кода

Но потоки всё равно полезны #

GIL не делает threading бесполезным.

Потоки хорошо подходят для I/O-bound задач:

сетевые запросы
работа с файлами
ожидание ответа от базы данных
ожидание внешнего API

Когда поток ждёт ввод-вывод, другой поток может получить управление.

Пример:

from threading import Thread
import requests


def load_url(url):
    response = requests.get(url)
    return response.text


threads = [
    Thread(target=load_url, args=("https://example.com",)),
    Thread(target=load_url, args=("https://example.org",)),
]

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

GIL и CPU-bound задачи #

Для тяжёлых вычислений лучше использовать не потоки, а процессы:

from multiprocessing import Pool


def count(n):
    total = 0

    for i in range(n):
        total += i

    return total


if __name__ == "__main__":
    with Pool() as pool:
        result = pool.map(count, [100_000_000, 100_000_000])

Почему multiprocessing помогает:

threading       → несколько потоков внутри одного процесса
multiprocessing → несколько отдельных процессов

У каждого процесса свой интерпретатор и свой GIL. Поэтому процессы могут реально работать параллельно на разных ядрах. Документация Python прямо указывает, что multiprocessing обходит GIL за счёт использования подпроцессов. ( Python documentation)

GIL и async #

asyncio не убирает GIL.

asyncio — это про конкурентное выполнение задач в одном потоке:

одна задача ждёт I/O
event loop переключается на другую задачу

Но если внутри async-кода запустить тяжёлый CPU-bound цикл, он всё равно будет блокировать выполнение.

Плохо:

async def heavy():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

Такой код не становится параллельным просто потому, что он написан через async.

GIL и C-расширения #

Некоторые C-расширения могут отпускать GIL на время тяжёлых вычислений.

Например, библиотеки для численных расчётов могут выполнять тяжёлую работу вне обычного Python bytecode. Поэтому в некоторых случаях код с NumPy, криптографией, сжатием и похожими задачами может использовать несколько ядер эффективнее, чем чистый Python-код.

Общая идея:

чистый Python CPU-bound код
ограничен GIL

C-расширение, которое отпускает GIL
может работать параллельнее

Free-threaded Python #

Начиная с Python 3.13, в CPython появилась отдельная free-threaded сборка, где GIL может быть отключён. Это позволяет нескольким Python-потокам реально выполнять Python-код параллельно на разных ядрах. Но это не стандартный режим обычной CPython-сборки. ( Python documentation)

То есть важно различать:

обычный CPython
GIL включён

free-threaded CPython
GIL отключён

Коротко #

GIL — глобальная блокировка интерпретатора CPython.

Она означает:

в обычном CPython только один поток одновременно выполняет Python bytecode

Практический вывод:

I/O-bound задачи  → threading может быть полезен
CPU-bound задачи  → лучше multiprocessing / ProcessPoolExecutor / C-расширения
asyncio           → не убирает GIL
free-threaded Python → отдельная сборка CPython без GIL

Главная мысль:

GIL не запрещает многопоточность,
но ограничивает параллельное выполнение чистого Python-кода в обычном CPython.


3. Что такое и как устроен механизм сборки мусора? #

Что такое сборка мусора #

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

В Python программист обычно не пишет:

free(obj)
delete obj

Вместо этого Python сам отслеживает, можно ли удалить объект.

Главная идея:

объект можно удалить,
когда он больше недоступен из программы

Например:

a = [1, 2, 3]

a = None

После a = None список [1, 2, 3] больше не доступен через имя a.

Если других ссылок на него нет, объект можно удалить.

В CPython есть два основных механизма #

В CPython память управляется в основном через:

1. счётчик ссылок
2. garbage collector для циклических ссылок

То есть сборка мусора в Python — это не только gc, а связка нескольких механизмов.

1. Счётчик ссылок #

У каждого объекта в CPython есть счётчик ссылок.

Он показывает, сколько ссылок ведёт на объект.

a = [1, 2, 3]
b = a

Схематично:

a ─┐
   ├──> [1, 2, 3]
b ─┘

На список ссылаются a и b.

Если удалить одну ссылку:

del a

объект ещё жив:

b ───> [1, 2, 3]

Если удалить последнюю ссылку:

del b

список становится недоступным, и CPython обычно сразу освобождает объект.

Упрощённо:

reference count > 0
объект жив

reference count == 0
объект можно уничтожить

Важно: del не удаляет объект напрямую #

a = [1, 2, 3]

del a

del a удаляет имя a, то есть ссылку на объект.

Сам объект удаляется только тогда, когда ссылок на него больше нет.

Пример:

a = [1, 2, 3]
b = a

del a

print(b)

Результат:

[1, 2, 3]

Список не удалился, потому что на него всё ещё ссылается b.

2. Garbage collector для циклов #

Один счётчик ссылок не решает проблему циклических ссылок.

Пример:

a = []
b = []

a.append(b)
b.append(a)

Получается цикл:

a ───> list1 ───> list2
        ↑         ↓
        └─────────┘

Теперь удалим внешние ссылки:

del a
del b

Снаружи программа уже не может добраться до этих списков.

Но сами списки всё ещё ссылаются друг на друга:

list1 <──> list2

Проблема:

счётчик ссылок не равен 0,
но объекты уже недоступны из программы

Для этого и нужен отдельный сборщик мусора — garbage collector.

Он ищет такие недоступные циклы и удаляет их.

Как garbage collector понимает, что объект мусор #

Упрощённо он делает так:

1. отслеживает контейнерные объекты
2. ищет группы объектов, которые ссылаются друг на друга
3. проверяет, доступны ли они снаружи
4. если группа недоступна — её можно удалить

Контейнерные объекты — это объекты, которые могут хранить ссылки на другие объекты:

list
dict
set
tuple
class instance

Например:

users = [
    {"name": "Alex"},
    {"name": "Bob"},
]

Здесь список хранит словари, а словари хранят строки.

Такие объекты потенциально могут образовывать циклы, поэтому сборщик мусора их отслеживает.

Почему не все объекты отслеживаются GC #

Простые объекты обычно не участвуют в циклах:

x = 10
name = "Alex"
flag = True

Например, число 10 не может хранить ссылку на список или словарь.

Поэтому garbage collector в основном работает с объектами, которые могут содержать ссылки на другие объекты.

Поколения объектов #

В CPython сборщик мусора работает не постоянно, а периодически.

Идея такая:

многие объекты живут очень недолго
старые объекты обычно живут дольше

Поэтому объекты условно делятся на поколения.

Молодые объекты проверяются чаще, старые — реже.

Упрощённо:

новые объекты
проверяются часто

старые объекты
проверяются реже

Это нужно для производительности.

Если бы Python постоянно проверял всю память целиком, программа работала бы медленнее.

Когда запускается сборщик мусора #

Сборщик мусора может запускаться автоматически, когда Python видит, что было создано достаточно много новых объектов.

Упрощённо:

создали много контейнерных объектов
превысили внутренний порог
запустился garbage collector

Также его можно вызвать вручную:

import gc

gc.collect()

Но в обычном коде это почти никогда не нужно.

Пример с циклом #

import gc


a = []
b = []

a.append(b)
b.append(a)

del a
del b

collected = gc.collect()

print(collected)

gc.collect() принудительно запускает сборщик мусора и возвращает количество найденных недостижимых объектов.

Сборка мусора и __del__ #

У класса может быть метод __del__:

class User:
    def __del__(self):
        print("Объект удаляется")

Он вызывается, когда объект уничтожается.

Но полагаться на __del__ для важных действий не стоит.

Плохо:

class FileWrapper:
    def __del__(self):
        self.file.close()

Лучше так:

with open("data.txt") as file:
    data = file.read()

Причина:

момент уничтожения объекта не всегда удобен и предсказуем

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

Освобождение объекта не всегда значит возврат памяти ОС #

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

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

То есть:

объект удалён
память свободна для Python
но ОС может не увидеть мгновенного уменьшения RAM

Поэтому иногда процесс Python продолжает занимать много памяти даже после удаления больших объектов.

Общая схема #

создаётся объект
на него появляются ссылки
счётчик ссылок увеличивается

ссылки исчезают
счётчик ссылок уменьшается

если счётчик стал 0
объект обычно сразу уничтожается

если есть цикл
счётчик может быть не 0
garbage collector ищет недоступные циклы
удаляет их

Пример полностью #

import gc


class Node:
    def __init__(self, name):
        self.name = name
        self.link = None


node1 = Node("node1")
node2 = Node("node2")

node1.link = node2
node2.link = node1

del node1
del node2

gc.collect()

Что произошло:

node1 и node2 ссылались друг на друга
внешние имена удалили
объекты стали недоступны
gc.collect() нашёл цикл
цикл был удалён

Коротко #

В CPython сборка мусора устроена так:

1. Счётчик ссылок
   Удаляет большинство объектов сразу,
   когда на них больше нет ссылок.

2. Garbage collector
   Дополнительно ищет циклические ссылки,
   которые счётчик ссылок сам удалить не может.

3. Аллокатор памяти
   Может оставить освобождённую память внутри Python
   для последующего переиспользования.

Главная мысль:

объект удаляется не потому, что вызвали del,
а потому что он стал недоступен и больше не нужен программе


4. В чем основная идея введения механизма поколений #

Основная идея поколений в сборке мусора #

Идея механизма поколений основана на наблюдении:

большинство объектов в программе живут недолго

Например:

def func():
    data = [1, 2, 3]
    result = sum(data)
    return result

Список data нужен только во время выполнения функции. После выхода из функции он быстро становится ненужным.

Таких временных объектов в Python очень много:

локальные списки
временные словари
промежуточные кортежи
объекты внутри вычислений

Поэтому нет смысла каждый раз проверять всю память целиком.

Зачем нужны поколения #

Без поколений сборщик мусора мог бы работать так:

создалось много объектов
проверяем все объекты в памяти
ищем мусор

Проблема:

проверять все объекты дорого

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

Механизм поколений решает это так:

молодые объекты проверяем часто
старые объекты проверяем реже

Почему это эффективно #

Если объект только что создан, у него высокая вероятность быстро стать ненужным.

for i in range(1000):
    temp = [i, i * 2, i * 3]

temp на каждой итерации создаёт новый список, который почти сразу становится ненужным.

Такие объекты выгодно проверять часто.

А если объект живёт долго:

settings = {
    "debug": False,
    "host": "localhost",
    "port": 8000,
}

то вероятность, что он внезапно станет мусором, ниже.

Поэтому старые объекты проверяются реже.

Упрощённая схема #

новый объект
молодое поколение
пережил сборку мусора
переходит в более старое поколение
проверяется реже

То есть объект как бы “стареет”, если переживает проверки сборщика мусора.

Что это даёт #

Основная выгода — производительность.

Вместо того чтобы постоянно проверять всё:

все объекты
все контейнеры
всю память

Python чаще проверяет только небольшую часть объектов:

молодые объекты

А старые трогает реже.

Важно: поколения относятся именно к cyclic GC #

В CPython большинство объектов удаляются через счётчик ссылок:

ссылок стало 0
объект удаляется

Поколения нужны в основном для другого механизма — сборщика циклических ссылок.

Например:

a = []
b = []

a.append(b)
b.append(a)

del a
del b

Здесь объекты ссылаются друг на друга, поэтому одного счётчика ссылок недостаточно.

Для таких случаев работает garbage collector, и именно он использует идею поколений.

Коротко #

Основная идея поколений:

молодые объекты чаще становятся мусором,
поэтому их нужно проверять чаще

А старые объекты:

уже пережили несколько сборок
скорее всего, будут жить дольше
их можно проверять реже

Главная цель:

уменьшить стоимость сборки мусора
и не сканировать все объекты слишком часто


5. Где хранится счётчик ссылок у объекта? #

Где хранится счётчик ссылок у объекта #

В CPython счётчик ссылок хранится внутри самого объекта, точнее — в его служебном заголовке.

Не в переменной:

a = [1, 2, 3]

и не где-то отдельно “рядом” в Python-коде, а именно в структуре объекта в памяти.

Упрощённо:

a ───> [ служебный заголовок | данные объекта ]
        здесь хранится счётчик ссылок

Как это выглядит на уровне CPython #

В CPython почти каждый объект начинается со структуры PyObject.

Упрощённо:

typedef struct _object {
    Py_ssize_t ob_refcnt;
    PyTypeObject *ob_type;
} PyObject;

То есть у объекта есть минимум два важных служебных поля:

ob_refcnt  → счётчик ссылок
ob_type    → указатель на тип объекта

Схематично:

объект list в памяти:

┌────────────────────┐
│ ob_refcnt           │  ← количество ссылок на объект
├────────────────────┤
│ ob_type             │  ← ссылка на тип list
├────────────────────┤
│ размер списка       │
├────────────────────┤
│ указатель на items  │
└────────────────────┘

Счётчик ссылок принадлежит объекту, а не имени #

a = []
b = a

Здесь a и b — это имена, которые ссылаются на один объект.

a ─┐
   ├──> list object
b ─┘

Счётчик ссылок находится в самом list object.

Упрощённо:

list object:
    ob_refcnt = 2+

Почему 2+, а не строго 2: интерпретатор может иметь дополнительные временные внутренние ссылки.

Пример через sys.getrefcount #

import sys


lst = []

print(sys.getrefcount(lst))

Важно: sys.getrefcount(obj) обычно показывает значение на 1 больше ожидаемого, потому что при передаче объекта в функцию временно создаётся ещё одна ссылка.

Пример:

import sys


lst = []

print(sys.getrefcount(lst))  # например 2

a = lst

print(sys.getrefcount(lst))  # например 3

Схематично:

lst ─┐
     ├──> []
a ───┘

+ временная ссылка внутри sys.getrefcount()

Переменная не хранит объект #

В Python имя переменной хранит не сам объект, а ссылку на объект.

x = 10

Упрощённо:

x ───> объект int(10)

Счётчик ссылок находится здесь:

объект int(10):
    ob_refcnt
    ob_type
    значение числа

А не здесь:

x:
    ob_refcnt  # нет

Для объектов переменного размера #

Некоторые объекты, например list, tuple, str, имеют ещё размер.

Для них CPython использует структуру вроде PyVarObject.

Упрощённо:

typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size;
} PyVarObject;

То есть:

PyVarObject
├── PyObject
│   ├── ob_refcnt
│   └── ob_type
└── ob_size

Например, у списка есть:

ob_refcnt  → сколько ссылок на список
ob_type    → тип list
ob_size    → текущая длина списка
items      → указатель на массив элементов

Сборщик мусора и счётчик ссылок — не одно и то же #

У контейнерных объектов, которые отслеживаются циклическим GC, может быть дополнительный служебный заголовок для garbage collector.

Но сам счётчик ссылок всё равно находится в объектном заголовке CPython:

GC header          → данные для сборщика циклов
PyObject header    → ob_refcnt, ob_type
object data        → данные конкретного объекта

То есть:

счётчик ссылок ≠ данные garbage collector

Это разные механизмы.

Это особенность CPython #

Важно: это описание относится именно к CPython.

В других реализациях Python, например PyPy, управление памятью может быть устроено иначе. Там может не быть такого же счётчика ссылок у каждого объекта, потому что используется другой garbage collector.

Поэтому правильно говорить так:

В CPython счётчик ссылок хранится в заголовке объекта,
в поле ob_refcnt.

Коротко #

Счётчик ссылок хранится:

не в переменной
не в ссылке
не в стеке Python-кода
а внутри самого объекта

В CPython это поле называется:

ob_refcnt

и находится в служебном заголовке объекта:

PyObject
├── ob_refcnt
└── ob_type


6. Какой недостаток механизма подсчёта ссылок? #

Главный недостаток подсчёта ссылок #

Основной недостаток механизма подсчёта ссылок — он сам по себе не умеет удалять циклические ссылки.

То есть если объекты ссылаются друг на друга, их счётчики ссылок не становятся равными 0, даже если из программы эти объекты уже недоступны.

Пример проблемы #

a = []
b = []

a.append(b)
b.append(a)

del a
del b

Схематично:

list1 <──> list2

После:

del a
del b

внешних имён больше нет, но списки всё ещё держат друг друга:

list1 ссылается на list2
list2 ссылается на list1

Для подсчёта ссылок это выглядит так:

у list1 есть ссылка от list2
у list2 есть ссылка от list1

Значит счётчик ссылок не равен 0, и простой reference counting не удалит эти объекты.

Почему это проблема #

Объекты уже недоступны из программы:

программа больше не может обратиться к этим спискам

Но они всё ещё занимают память:

память занята
объекты логически уже мусор
но счётчик ссылок не равен 0

Это называется циклический мусор.

Как CPython решает эту проблему #

В CPython есть дополнительный механизм — циклический garbage collector.

Он нужен именно для таких случаев:

объекты ссылаются друг на друга
но снаружи уже недоступны

То есть в CPython работают вместе:

подсчёт ссылок
+
garbage collector для циклов

Другие недостатки подсчёта ссылок #

Кроме циклов, есть ещё несколько минусов.

1. Накладные расходы #

Каждый раз, когда появляется или исчезает ссылка, счётчик нужно обновлять.

a = obj  # увеличить счётчик ссылок
del a    # уменьшить счётчик ссылок

Таких операций в программе очень много, поэтому это создаёт постоянную нагрузку.

2. Дополнительная память #

У каждого объекта в CPython есть служебное поле для счётчика ссылок:

ob_refcnt

Это увеличивает размер каждого объекта.

3. Сложность в многопоточности #

Если несколько потоков одновременно меняют ссылки на объекты, нужно безопасно обновлять счётчики ссылок.

Иначе возможна ситуация:

один поток увеличивает счётчик
другой поток уменьшает счётчик
результат повреждается

В обычном CPython это связано с GIL: глобальная блокировка помогает защищать внутреннее состояние интерпретатора, включая операции со ссылками.

4. Удаление может произойти в неудобный момент #

Когда счётчик ссылок стал 0, объект может быть уничтожен сразу.

Иногда это вызывает цепочку удалений:

удалили один объект
он отпустил ссылки на другие объекты
те тоже стали ненужны
началась цепочка освобождения памяти

Обычно это нормально, но в больших структурах данных может дать заметную задержку.

Коротко #

Главный недостаток:

подсчёт ссылок не справляется с циклическими ссылками

Пример:

object A -> object B
object B -> object A

Если снаружи на них больше нет ссылок, они уже мусор, но их счётчики ссылок всё ещё не равны 0.

Поэтому в CPython одного подсчёта ссылок недостаточно. Нужен дополнительный сборщик мусора для циклов.


7. Как называется механизм удаления циклических ссылок? #

Как называется механизм удаления циклических ссылок #

Механизм удаления циклических ссылок называется:

циклический сборщик мусора

или по-английски:

cyclic garbage collector

Также часто говорят:

cycle GC

В Python с ним работает модуль:

import gc

Что он делает #

Он ищет объекты, которые:

1. ссылаются друг на друга
2. имеют ненулевые счётчики ссылок
3. но уже недоступны из программы

Пример:

a = []
b = []

a.append(b)
b.append(a)

del a
del b

После del a и del b получается цикл:

list1 <──> list2

Обычный подсчёт ссылок сам не удалит эти объекты, потому что они всё ещё держат ссылки друг на друга.

Тогда подключается:

cyclic garbage collector

Как можно вызвать вручную #

import gc

gc.collect()

gc.collect() вручную запускает сборщик мусора и пытается найти недоступные циклические объекты.

Коротко #

Механизм удаления циклических ссылок в Python называется cyclic garbage collector.

В CPython он дополняет основной механизм подсчёта ссылок:

reference counting
+
cyclic garbage collector


8. Как Python разрывает циклические ссылки? #

Как Python разрывает циклические ссылки #

В CPython циклические ссылки разрывает циклический сборщик мусораcyclic garbage collector.

Он не просто смотрит на счётчик ссылок. Он ищет группы объектов, которые ссылаются друг на друга, но недоступны из обычного кода.

Пример цикла:

a = []
b = []

a.append(b)
b.append(a)

del a
del b

После del a и del b внешних ссылок уже нет, но сами списки держат друг друга:

list1 ───> list2
  ↑         ↓
  └─────────┘

Для счётчика ссылок это не мусор, потому что ссылки всё ещё есть. Для garbage collector это мусор, потому что снаружи до этих объектов уже нельзя добраться.

Как CPython это определяет #

Упрощённо алгоритм такой:

1. GC берёт группу отслеживаемых объектов
2. Смотрит их реальные счётчики ссылок
3. Учитывает ссылки внутри этой группы
4. Определяет, есть ли ссылки на группу извне
5. Если внешних ссылок нет — группа считается недостижимой

То есть GC пытается понять:

объекты живы только потому,
что держат друг друга?

Если да, это циклический мусор.

Что значит “разрывает цикл” #

Когда GC понял, что группа объектов недостижима, он начинает очищать ссылки внутри этих объектов.

Упрощённо:

list1 ссылается на list2
list2 ссылается на list1

GC очищает внутренние ссылки
list1 больше не держит list2
list2 больше не держит list1
счётчики ссылок падают
объекты удаляются

Схематично:

До:

list1 ───> list2
  ↑         ↓
  └─────────┘

После очистки:

list1      list2

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

На уровне CPython #

На уровне реализации CPython у контейнерных объектов есть специальные функции очистки.

Например, объект может уметь сказать:

я храню ссылки на другие объекты,
и я могу эти ссылки очистить

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

Упрощённо:

GC нашёл недостижимый цикл
вызывает очистку контейнеров
контейнеры отпускают ссылки друг на друга
reference counting завершает удаление

Какие объекты GC отслеживает #

Циклический GC отслеживает в основном контейнерные объекты:

list
dict
set
tuple
экземпляры классов

Потому что именно они могут хранить ссылки на другие объекты и образовывать циклы.

Простые объекты вроде чисел обычно не образуют циклов:

x = 10
name = "Alex"
flag = True

Поэтому для них обычно достаточно обычного подсчёта ссылок.

Пример с классами #

import gc


class Node:
    def __init__(self, name):
        self.name = name
        self.link = None


node1 = Node("node1")
node2 = Node("node2")

node1.link = node2
node2.link = node1

del node1
del node2

gc.collect()

Сначала:

node1 ───> node2
  ↑         ↓
  └─────────┘

После удаления внешних имён:

Node("node1") <──> Node("node2")

Снаружи до них уже нельзя добраться, но они держат друг друга.

gc.collect() запускает сборщик мусора, он находит этот цикл и очищает внутренние ссылки.

Важный момент про __del__ #

Раньше циклы с объектами, у которых есть __del__, были проблемнее. В современных версиях Python такие циклы обрабатываются безопаснее, но всё равно лучше не строить важную логику освобождения ресурсов на __del__.

Для файлов, соединений, сокетов и подобных ресурсов лучше использовать:

with open("data.txt") as file:
    data = file.read()

или явно закрывать ресурс:

connection.close()

Коротко #

Python разрывает циклические ссылки так:

1. Находит группу объектов, которые ссылаются друг на друга.
2. Проверяет, есть ли ссылки на эту группу извне.
3. Если внешних ссылок нет — группа недостижима.
4. GC очищает внутренние ссылки между объектами.
5. Счётчики ссылок падают.
6. Объекты уничтожаются и память освобождается.

Главная идея:

если объекты живы только за счёт ссылок друг на друга,
но программа уже не может до них добраться,
то GC считает их мусором и разрывает этот цикл


9. В каких случаях память не освобождается после удаления ссылок? #

Когда память не освобождается после удаления ссылок #

В Python важно различать две ситуации:

1. Объект не удалился, потому что на него ещё есть ссылки.
2. Объект удалился, но память не вернулась операционной системе.

Это разные вещи.

1. На объект всё ещё есть другие ссылки #

del удаляет только имя, а не сам объект.

lst = [1, 2, 3]

other = lst

del lst

Список не удалится, потому что на него всё ещё ссылается other.

lst   ─X

other ───> [1, 2, 3]

Объект жив, пока на него есть хотя бы одна сильная ссылка.

2. Объект лежит внутри другого объекта #

Иногда ссылка остаётся не в переменной, а внутри контейнера.

data = [1, 2, 3]

storage = []
storage.append(data)

del data

Список [1, 2, 3] всё ещё жив:

storage ───> [[1, 2, 3]]

То же самое может быть со словарями, множествами, объектами классов, кэшем и так далее.

3. Объект хранится в глобальной переменной #

CACHE = []


def load_data():
    data = [1, 2, 3]
    CACHE.append(data)

    del data

После del data объект не удалится, потому что он попал в глобальный CACHE.

Это частая причина “утечек” памяти в Python-приложениях:

глобальный dict/list/cache продолжает держать объекты

4. Объект находится в замыкании #

def outer():
    data = [1, 2, 3]

    def inner():
        return data

    return inner


func = outer()

После завершения outer() список data не удаляется, потому что его держит внутренняя функция inner.

Схематично:

func ───> inner
          └──> data

Это называется замыкание.

5. Объект попал в traceback исключения #

Исключения могут удерживать стек вызовов, локальные переменные и объекты из этих фреймов.

Пример:

try:
    big_data = [0] * 10_000_000
    raise ValueError("error")
except ValueError as exc:
    saved_exception = exc

Если сохранить исключение, оно может удерживать traceback, а traceback — локальные переменные.

Упрощённо:

saved_exception
└── traceback
    └── frame
        └── locals
            └── big_data

Поэтому большие объекты могут жить дольше, чем ожидается.

6. Есть циклические ссылки #

Пример:

a = []
b = []

a.append(b)
b.append(a)

del a
del b

Внешних ссылок уже нет, но списки ссылаются друг на друга:

list1 <──> list2

Простой подсчёт ссылок не удалит их сразу, потому что счётчики ссылок не равны нулю.

Для этого нужен cyclic garbage collector.

Обычно Python сам периодически собирает такие циклы, но память может не освободиться мгновенно.

Можно запустить вручную:

import gc

gc.collect()

Но в обычном коде постоянно вызывать gc.collect() не нужно.

7. Garbage collector отключён #

Сборщик циклических ссылок можно отключить:

import gc

gc.disable()

Если он отключён, циклический мусор может не удаляться автоматически.

Проверить:

import gc

print(gc.isenabled())

Включить обратно:

gc.enable()

8. Объект удалился, но память осталась у Python #

Это очень важный момент.

Допустим, объект действительно был удалён:

data = [0] * 10_000_000

del data

Это не гарантирует, что потребление RAM в диспетчере задач сразу уменьшится.

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

То есть:

объект удалён
память свободна для Python
но не обязательно сразу возвращена ОС

Поэтому внешне может казаться, что память “не освободилась”, хотя Python уже может переиспользовать её внутри процесса.

9. Память фрагментирована #

Даже если часть объектов удалена, память может быть занята фрагментированными блоками.

Условно:

[занято][свободно][занято][свободно][занято]

Python может не вернуть такую память ОС, потому что внутри арены всё ещё остаются живые объекты.

Это особенно заметно в долгоживущих процессах:

web-серверы
боты
воркеры
фоновые сервисы

10. Работают внутренние кэши Python #

Некоторые объекты Python может кэшировать или переиспользовать.

Например:

маленькие числа
некоторые строки
внутренние структуры интерпретатора

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

Это нормальная оптимизация.

11. Память держит сторонняя библиотека #

Если используются библиотеки на C/C++ или расширения, память может управляться не только Python-объектами.

Например:

NumPy
Pandas
Pillow
PyTorch
OpenCV
драйверы БД
сетевые библиотеки

Python-объект может быть удалён, но нативная библиотека может держать собственный буфер, пул памяти или кэш.

12. Объект специально удерживается кэшем #

Пример с lru_cache:

from functools import lru_cache


@lru_cache
def get_data(n):
    return [0] * n


data = get_data(1_000_000)

del data

Список может остаться в кэше функции.

Очистить кэш можно так:

get_data.cache_clear()

Как проверить, что объект всё ещё жив #

Можно использовать sys.getrefcount():

import sys


lst = []

print(sys.getrefcount(lst))

Но важно: getrefcount() сам временно создаёт дополнительную ссылку, поэтому значение обычно на 1 больше ожидаемого.

Для поиска ссылок можно использовать:

import gc

gc.get_referrers(obj)

Но это инструмент для отладки, не для обычной логики приложения.

Коротко #

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

1. На объект всё ещё есть другие ссылки.
2. Объект находится внутри контейнера.
3. Его держит глобальный кэш.
4. Его держит замыкание.
5. Его держит traceback исключения.
6. Есть циклические ссылки.
7. Garbage collector отключён.
8. Объект удалён, но память осталась у аллокатора Python.
9. Есть фрагментация памяти.
10. Память держит сторонняя C/C++ библиотека.
11. Объект сохранён во внутреннем или пользовательском кэше.

Главная мысль:

del удаляет ссылку, а не гарантирует немедленное освобождение памяти ОС.

В CPython объект обычно уничтожается, когда его счётчик ссылок стал 0, но освобождённая память может остаться внутри процесса Python для повторного использования.


10. Как GIL влияет на асинхронность | многопоточность | многопроцессорность #

GIL влияет так:

asyncio             → GIL почти не мешает, пока задачи I/O-bound
threading           → мешает CPU-bound коду выполняться параллельно
multiprocessing     → обходит GIL через отдельные процессы

В обычном CPython GIL разрешает только одному потоку одновременно выполнять Python bytecode. Но это не значит, что в Python нет конкурентности вообще. Важно различать: асинхронность, потоки и процессы. Проверено по документации Python: threading, asyncio, multiprocessing, concurrent.futures, документация по free-threaded CPython.

1. GIL и асинхронность #

asyncio обычно работает в одном потоке.

Примерно так:

один поток
один event loop
много async-задач

Пример:

import asyncio


async def load_data():
    await asyncio.sleep(1)
    return "done"


async def main():
    result = await asyncio.gather(
        load_data(),
        load_data(),
        load_data(),
    )

    print(result)


asyncio.run(main())

Здесь задачи не выполняются параллельно на разных ядрах. Они выполняются конкурентно:

задача 1 ждёт I/O
event loop переключается на задачу 2
задача 2 ждёт I/O
event loop переключается на задачу 3

То есть asyncio хорошо подходит для:

HTTP-запросов
работы с БД
сокетов
очередей
файлового/сетевого ожидания

Но если внутри async-функции запустить тяжёлый CPU-bound код, GIL не поможет и asyncio не спасёт.

Плохо:

async def heavy_calculation():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

Такая функция будет блокировать event loop.

Главная мысль:

asyncio не убирает GIL
asyncio просто позволяет эффективно переключаться во время ожидания

2. GIL и многопоточность #

Многопоточность в Python — это threading.

Пример:

from threading import Thread


def task():
    total = 0

    for i in range(50_000_000):
        total += i


t1 = Thread(target=task)
t2 = Thread(target=task)

t1.start()
t2.start()

t1.join()
t2.join()

В обычном CPython два потока не будут одновременно выполнять Python bytecode на двух ядрах.

Схема:

Thread 1 ─┐
Thread 2 ─┼──> GIL ───> CPython interpreter
Thread 3 ─┘

В конкретный момент Python-код выполняет только один поток.

Поэтому для чистого Python CPU-bound кода:

threading обычно не даёт ускорения

CPU-bound задачи:

математические вычисления
обработка больших массивов чистым Python-кодом
тяжёлые циклы
парсинг больших данных без внешних C-библиотек

Но потоки полезны для I/O-bound задач:

from threading import Thread
import requests


def load_url(url):
    response = requests.get(url)
    print(len(response.text))


threads = [
    Thread(target=load_url, args=("https://example.com",)),
    Thread(target=load_url, args=("https://example.org",)),
]

for thread in threads:
    thread.start()

for thread in threads:
    thread.join()

Почему здесь потоки могут быть полезны:

поток ждёт сеть
в это время другой поток может работать

Главная мысль:

threading хорош для I/O-bound
threading плох для ускорения чистого CPU-bound Python-кода

3. GIL и многопроцессность #

Правильнее говорить многопроцессность, а не многопроцессорность.

В Python для этого используют:

multiprocessing

или:

concurrent.futures.ProcessPoolExecutor

Пример:

from multiprocessing import Pool


def calculate(n):
    total = 0

    for i in range(n):
        total += i

    return total


if __name__ == "__main__":
    with Pool() as pool:
        result = pool.map(calculate, [50_000_000, 50_000_000])

    print(result)

Здесь создаются отдельные процессы.

Схема:

Process 1 → свой Python interpreter → свой GIL
Process 2 → свой Python interpreter → свой GIL
Process 3 → свой Python interpreter → свой GIL

Так как процессы отдельные, они могут реально выполняться параллельно на разных ядрах CPU.

Поэтому для CPU-bound задач лучше:

multiprocessing
ProcessPoolExecutor

А не:

threading

Минусы многопроцессности:

процессы тяжелее потоков
у каждого процесса своя память
данные между процессами нужно передавать
есть расходы на сериализацию/pickle
старт процессов дороже, чем старт потоков

Сравнение #

МеханизмПараллелизм CPUПодходит дляКак влияет GIL
asyncioНетI/O-boundНе убирает GIL, но эффективно переключает задачи на ожидании
threadingОбычно нетI/O-boundОдин поток выполняет Python bytecode в момент времени
multiprocessingДаCPU-boundОбходит GIL через отдельные процессы

CPU-bound vs I/O-bound #

CPU-bound:

задача упирается в процессор

Пример:

def cpu_bound():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

Лучше использовать:

multiprocessing
ProcessPoolExecutor

I/O-bound:

задача много ждёт внешние ресурсы

Пример:

HTTP-запрос
запрос в БД
чтение файла
ожидание ответа сервера

Лучше использовать:

asyncio
threading

Важное уточнение про новые версии Python #

В обычной сборке CPython GIL есть.

Но начиная с Python 3.13 появилась возможность free-threaded сборки CPython, где GIL может быть отключён. В Python 3.14 free-threaded Python уже официально поддерживается, но всё ещё является отдельным опциональным режимом, а не обычным поведением стандартной сборки.

То есть на практике чаще всего:

обычный CPython
GIL включён

А отдельно:

free-threaded CPython
GIL может быть отключён

Коротко по выбору #

Много сетевых запросов?
asyncio или threading

Много запросов в БД?
asyncio, если драйвер async
threading, если код синхронный

Тяжёлые вычисления на CPU?
multiprocessing / ProcessPoolExecutor

Чистый Python-код в потоках?
GIL не даст нормального CPU-параллелизма

Главная мысль:

GIL ограничивает многопоточность для CPU-bound Python-кода,
но не мешает эффективно использовать async/threading для I/O-bound задач
и обходится через multiprocessing для CPU-bound задач.


11. Как GIL ограничивает потоки в одном процессе? #

Как GIL ограничивает потоки в одном процессе #

В обычном CPython у процесса есть глобальная блокировка интерпретатора — GIL.

Её смысл:

внутри одного процесса несколько потоков могут существовать,
но Python bytecode одновременно выполняет только один поток

То есть потоки есть:

Thread 1
Thread 2
Thread 3

Но перед выполнением Python-кода каждый поток должен получить GIL:

Thread 1 ─┐
Thread 2 ─┼──> GIL ───> выполнение Python bytecode
Thread 3 ─┘

В конкретный момент времени GIL держит только один поток.

Что происходит при CPU-bound задаче #

Допустим, есть тяжёлая вычислительная функция:

def calculate():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

Запустим её в двух потоках:

from threading import Thread


t1 = Thread(target=calculate)
t2 = Thread(target=calculate)

t1.start()
t2.start()

t1.join()
t2.join()

На машине с несколькими ядрами может казаться, что два потока должны считать параллельно.

Но в обычном CPython будет примерно так:

Thread 1 получил GIL
немного выполняет Python-код
отдаёт GIL

Thread 2 получил GIL
немного выполняет Python-код
отдаёт GIL

Thread 1 снова получил GIL
...

То есть потоки не выполняют Python-код одновременно. Они по очереди получают доступ к интерпретатору.

Главное ограничение #

GIL не запрещает создавать много потоков.

Он ограничивает именно это:

одновременное выполнение Python bytecode несколькими потоками
внутри одного процесса

Поэтому для чистого Python CPU-bound кода:

2 потока ≠ ускорение в 2 раза
4 потока ≠ ускорение в 4 раза

Часто может быть даже медленнее из-за расходов на переключение потоков.

Почему потоки всё равно переключаются #

GIL не выдаётся одному потоку навсегда.

Интерпретатор периодически переключает выполнение между потоками:

Thread 1 работает
пауза / переключение
Thread 2 работает
пауза / переключение
Thread 1 работает дальше

Это даёт конкурентность, но не настоящий параллелизм Python-кода на CPU.

Разница:

конкурентность:
задачи продвигаются по очереди

параллелизм:
задачи реально выполняются одновременно

В одном процессе с GIL для Python bytecode обычно есть конкурентность, но нет полноценного CPU-параллелизма.

Когда GIL отпускается #

GIL может быть отпущен, когда поток не выполняет Python bytecode, а ждёт внешнюю операцию.

Например:

ожидание сетевого ответа
чтение файла
запрос в базу данных
sleep
некоторые C-расширения

Пример:

import time
from threading import Thread


def wait():
    time.sleep(2)
    print("done")


t1 = Thread(target=wait)
t2 = Thread(target=wait)

t1.start()
t2.start()

t1.join()
t2.join()

Здесь оба потока могут завершиться примерно за 2 секунды, а не за 4, потому что sleep — это ожидание, а не активное выполнение Python-кода.

Схема:

Thread 1 ждёт I/O
GIL может быть отдан другому потоку
Thread 2 тоже работает или ждёт

Почему threading полезен для I/O-bound #

Потоки в Python полезны, когда задача много ждёт:

HTTP-запросы
работа с БД
файловые операции
сокеты
очереди

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

Для I/O-bound задач:

threading может дать хороший эффект

Для CPU-bound задач:

threading обычно не даёт нормального ускорения

Почему multiprocessing обходит GIL #

GIL действует внутри одного интерпретатора Python.

А при multiprocessing создаются отдельные процессы:

Process 1 → свой интерпретатор → свой GIL
Process 2 → свой интерпретатор → свой GIL
Process 3 → свой интерпретатор → свой GIL

Поэтому процессы могут реально выполняться параллельно на разных ядрах.

Для CPU-bound задач лучше:

from concurrent.futures import ProcessPoolExecutor


def calculate(n):
    total = 0

    for i in range(n):
        total += i

    return total


if __name__ == "__main__":
    with ProcessPoolExecutor() as executor:
        results = executor.map(calculate, [100_000_000, 100_000_000])

Здесь уже не один GIL на все задачи, а отдельный GIL внутри каждого процесса.

Важно: GIL не защищает твой код от race condition #

Иногда думают:

раз есть GIL, значит в потоках не бывает гонок данных

Это неверно.

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

Пример:

counter = 0


def increment():
    global counter

    for _ in range(100_000):
        counter += 1

Операция:

counter += 1

не обязана быть одной неделимой операцией на уровне логики.

Упрощённо она состоит из шагов:

прочитать counter
прибавить 1
записать новое значение

Между этими шагами возможны переключения. Поэтому для общей изменяемой памяти всё равно нужны:

from threading import Lock

или другие механизмы синхронизации.

Итоговая схема #

Один процесс CPython
├── Thread 1
├── Thread 2
├── Thread 3
└── один общий GIL

Что разрешено:

много потоков
переключение между потоками
ожидание I/O в одних потоках и работа других

Что ограничено:

одновременное выполнение Python bytecode несколькими потоками

Главное #

GIL ограничивает потоки так:

в одном процессе обычного CPython
только один поток за раз может выполнять Python bytecode

Поэтому:

I/O-bound задачи  → threading полезен
CPU-bound задачи  → threading почти не ускоряет
CPU-bound задачи  → лучше multiprocessing / ProcessPoolExecutor

GIL не запрещает многопоточность, но превращает выполнение Python-кода в потоках в поочерёдное владение интерпретатором.


12. Почему GIL не удаляют полностью в новых версиях Python? #

Почему GIL не удаляют полностью сразу #

GIL не удаляют резко, потому что это сломало бы слишком много существующей экосистемы CPython.

Правильнее сказать так:

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

Начиная с Python 3.13 появились free-threaded сборки CPython, где GIL может быть отключён, но это не режим по умолчанию. В Python 3.14 этот режим был серьёзно улучшен, но он всё ещё остаётся отдельным вариантом исполнения, а не полной заменой обычного CPython. ( Python documentation) ( Python documentation)

1. Огромная совместимость со старым кодом #

Много Python-кода и особенно C-расширений исторически писались с расчётом на то, что есть GIL.

Например, расширение могло не защищать свои внутренние структуры отдельными lock’ами, потому что предполагалось:

одновременно Python-код выполняет только один поток

Если убрать GIL резко, часть расширений может получить:

race condition
повреждение памяти
нестабильные падения
странные баги

Поэтому free-threaded режим вводят постепенно. Документация Python прямо указывает, что некоторые сторонние пакеты, особенно с extension modules, могут быть не готовы к free-threaded сборке и могут снова включать GIL при импорте.

2. Удаление GIL требует переделки внутренностей CPython #

GIL защищал много внутренних структур интерпретатора.

Чтобы убрать его, нужно заменить одну большую блокировку множеством более точных механизмов:

атомарные операции
локальные блокировки
потокобезопасные контейнеры
изменения в подсчёте ссылок
изменения в аллокаторе памяти

PEP 703 прямо описывает, что для удаления GIL нужны существенные изменения в CPython: подсчёт ссылок, управление памятью, потокобезопасность контейнеров, locking и atomic API.

3. Может просесть производительность однопоточного кода #

GIL мешает CPU-bound многопоточности, но он также делает обычный однопоточный CPython проще и быстрее.

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

В Python 3.14 free-threaded режим улучшили, но документация всё равно указывает примерный штраф для однопоточного кода около 5–10% в зависимости от платформы и компилятора.

4. Не весь код автоматически ускорится #

Убрать GIL не значит автоматически ускорить любую Python-программу.

Например, это не сильно поможет, если программа:

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

Free-threaded Python полезнее для кода, который реально написан под параллельное выполнение потоков. PEP 779 отдельно подчёркивает, что это не простое drop-in решение: некоторый код нужно проектировать иначе, чтобы получить выгоду и не получить проблемы производительности.

5. Удаление GIL повышает риск гонок данных #

С GIL многие операции выглядели “достаточно безопасными” на практике, хотя это не полноценная защита бизнес-логики.

Без GIL больше кода должен явно думать о синхронизации:

from threading import Lock


lock = Lock()
counter = 0


def increment():
    global counter

    with lock:
        counter += 1

То есть программистам чаще придётся использовать:

Lock
RLock
Queue
Semaphore
thread-safe структуры
аккуратную архитектуру shared state

Это делает модель исполнения мощнее, но сложнее.

6. Нужен постепенный переход экосистемы #

Проблема не только в CPython. Есть ещё:

NumPy
Pandas
PyTorch
Pillow
cryptography
драйверы БД
web-фреймворки
Cython/pybind11 расширения
колёса под разные ABI

Многие библиотеки должны быть проверены, адаптированы и собраны под free-threaded режим.

PEP 779 описывает phased rollout: сначала экспериментальная стадия, затем официально поддерживаемая, но опциональная сборка, и только потом возможный переход к default-режиму.

Главное #

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

сломалась бы совместимость
усложнилась бы внутренняя архитектура CPython
часть C-расширений стала бы небезопасной
однопоточный код мог бы замедлиться
экосистема не успела бы адаптироваться

Поэтому выбран постепенный путь:

обычный CPython
GIL включён

free-threaded CPython
GIL можно отключить

Итог:

GIL не “не удаляют”,
а переводят в опциональный режим постепенно,
чтобы не сломать Python-экосистему.


13. Зачем (С какой целью) вообще был придуман GiL? #

Зачем вообще придумали GIL #

GIL придумали не как “ограничитель потоков”, а как упрощение и защита внутренней работы CPython.

Главная цель:

сделать управление памятью и объектами CPython простым, быстрым и безопасным
в условиях многопоточности

Главная причина — счётчик ссылок #

В CPython у каждого объекта есть счётчик ссылок:

ob_refcnt

Он показывает, сколько ссылок ведёт на объект.

Пример:

a = []
b = a

Схема:

a ─┐
   ├──> list object
b ─┘

Когда появляется новая ссылка, счётчик увеличивается.

Когда ссылка исчезает, счётчик уменьшается.

del a

Проблема: в многопоточной программе несколько потоков могут одновременно менять ссылки на один и тот же объект.

Например:

Thread 1 увеличивает ob_refcnt
Thread 2 уменьшает ob_refcnt

Если делать это без защиты, можно повредить счётчик ссылок:

счётчик стал неправильным
объект удалился слишком рано
или
объект никогда не удалился

Это уже критическая ошибка управления памятью.

GIL как простая глобальная защита #

Вместо того чтобы ставить отдельные блокировки на каждый объект и каждую внутреннюю структуру, CPython использует одну большую блокировку:

GIL

Схема:

Thread 1 ─┐
Thread 2 ─┼──> GIL ───> работа с объектами CPython
Thread 3 ─┘

В конкретный момент времени только один поток может выполнять Python bytecode и изменять внутреннее состояние интерпретатора.

Это защищает:

счётчики ссылок объектов
таблицы объектов
внутренние структуры интерпретатора
память CPython
часть C API

Почему не сделали много мелких lock’ов #

Можно было бы не делать GIL, а поставить много мелких блокировок:

lock на объект
lock на dict
lock на list
lock на allocator
lock на import system
lock на type system
...

Но у этого есть минусы:

сложнее реализация
больше накладных расходов
выше риск deadlock
сложнее писать C-расширения
медленнее однопоточный код
сложнее отлаживать интерпретатор

Для раннего CPython это было особенно важно: Python должен был быть простым, переносимым и достаточно быстрым в обычном однопоточном сценарии.

GIL ускорял однопоточный Python #

Парадоксально, но GIL не только ограничивает многопоточность, он ещё и помогает обычному CPython быть быстрее в однопоточном режиме.

Без GIL многие операции пришлось бы делать через:

атомарные операции
мелкие блокировки
потокобезопасные структуры
дополнительные проверки

А это замедлило бы даже код, который вообще не использует потоки.

То есть GIL был компромиссом:

быстрее и проще однопоточный Python
но хуже CPU-bound многопоточность

GIL упростил C-расширения #

Ещё одна важная цель — удобство для C-расширений.

Многие модули для CPython написаны на C:

расширения для БД
численные библиотеки
криптография
парсеры
системные модули

С GIL C-расширение может работать с Python-объектами, не ставя отдельные lock’и на каждый доступ.

Упрощённо:

есть GIL
значит во время работы с Python-объектами
другой поток не меняет их параллельно

Это сильно упрощает написание extension modules.

Почему это было разумно исторически #

Когда Python создавался и развивался, типичный сценарий был не такой:

запустить 16 CPU-bound потоков на 16 ядрах

А скорее такой:

скрипты
автоматизация
серверная логика
работа с файлами
сетевой ввод-вывод
интеграция с C-библиотеками

Для таких задач GIL был приемлемым компромиссом.

Он давал:

простую модель памяти
стабильный интерпретатор
быстрое управление объектами
простую интеграцию с C
нормальную производительность в одном потоке

Почему GIL не мешает всему подряд #

GIL мешает в основном тогда, когда несколько потоков одновременно хотят выполнять чистый Python CPU-bound код.

Например:

def calculate():
    total = 0

    for i in range(100_000_000):
        total += i

    return total

В потоках такой код не будет нормально параллелиться.

Но при I/O-задачах GIL не такая большая проблема:

запросы в сеть
ожидание БД
чтение файлов
sleep

Пока один поток ждёт I/O, другой может получить GIL и работать.

Коротко #

GIL придумали для того, чтобы:

1. Защитить внутренние структуры CPython.
2. Безопасно обновлять счётчики ссылок объектов.
3. Упростить управление памятью.
4. Упростить написание C-расширений.
5. Сохранить хорошую скорость однопоточного Python.
6. Избежать большого количества мелких lock'ов внутри интерпретатора.

Главная идея:

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

Цена этого компромисса:

потоки внутри одного процесса плохо ускоряют CPU-bound Python-код


14. Можно ли самостоятельно переключить GIL на другой поток? #

Можно ли самостоятельно переключить GIL на другой поток #

Нет, из обычного Python-кода нельзя напрямую сказать:

GIL, перейди с Thread-1 на Thread-2

У Python нет публичного API вида:

switch_gil_to(thread)

GIL управляется самим интерпретатором CPython и планировщиком ОС.

Кто реально управляет переключением #

В обычном CPython переключение между потоками происходит примерно так:

Thread 1 держит GIL
выполняет Python bytecode
интерпретатор решает, что пора дать шанс другому потоку
Thread 1 отпускает GIL
другой поток может его получить

То есть поток не выбирает напрямую, кому передать GIL.

Схема:

Thread 1 ─┐
Thread 2 ─┼──> борьба за GIL
Thread 3 ─┘

GIL получает тот поток, которому это позволит интерпретатор/ОС.

Можно ли повлиять косвенно #

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

Например:

import time

time.sleep(0)

Это может дать планировщику шанс переключиться на другой поток.

Пример:

import time
from threading import Thread


def worker(name):
    for i in range(5):
        print(name, i)
        time.sleep(0)


t1 = Thread(target=worker, args=("Thread-1",))
t2 = Thread(target=worker, args=("Thread-2",))

t1.start()
t2.start()

t1.join()
t2.join()

Но важно:

time.sleep(0) не говорит:
"передай GIL конкретно Thread-2"

Он только даёт шанс переключиться.

Можно настроить интервал переключения #

В Python есть функция:

import sys

sys.setswitchinterval(0.005)

Она задаёт примерный интервал переключения потоков в секундах.

Посмотреть текущее значение:

import sys

print(sys.getswitchinterval())

Пример:

import sys

sys.setswitchinterval(0.001)

Это значит: интерпретатор будет чаще давать шанс другим потокам.

Но это тоже не ручное управление GIL.

sys.setswitchinterval()
меняет частоту попыток переключения
но не выбирает конкретный поток

Когда GIL отпускается автоматически #

GIL может быть отпущен, когда поток:

ждёт I/O
делает sleep
ждёт lock/event/condition
выполняет C-расширение, которое отпускает GIL

Например:

time.sleep(1)

Во время сна поток не выполняет Python bytecode, поэтому другой поток может получить GIL.

То же самое часто происходит при:

сетевых запросах
ожидании ответа БД
чтении/записи файлов

Можно ли управлять порядком потоков #

Можно управлять не GIL напрямую, а логикой выполнения потоков через синхронизацию:

from threading import Thread, Event


event = Event()


def first():
    print("first started")
    event.set()


def second():
    event.wait()
    print("second started after first")


t1 = Thread(target=first)
t2 = Thread(target=second)

t2.start()
t1.start()

t1.join()
t2.join()

Здесь мы не переключаем GIL вручную.

Мы просто говорим:

second ждёт,
пока first не разрешит ему продолжить

Для этого используют:

Lock
RLock
Event
Condition
Semaphore
Queue

На уровне C-расширений #

В C-расширениях для CPython можно явно отпустить GIL на время долгой операции.

Упрощённо:

Py_BEGIN_ALLOW_THREADS

/* долгая операция без работы с Python-объектами */

Py_END_ALLOW_THREADS

Но это не значит “передать GIL конкретному потоку”.

Это значит:

текущий C-код временно не трогает Python-объекты
можно отпустить GIL
другие Python-потоки получают шанс работать

Коротко #

Напрямую переключить GIL на другой поток нельзя.

Можно только косвенно повлиять:

time.sleep(0)              → дать шанс другому потоку
sys.setswitchinterval()    → изменить частоту переключений
I/O-операции               → поток может отпустить GIL
threading.Event/Lock       → управлять порядком выполнения потоков
C-расширения               → могут временно отпускать GIL

Главная мысль:

GIL — это внутренняя блокировка интерпретатора,
а не объект, которым обычный Python-код управляет напрямую.


15. Освобождается/Отпускается ли GIL во время выполнения Python-программы? #

Да, GIL периодически освобождается во время выполнения Python-программы #

Но важно различать два случая:

1. GIL отпускается для переключения между потоками
2. GIL отпускается на время блокирующих операций

1. Во время выполнения Python-кода GIL может переходить между потоками #

В обычном CPython с включённым GIL в один момент времени только один поток выполняет Python-код. Но интерпретатор периодически пытается переключаться между потоками между bytecode-инструкциями. Частоту таких переключений можно настраивать через sys.setswitchinterval()

Упрощённо:

Thread-1 держит GIL
выполняет часть Python bytecode
интерпретатор даёт шанс другому потоку
Thread-1 отпускает GIL
Thread-2 захватывает GIL
Thread-2 выполняет Python bytecode

Но это не значит, что два Python-потока одновременно выполняют Python-код на разных ядрах. В обычном CPython из-за GIL одновременно Python-код выполняет только один поток

2. GIL отпускается во время блокирующего I/O #

Например, когда поток ждёт:

чтение файла
запись файла
сетевой запрос
ответ от БД
socket recv/send

На время ожидания GIL обычно отпускается, чтобы другие потоки могли продолжить работу. Документация CPython прямо указывает, что GIL освобождается вокруг блокирующих I/O-операций, например чтения или записи файла

Примерно так:

with open("data.txt") as f:
    data = f.read()

Во время реального ожидания чтения с диска поток может отпустить GIL.

3. C-расширения тоже могут отпускать GIL #

Некоторые C-расширения отпускают GIL, если выполняют долгую работу без обращения к Python-объектам. В документации как примеры указаны zlib и hashlib: они могут отсоединять thread state при сжатии или хешировании данных

То есть код вроде:

import hashlib

hashlib.sha256(large_data).hexdigest()

может выполнять тяжёлую нативную работу без удержания GIL всё время.

4. Для CPU-bound Python-кода GIL не даёт настоящего параллелизма #

Например:

def calc():
    total = 0
    for i in range(100_000_000):
        total += i

Если запустить несколько таких функций в разных threading.Thread, потоки будут конкурировать за GIL:

Thread-1 считает
Thread-2 ждёт GIL
Thread-1 отпустил GIL
Thread-2 считает
Thread-1 ждёт GIL
...

Это конкурентность, но не полноценный параллелизм Python-кода на нескольких ядрах.

Для CPU-bound задач обычно используют:

multiprocessing
concurrent.futures.ProcessPoolExecutor

Потому что разные процессы имеют разные интерпретаторы и не делят один GIL

5. В Python 3.13+ есть исключение: free-threaded build #

Начиная с Python 3.13, CPython поддерживает отдельную free-threaded-сборку, где GIL может быть отключён. Но это не обычный режим по умолчанию, а специальная сборка/конфигурация

Итог #

Да, GIL освобождается/отпускается во время выполнения программы:

Да — при переключении между потоками.
Да — во время блокирующего I/O.
Да — в некоторых C-расширениях.
Нет — это не даёт обычным Python-потокам одновременно выполнять Python bytecode на разных ядрах.

Главная мысль:

GIL не держится одним потоком навсегда.
Он периодически передаётся другим потокам,
но в обычном CPython одновременно Python-код выполняет только один поток.


16. Является ли GIL частью языка Python или деталью реализации CPython? #

GIL не является частью языка Python.
Это деталь реализации CPython — основной, канонической реализации Python, которую обычно скачивают с python.org. Документация отдельно различает CPython и другие реализации Python, например Jython или IronPython.

Что это значит #

Язык Python описывает синтаксис и поведение кода:

x = 10
def func():
    return x + 1

А CPython решает, как именно это выполнить внутри:

Python-код
байткод
виртуальная машина CPython
GIL, refcount, GC, C API и т.д.

GIL находится именно на этом уровне — внутри реализации интерпретатора.

Почему тогда говорят “GIL в Python” #

Потому что под “Python” чаще всего имеют в виду CPython. В обычной сборке CPython с включённым GIL только один поток может выполнять Python-байткод в один момент времени. Это ограничивает CPU-bound многопоточность.

Но это не требование самого языка. Например, начиная с Python 3.13 у CPython есть free-threaded сборка, где GIL может быть отключён. Документация прямо описывает это как сборку CPython без GIL.

Итог #

Правильная формулировка:

GIL — это не часть языка Python,
а механизм реализации CPython.

Ещё точнее:

В стандартной GIL-enabled сборке CPython GIL ограничивает выполнение Python-байткода одним потоком за раз,
но сам язык Python не требует наличия GIL.