Не так давно вышел Python 3.14.5 – предпоследний стабильный релиз языка. Напомним, что в релизе 3.14.0 (вышедшем в октябре 2025 года) классический иерархический сборщик мусора (generational garbage collector) заменили на инкрементальный (incremental GC).

Из пулл-реквеста, в рамках которого вносились эти изменения:

Циклический сборщик мусора теперь работает в инкрементальном режиме. Благодаря этому, максимальная продолжительность пауз на свалках динамической памяти (heap) большого объема сократилась на порядок, а то и сильнее.

Теперь поколений всего два: молодое и старшее. Если функция gc.collect() не вызывается вручную, сборщик запускается немного реже. В момент срабатывания он очищает молодое поколение и определенный порционный инкремент старшего, вместо полной зачистки одного или нескольких поколений целиком.

Этот пулл-реквест попал в основную ветку Python еще в 2024 году, однако из релизного дистрибутива 3.13 его в последний момент исключили; в итоге первой версией с инкрементальным сборщиком стал Python 3.14.0.

Однако пользователи начали массово жаловаться на нагрузку на память, из-за чего в выпуске 3.14.5 разработчики приняли решение полностью откатить нововведения. Термин «нагрузка на память» и ее причины мы тоже детально разберем.

К сожалению, альтернативный механизм сборки мусора в некотором роде сознательно (если не сказать слишком резко) не сделали переключаемой опцией – в отличие, скажем, от Java или Go, где менять алгоритмы сборки можно буквально флагом запуска. В итоге разработчики, которым инкрементальный сборщик пришелся по душе (а такие действительно есть), попросту лишились возможности им пользоваться. Любопытно и то, что изначально столь масштабная переработка сборщика даже не проходила через стандартную процедуру PEP.

Чтобы понять суть проблемы, придется заглянуть глубже и начать с базиса управления памятью в Python – подсчета ссылок.

В этой статье под «Python» я подразумеваю стандартную реализацию – CPython.

❯ Python

Прежде чем разбираться со ссылками, соберем локально две версии интерпретатора: 3.14.4 и 3.14.5. Поскольку настраивать поведение мусоросборщика в рамках одного релиза нельзя, для наглядности экспериментов мы будем сравнивать именно эти микрорелизы: в 3.14.4 работает инкрементальный GC, а в 3.14.5 – классический иерархический.

sudo apt-get update -y
sudo apt-get install -y build-essential pkg-config
git clone --depth 1 --branch v3.14.4 https://github.com/python/cpython cpython3.14.4
git clone --depth 1 --branch v3.14.5 https://github.com/python/cpython cpython3.14.5
(cd cpython3.14.4 && ./configure --with-trace-refs && make -j16)
(cd cpython3.14.5 && ./configure --with-trace-refs && make -j16)

Флаг --with-trace-refs активирует отладочный механизм трассировки ссылок, который пригодится нам чуть позже.

Итак, обе сборки готовы:

$ ./cpython3.14.4/python --version
Python 3.14.4

$ ./cpython3.14.5/python --version
Python 3.14.5

Перейдем к управлению памятью.

❯ Ликбез по подсчету ссылок

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

Количество ссылок можно проверить с помощью функции sys.getrefcount.

~~~ refcount1.py ~~~

import sys
print(sys.getrefcount([])) # 1

Запустим:

$ ./cpython3.14.4/python refcount1.py
1

$ ./cpython3.14.5/python refcount1.py
1

Здесь мы создаем анонимный список, на который ссылается ровно один аргумент функции sys.getrefcount(). Как только функция завершает работу, счетчик обнуляется и объект тут же уничтожается в памяти.

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

~~~ refcount2.py ~~~

import sys
a = []
# refcount для этого объекта равен 1
print(sys.getrefcount(a)) # 2: `a` плюс временная ссылка в `sys.getrefcount`

Запускаем:

$ ./cpython3.14.4/python refcount2.py
2

$ ./cpython3.14.5/python refcount2.py
2

Если сразу несколько переменных указывают на один и тот же объект, количество ссылок растет. Вызвав id(obj) (в CPython это реальный адрес объекта в оперативной памяти) для разных переменных, мы увидим одинаковые значения. Подсчет ссылок в Python привязан именно к объекту в памяти, а не к именам переменных.

~~~ refcount3.py ~~~

import sys

a = []
print("a memory", hex(id(a))) # 0x1026e1680 в моем запуске
# У объекта 0x1026e1680 счетчик равен 1: переменная `a`
print(sys.getrefcount(a)) # 2: сама `a` плюс временный аргумент sys.getrefcount

