
Не так давно вышел 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‑канале ↩
Комментарии (7)

zzzzzzerg
04.08.2026 08:49Всем привет, я немного затронул эту движуху - у кого есть вопросы задавайте.
Кому интересно (простите за рекламу) в одной из статей своей серии о сборке мусора в питоне я разбирал как устроен инкрементальный сборщик мусора, также можно посмотреть доклад с UfaDevConf, в котором я рассказывал как инкрементальный сборщик может оказать влияние на, первый взгляд, безобидную программу.
Однако из-за порционной обработки на окончательное освобождение памяти требуется больше времени.
Вот тут сказано самое главное, но очень вскольз.
Инкрементальный сборщик мусора привнес две "новые" для CPython концепции - фазу MarkAlive и инкремент сборки мусора.
И обе концепции в реальной жизни могут неприятно выстрелить. MarkAlive может привести к квадратичное сложности (видео по ссылке выше как раз об этом) если у вас довольно большой набор локальных переменных в текущем фрейме.
Формирование инкремента довольно запутанная вещь и, главное, не контролируемая извне. В итоге GC call может произойти, но инкремент не будет создан и никакая работа по сборке мусора выполнена не будет. Это как раз ситуация из первой ссылки в посте - Observed memory leak in ssl library: Python 3.14 GC issue · Issue #142516 · python/cpython .
Из за того, что реальная работа по сборке мусора откладывается на неопределенное время, циклы, которые существуют в программе (в частности в httpx в Response) живут дольше, чем в поколенческом GC. А у Response довольно большой payload, там помимо SSL контекста довольно много всего. В итоге память не успевает освобождаться и нижележащий аллокатор выделяет новую память. В зависимости от размера payload (в статье есть эксперименты, на гитхабе тоже, на DPO можно увидеть Cyclotron, который сделал Нил для этих целей) аллокация может стабилизироваться на различной полке (1Гб, 8Гб, 20Гб и тд.), что очевидно не приемлемо для многих сервисов.
Поддерживать две версии накануне бета релиза тоже не решились, т.к. код GC довольно сложная вещь и это потребовало бы больше усилий по сопровождению. Ну и 3.14.5 совершил очень редкую вещь, когда в patchlevel релизе отменили сложную функциональность, которая затронула много всего. Таких прецедентов в 3.* всего два наверное.

akardapolov
04.08.2026 08:49А регресс не показал падение производительности при внедрении новых функций в GC? Вот это удивляет - как в прод пролезли эти правки не проверенные. Похоже такой инфраструктуры (тестов производительности) просто нет - очень странно для ЯП № 1 во всех рейтингах.

zzzzzzerg
04.08.2026 08:49У нас есть python/pyperformance, но он в целом не позволяет отслеживать изменения в GC в силу исторических причин. У нас есть
Py_STATS, но такие сборки редко собираются и там смотрят на другое.При внедрении Incremental GC был нарушен процесс (я не знаю причин) и его добавили в обход PEP. PEP наверное позволил бы на ранних этапах найти больше проблем, чем после внедрения. Работы по подготовке PEPa ведутся.
Сейчас в 3.15+ мы добавили новое API для мониторинга событий GC (я буквально неделю назад на PyCon Russia рассказывал, можно посмотреть у меня в гитхабе слайды), будем на основе этого строить какие-то бенчмарки.
Проблема в том, что это волонтерская работа, которая идет в свободное время, которого не так много, к сожалению.

AlexProvatorov
04.08.2026 08:49Спасибо за глубокий разбор с кодом! Результаты бенчмарков в worst-case сценарии действительно впечатляют.
Но возникает философский вопрос: да, инкрементальный сборщик проигрывает по объему памяти в разы, но в абсолютных цифрах (даже на ваших утяжеленных тестах) речь идет о разнице в пару гигабайт. При этом по latency (особенно p99) инкрементальный алгоритм показывает отличные результаты, минимизируя stop-the-world паузы. Для многих realtime-сервисов (например, в финтехе или геймдеве) плавность работы и предсказуемые задержки критичнее, чем лишние гигабайты оперативной памяти на сервере, которой обычно с запасом.
Не кажется ли вам, что лишение разработчиков выбора (полный откат вместо внедрения флага запуска, как в Go или Java) — это слишком радикальный шаг со стороны core-разработчиков Python? Почему, по вашему мнению, они так упорно отказываются поддерживать два алгоритма параллельно, пусть даже инкрементальный шел бы с пометкой "experimental"?

Merced
04.08.2026 08:49Тем кому он нужен могут использовать сборку с инкрементальным gc, а после пересесть на версию когда все проблемы устранят и вернут новый сборщик мусора обратно. Как по мне для разрабов языка это гораздо проще чем поддерживать 2 версии сборщика, учитывая что это изначально не планировалось.
viktoriaro
Сначала внедрили новый сборщик, который должен был всё улучшить, а потом выяснилось, что в реальных сценариях он может заметно увеличивать потребление памяти. Возникает вопрос: насколько тщательно вообще тестировали такие изменения до выпуска? Похоже, пользователям снова пришлось выступать в роли бета-тестеров.