
Не так давно вышел 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‑канале ↩
viktoriaro
Сначала внедрили новый сборщик, который должен был всё улучшить, а потом выяснилось, что в реальных сценариях он может заметно увеличивать потребление памяти. Возникает вопрос: насколько тщательно вообще тестировали такие изменения до выпуска? Похоже, пользователям снова пришлось выступать в роли бета-тестеров.