b = a
print("b memory", hex(id(b))) # тот же адрес, что и у `a`: 0x1026e1680
# У объекта 0x1026e1680 счетчик равен 2: `a` и `b`
print(sys.getrefcount(a)) # 3: `a`, `b` и аргумент функции
print(sys.getrefcount(b)) # 3: `b`, `a` и аргумент функции

del b

# У объекта 0x1026e1680 счетчик снова равен 1
print(sys.getrefcount(a)) # 2: `a` и аргумент функции

del a

# Счетчик у 0x1026e1680 равен 0 – объект уничтожен

Запустим:

$ ./cpython3.14.4/python refcount3.py
a memory 0xfed730bdda40
2
b memory 0xfed730bdda40
3
3
2

$ ./cpython3.14.5/python refcount3.py
a memory 0xf1331f9dda40
2
b memory 0xf1331f9dda40
3
3
2

Момент освобождения памяти для встроенных типов (списков, словарей и т. д.) отследить напрямую нельзя, однако для пользовательских объектов это легко сделать – либо через метод del, либо зарегистрировав колбэк с помощью weakref.finalize.

~~~ refcount4.py ~~~

import sys, weakref

class Obj: pass
a = Obj()

weakref.finalize(a, print, "freeing "+hex(id(a)))

print("a memory", hex(id(a))) # 0x1005b4d70 в моем запуске
# У объекта 0x1005b4d70 счетчик равен 1: переменная `a`
print(sys.getrefcount(a)) # 2: `a` и аргумент функции

b = a
print("b memory", hex(id(b))) # адрес совпадает с `a`: 0x1005b4d70
# У объекта 0x1005b4d70 счетчик равен 2: `a` и `b`
print(sys.getrefcount(a)) # 3: `a`, `b` и аргумент
print(sys.getrefcount(b)) # 3: `b`, `a` и аргумент

del b

# У объекта 0x1005b4d70 счетчик равен 1
print(sys.getrefcount(a)) # 2: `a` и аргумент

del a

# Счетчик объекта равен 0, он уничтожается. На экране появится "freeing 0x1005b4d70"

Запустим:

$ ./cpython3.14.4/python refcount4.py
a memory 0xe496a9bc5160
2
b memory 0xe496a9bc5160
3
3
2
freeing 0xe496a9bc5160

$ ./cpython3.14.5/python refcount4.py
a memory 0xfdfcf15b1160
2
b memory 0xfdfcf15b1160
3
3
2
freeing 0xfdfcf15b1160

Но идиллия рушится, как только в дело вступают циклические ссылки.

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

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

Продемонстрируем это с помощью функции sys.getobjects(limit), которая возвращает список всех выделенных объектов (эта функция доступна только в отладочных сборках с --with-trace-refs). Наш объект останется в списке даже после вызова del, так как замкнутая ссылка удерживает значение счетчика выше нуля.

~~~ refcount5.py ~~~

import sys

class Obj: pass

a = Obj()  # 1 ссылка на Obj()
i = id(a)
a.me = a  # 2 ссылки на Obj()

assert any(id(o) == i for o in sys.getobjects(0))

del a  # осталась 1 ссылка на Obj()

assert any(id(o) == i for o in sys.getobjects(0))

Запустим:

$ ./cpython3.14.4/python refcount5.py
$ ./cpython3.14.5/python refcount5.py

Новички часто путают оператор del с принудительным уничтожением объекта в памяти (казалось бы: какая разница, ссылается ли он сам на себя, если мы его явно удаляем?). Но del лишь убирает имя a из текущей области видимости и уменьшает счетчик ссылок целевого объекта на единицу. Сам объект благополучно остается в памяти, поскольку продолжает ссылаться на самого себя, однако все внешние связки с ним утрачены. Налицо классическая утечка памяти! Ну, или она была бы таковой…

Один из способов вручную разорвать цикл ссылок – использование хрупких ссылок (weak references).

~~~ refcount6.py ~~~

import sys, weakref

class Obj: pass

a = Obj() # 1 ссылка на Obj()
i = id(a)
a.me = weakref.ref(a) # по-прежнему 1 ссылка на Obj()

assert any(id(o) == i for o in sys.getobjects(0))

del a # 0 ссылок на Obj()

assert not any(id(o) == i for o in sys.getobjects(0))

Запустим:

$ ./cpython3.14.4/python refcount6.py
$ ./cpython3.14.5/python refcount6.py

Теперь атрибут a.me содержит хрупкую ссылку. Финальное утверждение assert успешно проходит: объект a был благополучно удален из памяти стандартным механизмом подсчета ссылок.

По какой-то причине этот скрипт периодически (но воспроизводимо) сваливается в падение по сегментации (segfault) как в 3.14.4, так и в 3.14.5:

#0  free_object (obj=0x7dbdbb11bbf2) at Objects/object.c:921
#1  clear_freelist (dofree=<optimized out>, is_finalization=<optimized out>, freelist=<optimized out>) at Objects/object.c:907
#2  _PyObject_ClearFreeLists (freelists=0x592f73db8e30 <_PyRuntime+101872>, is_finalization=is_finalization@entry=0) at Objects/object.c:952
#3  0x0000592f73a898e2 in _PyGC_ClearAllFreeLists (interp=<optimized out>) at Python/gc_gil.c:14
#4  0x0000592f73a88791 in gc_collect_main (tstate=tstate@entry=0x592f73ded168 <_PyRuntime+315688>, generation=<optimized out>, generation@entry=2,
    reason=reason@entry=_Py_GC_REASON_MANUAL) at Python/gc.c:1495
#5  0x0000592f73a88e60 in PyGC_Collect () at Python/gc.c:1682
#6  0x0000592f73ac2d29 in _Py_Finalize (runtime=0x592f73da0040 <_PyRuntime>) at Python/pylifecycle.c:2140
#7  0x0000592f73ac30cd in _Py_Finalize (runtime=0x592f73da0040 <_PyRuntime>) at Python/pylifecycle.c:2268
#8  0x0000592f73afbc1d in Py_RunMain () at Modules/main.c:778
#9  pymain_main (args=0x7ffdbcb39900) at Modules/main.c:806
#10 Py_BytesMain (argc=<optimized out>, argv=<optimized out>) at Modules/main.c:830
#11 0x00007dbdbb22a1ca in __libc_start_call_main (main=main@entry=0x592f73870320 <main>, argc=argc@entry=2, argv=argv@entry=0x7ffdbcb39a98)
    at ../sysdeps/nptl/libc_start_call_main.h:58
#12 0x00007dbdbb22a28b in __libc_start_main_impl (main=0x592f73870320 <main>, argc=2, argv=0x7ffdbcb39a98, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>,
    stack_end=0x7ffdbcb39a88) at ../csu/libc-start.c:360
#13 0x0000592f738827d5 in _start ()

Впрочем, мы пока закроем на это глаза.

Иерархический сборщик мусора

Для уборки заброшенных зацикленных объектов, которые не удалось освободить подсчетом ссылок и на которые больше не ссылаются живые переменные, Python подключает страховочный механизм – иерархический сборщик мусора.

Модуль gc позволяет регистрировать функции обратного вызова, срабатывающие при старте и завершении прохода сборщика. Мы будем выставлять глобальный флаг на время работы GC, а в финализаторе weakref.finalize проверять этот флаг. Если удаление произошло не во время сборки, значит, сработал обычный подсчет ссылок.

~~~ gc1.py ~~~

import gc, weakref

_in_gc = False
def _track(phase, info):
    global _in_gc
    _in_gc = phase == "start"
gc.callbacks.append(_track)

def watch(obj, label):
    weakref.finalize(obj, lambda: print(
        label, "freed by", "GC" if _in_gc else "refcount"))


class Obj(): pass
o = Obj()

watch(o, "o")
del o # o freed by refcount

p = Obj()
p.me = p
watch(p, "p")
del p
gc.collect() # p freed by GC

Запустим:

$ ./cpython3.14.4/python gc1.py
o freed by refcount
p freed by GC

$ ./cpython3.14.5/python gc1.py
o freed by refcount
p freed by GC

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

Объекты могут удаляться как сборщиком мусора, так и механизмом подсчета ссылок. В предыдущих примерах мы отслеживали финализацию при обнулении счетчика. Но как убедиться, что память высвободил именно подсчет ссылок, а не внезапно сработавший GC?

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

~~~ refcount7.py ~~~

import gc, sys, weakref

gc.disable()

class Obj: pass
a = Obj()

weakref.finalize(a, print, "freeing "+hex(id(a)))

print("a memory", hex(id(a))) # 0x1005b4d70 в моем запуске
# У объекта 0x1005b4d70 счетчик равен 1: `a`
print(sys.getrefcount(a)) # 2: `a` и аргумент функции

b = a
print("b memory", hex(id(b))) # тот же адрес: 0x1005b4d70
# У объекта 0x1005b4d70 счетчик равен 2: `a` и `b`
print(sys.getrefcount(a)) # 3: `a`, `b` и аргумент
print(sys.getrefcount(b)) # 3: `b`, `a` и аргумент

del b

# У объекта 0x1005b4d70 счетчик равен 1
print(sys.getrefcount(a)) # 2: `a` и аргумент

del a

# Счетчик обнулился, объект удален – выводится "freeing 0x1005b4d70"

Запустим:

$ ./cpython3.14.4/python refcount7.py
a memory 0xf6e64bf71160
2
b memory 0xf6e64bf71160
3
3
2
freeing 0xf6e64bf71160

$ ./cpython3.14.5/python refcount7.py
a memory 0xec231a391160
2
b memory 0xec231a391160
3
3
2
freeing 0xec231a391160

❯ Задержки и паузы

Поскольку сборщик мусора запускается периодически, а выполнением кода в CPython занимается единственный поток, на время работы GC программа фактически замирает. Минимизация таких задержек (пауз типа stop-the-world) – одна из ключевых задач любого GC. В Python для этого объекты разделяют по поколениям. Из документации Python:

Сборщик мусора распределяет объекты по трем поколениям в зависимости от того, сколько проверок они пережили. Свежесозданные объекты попадают в самое молодое поколение (поколение 0). Если объект переживает проход сборщика, он переводится в следующее, более старшее поколение. Поколение 2 – самое старшее, и попавшие туда объекты остаются там навсегда. Чтобы определить момент запуска, сборщик отслеживает баланс между количеством выделений и освобождений памяти с момента предыдущей проверки.

Через каждые L выделений памяти сборщик анализирует объекты 0-го поколения. Каждые M проверок 0-го поколения запускают сборку 1-го поколения, и каждые N проверок 1-го поколения провоцируют полную зачистку 2-го поколения. Пороговые значения L, M и N задаются методом gc.set_threshold(L, M, N).

$ ./cpython3.14.4/python -c 'import gc; print(gc.get_threshold())'
(2000, 10, 0)

$ ./cpython3.14.5/python -c 'import gc; print(gc.get_threshold())'
(2000, 10, 10)

Суть реформы в Python 3.14 заключалась в уменьшении объема работы за каждый отдельный запуск сборщика. Поколения 1 и 2 объединили в единое «старшее» поколение (именно поэтому N=0 в Python 3.14.4). Из внутренней документации Python 3.14.0 (до отката изменений):

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

Главная цель авторов состояла в том, чтобы сгладить пиковые задержки (длинный хвост латентности) при работе сборщика.

Мы можем легко проверить это поведение на практике. Для этого создадим 1) большой объем (потенциального) мусора, который 2) гарантированно попадет в поле зрения GC (то есть счетчики ссылок у этих объектов не обнулятся автоматически).

~~~ a_best_case_incremental_gc.py ~~~

import gc, resource, statistics, time

t = 0
ts = []
def _track(phase, info):
    global t
    if phase == "start":
        t = time.perf_counter_ns()
    else:
        d = time.perf_counter_ns() - t
        ts.append(d)
gc.callbacks.append(_track)

objs = []
for i in range(50_000_000):
    objs.append([])

p = statistics.quantiles(ts, n=100)

rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(
    f"gc runs: {len(ts):,}\n"
    f"max: {max(ts)/1e6:,.3f} ms\n"
    f"avg: {statistics.mean(ts)/1e3:,.1f} µs\n"
    f"stddev: {statistics.stdev(ts)/1e6:,.3f} ms\n"
    f"p99: {p[98]/1e6:,.3f} ms\n"
    f"max rss: {rss/1024:,.1f} MB"
)

Запустим:

$ ./cpython3.14.4/python a_best_case_incremental_gc.py
gc runs: 24,987
max: 1,394.344 ms
avg: 264.4 µs
stddev: 13.934 ms
p99: 0.019 ms
max rss: 6,774.1 MB

$ ./cpython3.14.5/python a_best_case_incremental_gc.py
gc runs: 24,987
max: 3,922.962 ms
avg: 896.1 µs
stddev: 41.094 ms
p99: 1.079 ms
max rss: 6,773.9 MB

В этом идеальном для инкрементального GC сценарии расход памяти оказался одинаковым. При этом средняя, максимальная и 99-я перцентиль задержек (p99) у инкрементального сборщика оказались в разы ниже.

Однако из-за порционной обработки на окончательное освобождение памяти требуется больше времени. Если долгоживущее приложение непрерывно генерирует «старший» мусор, процесс рискует постепенно разрастись до гигантских размеров и в итоге быть застреленным OOM-киллером в Linux. С прежним сборщиком из Python 3.13 подобное поведение было практически исключено.

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

~~~ a_worst_case_incremental_gc.py ~~~

import gc, resource, statistics, time

t = 0
ts = []
def _track(phase, info):
    global t
    if phase == "start":
        t = time.perf_counter_ns()
    else:
        d = time.perf_counter_ns() - t
        ts.append(d)
gc.callbacks.append(_track)

class Obj: pass

objs = []
for i in range(10_000_000):
    o = Obj()
    o.me = o
    objs.append(o)
    if i % 1_000 == 0:
      objs = []

p = statistics.quantiles(ts, n=100)

rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(
    f"gc runs: {len(ts):,}\n"
    f"max: {max(ts)/1e6:,.3f} ms\n"
    f"avg: {statistics.mean(ts)/1e3:,.1f} µs\n"
    f"stddev: {statistics.stdev(ts)/1e6:,.3f} ms\n"
    f"p99: {p[98]/1e6:,.3f} ms\n"
    f"max rss: {rss/1024:,.1f} MB"
)

Запустим:

$ ./cpython3.14.4/python a_worst_case_incremental_gc.py
gc runs: 25,000
max: 11.793 ms
avg: 900.7 µs
stddev: 1.587 ms
p99: 7.186 ms
max rss: 27.1 MB

$ ./cpython3.14.5/python a_worst_case_incremental_gc.py
gc runs: 24,988
max: 12.339 ms
avg: 785.1 µs
stddev: 0.958 ms
p99: 5.188 ms
max rss: 20.8 MB

И вот здесь проблема проявляется во всей красе. Хотя максимальная пауза у инкрементального сборщика все еще остается чуть меньше, по всем остальным метрикам он проиграл. Самое главное: инкрементальный GC затребовал ощутимо больше оперативной памяти, чем классический.

И это не статистическая погрешность: при повторных запусках картина остается стабильно одинаковой.

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

~~~ a_worst_case_incremental_gc_take2.py ~~~

import gc, resource, statistics, time

t = 0
ts = []
def _track(phase, info):
    global t
    if phase == "start":
        t = time.perf_counter_ns()
    else:
        d = time.perf_counter_ns() - t
        ts.append(d)
gc.callbacks.append(_track)

class Obj: pass

objs = []
for i in range(10_000_000):
    o = Obj()
    o.me = o
    o.payload = list(range(500))
    objs.append(o)
    if i % 1_000 == 0:
      objs = []

p = statistics.quantiles(ts, n=100)

rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(
    f"gc runs: {len(ts):,}\n"
    f"max: {max(ts)/1e6:,.3f} ms\n"
    f"avg: {statistics.mean(ts)/1e3:,.1f} µs\n"
    f"stddev: {statistics.stdev(ts)/1e6:,.3f} ms\n"
    f"p99: {p[98]/1e6:,.3f} ms\n"
    f"max rss: {rss/1024:,.1f} MB"
)

Каждому экземпляру от класса Obj мы добавляем полезную нагрузку – список из 500 элементов. Это единственное изменение.

$ diff a_worst_case_incremental_gc.py a_worst_case_incremental_gc_take2.py
19a20
>     o.payload = list(range(500))

Запустим. (Это может занять некоторое время.)

$ ./cpython3.14.4/python a_worst_case_incremental_gc_take2.py
gc runs: 9,999
max: 520.756 ms
avg: 46,756.5 µs
stddev: 75.110 ms
p99: 317.631 ms
max rss: 2,849.4 MB

$ ./cpython3.14.5/python a_worst_case_incremental_gc_take2.py
gc runs: 9,995
max: 572.420 ms
avg: 37,215.0 µs
stddev: 59.688 ms
p99: 320.846 ms
max rss: 717.0 MB

Вот мы и пришли к сценарию, в котором новый инкрементальный сборщик сжирает почти в четыре раза больше памяти (2,8 ГБ против 717 МБ!), чем традиционный.

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

Может быть интересно:
Перейти ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале 

Комментарии (1)


  1. viktoriaro
    04.08.2026 08:49

    Сначала внедрили новый сборщик, который должен был всё улучшить, а потом выяснилось, что в реальных сценариях он может заметно увеличивать потребление памяти. Возникает вопрос: насколько тщательно вообще тестировали такие изменения до выпуска? Похоже, пользователям снова пришлось выступать в роли бета-тестеров.