Управление памятью в CPython: три цифры, которые все повторяют неправильно
Пороги сборщика мусора, размер арены и стоимость объекта — всё это давно не то, что написано в статьях. Каждое число здесь снято с работающего интерпретатора и сверено с исходником на закреплённом теге.
Полное техническое изложение
TL;DR
- Python убирает мусор двумя способами. Первый работает всё время и мгновенно, второй просыпается изредка и нужен только для одного особого случая.
- Первый способ — счёт: у каждого объекта записано, сколько на него ссылаются. Дошло до нуля — объект стирается сразу же.
- Второй нужен, когда объекты держат друг друга: счёт у них не нулевой, а пользы никакой. Вот их и ищет отдельный уборщик.
- Числа, которые про всё это обычно пишут, устарели. Проверять их — дело трёх строк, и в статье показано каких.
Библиотека с записью на обложке
Представьте библиотеку, где на обложке каждой книги написано, сколько человек её сейчас держат.
Взяли книгу — прибавили к числу единицу. Вернули — отняли. Как только на обложке появился ноль, книгу немедленно увозят в макулатуру. Не вечером, не по расписанию — прямо в этот момент.
Так и работает Python. Объект — книга, число на обложке — счётчик ссылок.
a = SomeThing() # на обложке 1: держит имя a
b = a # на обложке 2: то же самое, но держат двое
del b # снова 1
del a # 0 — объект стёрт немедленноЗдесь важно одно слово: немедленно. Не «когда-нибудь потом», не «когда дойдут руки». В ту же миллисекунду.
- a = Thing("payload")
- b = a
- box = [a]
- del b
- box.clear()
- del a
объект создан, на него смотрит одно имя
ob_refcnt
1
кто держит
- a
измерение мешает измеряемому
Те же данные, снятые двумя способами на одном объекте: sys.getrefcount(x) - 1 на месте дал 1, а через функцию-помощник — 2. Параметр функции тоже ссылка. Поэтому в статье выражение везде записано на месте, а не вынесено в удобный rc().
Кстати, о числах на этой картинке. Если вы попробуете посмотреть счётчик сами, через sys.getrefcount, ответ будет на единицу больше ожидаемого. Причина забавная: чтобы спросить у объекта его счётчик, надо передать объект в функцию, а передача — это тоже ссылка. Измерительный прибор сам добавляет к тому, что измеряет.
Две книги с закладками друг на друга
Теперь случай, где счёт ломается.
Возьмите две книги и вложите в каждую закладку со ссылкой на другую. Читатели ушли, обе книги никому не нужны — но на обложках стоят единицы. Каждая книга держит другую.
По правилам библиотеки они живые. По факту их уже никогда никто не откроет.
left = Node('left')
right = Node('right')два обычных объекта, по одной ссылке на каждый
Освобождено: —
__del__: после del обоих имён не освободился никто, а gc.collect() вернул 2 и вызвал оба деструктора. Счётчик ссылок здесь не ошибается — он отвечает на свой вопрос правильно. Просто его вопрос («есть ли на объект хоть одна ссылка») не совпадает с нужным («достижим ли объект из работающей программы»).Посмотрите на третий шаг: после того как убрали оба имени, счётчики не обнулились, а стали равны единице. Это и есть вся проблема. Счётчик не ошибается — он честно отвечает на вопрос «есть ли на объект хоть одна ссылка». Просто нужный вопрос другой: «можно ли до объекта вообще добраться из программы».
Для этого и существует второй механизм — уборщик, который время от времени обходит объекты и ищет такие замкнутые группы. В Python он называется сборщиком мусора, и — вопреки распространённому мнению — занимается только этим. Всё остальное убирает счётчик.
Как часто просыпается уборщик
Уборщик не работает постоянно, иначе программа бы только этим и занималась. Он ждёт, пока накопится достаточно новых объектов, и тогда делает проход.
Сколько именно — можно спросить:
import gc
gc.get_threshold()И вот тут начинается интересное. Почти в каждой статье написано, что ответ будет (700, 10, 10). Проверка на разных версиях:
| Версия | Ответ |
|---|---|
| 3.11 и 3.12 | (700, 10, 10) |
| 3.13 | (2000, 10, 10) |
| 3.14.0 (rc2) | (2000, 10, 0) |
| 3.14.5 | (2000, 10, 10) |
Число поменялось в версии 3.13 и с тех пор успело поменяться ещё дважды. А самое любопытное — в официальной документации эти значения не написаны вообще: страница про сборщик объясняет, что значат три порога, но ни одного числа не приводит. Проверено для 3.13 и 3.14.
Мораль простая и полезная: про память лучше не вспоминать, а спрашивать. Одна строка кода надёжнее любой статьи, включая эту.
Почему память не всегда возвращается
Ещё одна вещь, которая пугает при первой встрече.
Программа создала миллион мелких объектов, потом удалила их все. Счётчики обнулились, объекты стёрты — а память, которую занимает процесс, осталась прежней. Утечка?
Нет. Python берёт память у операционной системы большими кусками — по одному мегабайту. Внутри такого куска он сам раздаёт места под объекты, и это быстро. Но вернуть кусок системе можно только целиком, когда он опустел полностью.
Арена — непрерывный кусок адресного пространства, который интерпретатор берёт у операционной системы целиком. На 64-битной сборке её размер 1 048 576 байт, то есть 1 МиБ, и она нарезается ровно на 64 пулов.
64 пула по 16 384 байт. Заполненность на схеме — иллюстративная раскладка; точные числа занятости смотрите на уровне «Блок», они измерены.
sys._debugmallocstats() на CPython 3.13.13 в конце прогона измерительного скрипта. Размеры и число классов — константы сборки, занятость — снимок этого процесса. Константы сверены с Include/internal/pycore_obmalloc.h на теге v3.14.5: ARENA_BITS 20, POOL_BITS 14, ALIGNMENT 16, SMALL_REQUEST_THRESHOLD 512. Если вы помните «256 КБ и 4 КБ» — это верно для 32-битной сборки, где те же константы равны 18 и 12.Достаточно, чтобы в мегабайтном куске остался жить один объект — и весь кусок останется за процессом. После пика нагрузки таких «почти пустых, но не совсем» кусков может набраться много.
Это не поломка и не утечка, это обычное поведение любого подобного распределителя памяти. И gc.collect() тут не поможет: он ищет замкнутые группы, а не уплотняет кучу.
Что стоит запомнить
Уборщик мусора в Python — не главный герой, а специалист по одному редкому случаю. Основную работу делает счёт ссылок, и делает мгновенно.
А ещё: почти любое число про память, которое вы прочитали, стоит перепроверить на своей версии. Не из недоверия к авторам — просто эти числа меняются тише, чем о них пишут.
TL;DR
- Память в CPython освобождают два механизма, а не один: счётчик ссылок делает это немедленно, циклический сборщик — потом и только для того, до чего счётчик не дотягивается.
- Пороги сборщика
(700, 10, 10), которые цитируют повсеместно, устарели в 3.13. Сейчас(2000, 10, 10), и по дороге они успели побывать(2000, 10, 0). - Арены по 256 КБ, пулы по 4 КБ и 64 класса размеров — описание 32-битной сборки. На 64-битной это 1 МиБ, 16 КиБ и 32 класса, потому что выравнивание там 16 байт, а не 8.
- В сборке без GIL заголовок объекта вырастает с 16 до 32 байт, а pymalloc заменён на mimalloc. Цена видна в
sys.getsizeof— но не у всех объектов, и причина этого сама по себе поучительна.
Зачем это знать?
Потому что почти всё, что написано про память в Python, описывает интерпретатор, которого у вас уже нет.
Это не преувеличение. Ниже — три числа, которые встречаются в каждой второй статье, и то, что на самом деле вернул интерпретатор при проверке. Все три разошлись с общепринятым, и разошлись недавно: два из них поменялись в 3.13, третье зависит от разрядности сборки уже давно, но продолжает жить в пересказах в старом виде.
Практическая польза не в самих числах, а в привычке. Память — та область, где «я читал, что» устаревает тише всего: gc.get_threshold() возвращает кортеж без предупреждений о том, что позавчера он был другим, а sys.getsizeof не сообщает, что в соседней сборке ответ отличается вдвое.
Модель в голове
Представьте библиотеку, где у каждой книги на обложке написано, сколько читателей её сейчас держат. Читатель взял — прибавил единицу, вернул — отнял. Как только счётчик обнулился, книга немедленно едет в макулатуру: не в конце дня, не по расписанию, а прямо в этот момент.
Схема работает безупречно ровно до одного случая. Две книги, в каждой закладка со ссылкой на другую. Оба читателя ушли, но счётчики не нулевые: книги держат друг друга. По правилам библиотеки они живые, по факту их никто никогда не откроет. Вот для этой — и только для этой — ситуации и существует второй механизм, который раз в какое-то время обходит полки и ищет замкнутые группы, недостижимые снаружи.
Всё остальное в этой статье — детали двух этих механизмов и цена, которую за них платят.
Счётчик ссылок: главный, а не запасной
Начнём с того, что обычно проговаривают вскользь: основную работу делает не сборщик мусора. Подавляющее большинство объектов в Python освобождается счётчиком ссылок, немедленно, и сборщик о них никогда не узнаёт.
Каждый объект начинается с поля ob_refcnt. Макросы Py_INCREF и Py_DECREF его увеличивают и уменьшают, и когда Py_DECREF доводит счётчик до нуля, освобождение происходит прямо там, в этом вызове.
- a = Thing("payload")
- b = a
- box = [a]
- del b
- box.clear()
- del a
объект создан, на него смотрит одно имя
ob_refcnt
1
кто держит
- a
измерение мешает измеряемому
Те же данные, снятые двумя способами на одном объекте: sys.getrefcount(x) - 1 на месте дал 1, а через функцию-помощник — 2. Параметр функции тоже ссылка. Поэтому в статье выражение везде записано на месте, а не вынесено в удобный rc().
Обратите внимание на нижнюю плашку рисунка. Она про то, что кажется мелочью, но объясняет половину недоумений вокруг getrefcount: сам замер добавляет ссылку. Аргумент, переданный в функцию, — это ссылка, поэтому sys.getrefcount(x) всегда на единицу больше «настоящего» значения. А если завести удобную обёртку def rc(obj): return sys.getrefcount(obj) - 1, помеха вырастет до двух: параметр obj — ещё одна ссылка. Измерено на 3.13.13: на месте выражение даёт 1, через обёртку — 2.
Вывод не «getrefcount врёт», а более общий: у этого инструмента есть цена наблюдения, и её надо вычитать сознательно.
Слабая ссылка — это ссылка, которая не считается
Иногда объект нужно помнить, но не удерживать: кэш, реестр наблюдателей, обратная ссылка с ребёнка на родителя. Для этого есть weakref — ссылка, которая не увеличивает ob_refcnt.
import sys, weakref
class T: pass
t = T()
sys.getrefcount(t) - 1 # 1
r = weakref.ref(t)
sys.getrefcount(t) - 1 # 1 — слабая ссылка не посчиталась
r() is t # True
del t
r() # None — объект освобождён, ссылка это знаетЧисла в комментариях — вывод этого кода на 3.13.13. Важна последняя строка: слабая ссылка не превращается в висячий указатель, она честно начинает возвращать None.
Цикл: единственное, чего счётчик не умеет
Теперь та самая пара книг с закладками друг на друга.
left = Node('left')
right = Node('right')два обычных объекта, по одной ссылке на каждый
Освобождено: —
__del__: после del обоих имён не освободился никто, а gc.collect() вернул 2 и вызвал оба деструктора. Счётчик ссылок здесь не ошибается — он отвечает на свой вопрос правильно. Просто его вопрос («есть ли на объект хоть одна ссылка») не совпадает с нужным («достижим ли объект из работающей программы»).Числа на рисунке стоит прочитать внимательно, потому что тут обычно и возникает путаница. После del left; del right счётчики не нулевые и не «непонятные» — они равны единице. Каждый объект держит другой, и с точки зрения счётчика это ничем не отличается от живой структуры данных.
Счётчик ссылок здесь не ошибается. Он правильно отвечает на свой вопрос — «есть ли на объект хоть одна ссылка». Просто нужный вопрос другой: «достижим ли объект из работающей программы». Разница между этими двумя вопросами и есть вся причина, по которой в CPython существует второй сборщик.
В замере gc.collect() вернул 2 и вызвал оба __del__. До этого не вызвался ни один.
Пороги: число, которое все помнят неправильно
Сборщик циклов не работает постоянно. Он просыпается, когда разница между числом созданных и числом уничтоженных отслеживаемых объектов превысила порог.
Что именно считают эти три числа
Прежде чем разбираться, какие числа правильные, стоит понять, что они настраивают: без этого (700, 10, 10) выглядит как три однородных значения, а это не так.
Поколений три, и это три списка. Каждый отслеживаемый объект лежит ровно в одном из них. Только что созданный попадает в нулевое. Тот, кто пережил сборку своего поколения, переезжает в следующее — и попадает под сборку реже.
Смысл деления — не в аккуратности, а в стоимости. Полный обход кучи стоит времени, пропорционального её размеру, а подавляющее большинство объектов умирает почти сразу после создания: временный список в цикле, распакованный кортеж, промежуточная строка. Обходить ради них всю кучу, включая структуры, живущие с запуска процесса, — платить за проверку того, что почти наверняка не изменилось. Поколения позволяют часто и дёшево смотреть туда, где смертность высокая, и редко — туда, где низкая.
Теперь главное, из-за чего три числа кажутся однородными. Они меряют разное:
| Порог | Что считает его счётчик | Когда счётчик растёт |
|---|---|---|
threshold0 | объекты | при каждом создании отслеживаемого объекта |
threshold1 | сборки поколения 0 | каждый раз, когда собрали нулевое поколение |
threshold2 | сборки поколения 1 | каждый раз, когда собрали первое |
То есть (2000, 10, 10) читается так: собрать нулевое поколение после 2000 новых объектов; каждую десятую такую сборку заодно собрать первое; каждую десятую сборку первого — заодно второе. Первое число — про объекты, два других — про сборки. Отсюда и порядок величин: 2000 против 10 — это не «один порог намного больше», это разные единицы измерения.
Двусмысленность зашита в сам исходник — в структуре поколения одно поле count обслуживает оба случая, и комментарий к нему честно перечисляет оба:
struct gc_generation {
PyGC_Head head;
int threshold; /* collection threshold */
int count; /* count of allocations or collections of younger
generations */
};Механику выбора видно в одном цикле — это gc_select_generation() из Python/gc.c на теге v3.13.0:
for (int i = NUM_GENERATIONS-1; i >= 0; i--) {
if (gcstate->generations[i].count > gcstate->generations[i].threshold) {
if (i == NUM_GENERATIONS - 1
&& gcstate->long_lived_pending < gcstate->long_lived_total / 4)
{
continue;
}
return i;
}
}Цикл идёт от старшего поколения к младшему и берёт первое, чей счётчик превысил порог. Собрав поколение i, сборщик обходит его вместе со всеми младшими, обнуляет их счётчики и добавляет единицу к счётчику следующего:
if (generation+1 < NUM_GENERATIONS) {
gcstate->generations[generation+1].count += 1;
}
for (i = 0; i <= generation; i++) {
gcstate->generations[i].count = 0;
}И тут же — условие, которого нет ни в документации, ни в пересказах. Строчка с long_lived_pending означает, что для самого старшего поколения превышения порога недостаточно. Полная сборка пропускается, если число объектов, переживших все неполные сборки и ещё ни разу не проверенных полной, меньше четверти от числа переживших последнюю полную. Проще: если с прошлого полного обхода долгоживущих почти не прибавилось, обходить всё заново незачем.
Практическое следствие важнее самого условия: threshold2 = 10 — необходимое условие полной сборки, но не достаточное. Программа, у которой куча долгоживущих объектов давно устоялась, может пройти сотни сборок первого поколения и не увидеть ни одной полной. Утверждение «полная сборка происходит каждые сто сборок нулевого поколения» арифметически стройное и неверное.
А теперь — сами числа
Порогов три, и почти в любой статье они приведены как (700, 10, 10). Проверка на пяти интерпретаторах:
| Сборка | gc.get_threshold() |
|---|---|
| 3.11.15 | (700, 10, 10) |
| 3.12.3 | (700, 10, 10) |
| 3.13.13 | (2000, 10, 10) |
| 3.14.0rc2 | (2000, 10, 0) |
| 3.14.5 | (2000, 10, 10) |
Это не особенность конкретных сборок. В Include/internal/pycore_runtime_init.h на теге v3.12.0 стоит { .threshold = 700, }; в том же файле на v3.13.0 — уже 2000.
Отдельно любопытно, что в документации gc дефолтные значения не описаны вообще — проверено для 3.13 и 3.14: страница объясняет смысл трёх порогов, но не приводит ни одного числа. Откуда именно 700 разошлось по статьям, утверждать не берусь. Достоверно другое: подтверждения в документации у этого числа нет, зато у gc.get_threshold() оно есть всегда — и отвечает за текущую сборку.
Проверить действующий порог можно за десять строк:
import gc
gc.collect()
first = gc.get_threshold()[0]
gen1 = gc.get_count()[1]
keep = []
for i in range(1, 4001):
keep.append([i]) # список отслеживается сборщиком
if gc.get_count()[1] != gen1: # поколение 0 собрали
print(first, i)
breakНа 3.13.13 печатается 2000 2000, на 3.12.3 — 700 700, и так пять запусков из пяти: сборка срабатывает ровно на объекте с номером, равным порогу.
Одна оговорка: на 3.14.0rc2 этот же код за 4000 объектов не печатает ничего. Там третий порог равен нулю, сборка идёт приращениями, и счётчик поколений ведёт себя иначе — что само по себе неплохая иллюстрация к следующему разделу.
История с откатом, которую стоит знать целиком
Третье число в строке 3.14.0 — ноль, и это не опечатка.
В 3.13.0a6 в CPython появился инкрементальный сборщик циклов. Формулировка из журнала изменений: реализован инкрементальный сборщик, старое поколение собирается порциями, полный обход кучи больше не нужен, число поколений уменьшено с трёх до двух. Смысл — убрать длинные паузы на больших кучах.
В релиз 3.13 он не попал, а в 3.14.0 попал. Тогда же третий порог перестал что-либо значить: документация 3.14 помечает threshold2 как игнорируемый, а gc.collect(1) — как «выполнить приращение сборки» вместо «собрать поколение 1». Отсюда и (2000, 10, 0) в замере на 3.14.0rc2.
А затем — редкий случай — изменение откатили в патч-релизе:
Python 3.14.0-3.14.4 shipped with a new incremental GC. However, due to a number of reports of significant memory pressure in production environments, it has been reverted back to the generational GC from 3.13.
Дословно: версии 3.14.0–3.14.4 вышли с новым инкрементальным сборщиком, но из-за ряда сообщений о существенном росте потребления памяти в production его вернули к поколенческому сборщику из 3.13. Запись в журнале изменений 3.14.5rc1 (gh-142516) уточняет, что откат сделан для сборки по умолчанию, а сборщик free-threaded сборки оставлен без изменений. Это не значит, что там остался инкрементальный: у сборки без GIL с 3.13.0a4 своя реализация сборщика, которая ищет объекты через mimalloc и, по формулировке того же журнала, не является поколенческой. Инкрементальный сборщик её не касался ни до, ни после отката.
Отсюда практический вывод для тех, кто держит большие кучи: строка «Python 3.14» в требованиях недостаточно точна. Поведение сборщика в 3.14.3 и 3.14.5 разное, и разница ровно в том, из-за чего откатывали.
Где лежат сами объекты: арены, пулы, блоки
Второй механизм — не сборка, а распределение. Просить у операционной системы память под каждый объект по отдельности слишком дорого: объекты в Python мелкие и создаются миллионами. Поэтому CPython берёт память крупными кусками и раздаёт её сам.
Уровня три: арена → пул → блок.
Класс размера: почему их 32 и зачем они вообще
Это место обычно проговаривают одной фразой — «объекты раскладываются по классам размеров», — и читатель остаётся с ощущением, что классы просто есть. Между тем именно они делают выделение дешёвым, и понять их стоит до всех чисел.
Класс размера — это множество запросов, которые получают блок одного и того же размера. Запрос округляется вверх до ближайшего кратного шестнадцати: и 1 байт, и 16 получают блок в 16 байт; и 17, и 32 — блок в 32; и так до 512. Отсюда 32 класса: 512 ÷ 16.
Шестнадцать — это ALIGNMENT 64-битной сборки, и число классов зависит от неё напрямую. На 32-битной выравнивание равно восьми, и классов там 64. Дальше в статье это разобрано подробнее; здесь важно только, что «32» — не свойство pymalloc вообще, а свойство сборки, на которой сняты все числа этой статьи.
В исходнике вычисление класса — одна строка арифметики, номер получается сдвигом:
uint size = (uint)(nbytes - 1) >> ALIGNMENT_SHIFT;Ключевое — правило, ради которого всё построено: один пул обслуживает ровно один класс. Все блоки внутри пула одинакового размера, а значит взаимозаменяемы. И это превращает выделение памяти в две операции над односвязным списком:
- выделить — снять первый блок со списка свободных этого пула;
- освободить — положить блок обратно в начало списка.
Ни поиска подходящей дырки, ни разрезания большого куска на меньший, ни склейки соседних свободных участков — всего того, чем занят обычный malloc общего назначения. Он вынужден этим заниматься именно потому, что у него запросы любого размера вперемешку. pymalloc платит за отказ от этой работы округлением: часть блока пропадает впустую, зато выделение стоит несколько инструкций. Сколько именно пропадает, интерпретатор считает сам — цифра будет ниже, в разборе вывода sys._debugmallocstats().
Поэтому ответ на «почему классы разные» — чтобы внутри одного пула всё было одинаковым. Разнообразие вынесено на уровень выше: пулов много, и каждый занят своим размером.
Как память заполняется и как освобождается
Заполнение идёт сверху вниз и только по необходимости:
- Программа просит 100 байт. Округление вверх даёт класс 112 байт.
- Аллокатор ищет пул этого класса, в котором есть свободный блок, и снимает блок со списка свободных.
- Если такого пула нет — берётся пустой пул (из уже имеющейся арены) и назначается классу 112. Пул не рождается со своим размером, он его получает при первом использовании.
- Если пустых пулов не осталось — у операционной системы запрашивается новая арена в 1 МиБ, и она нарезается на 64 пула.
Освобождение идёт теми же тремя уровнями, но каждый следующий уровень требует более сильного условия:
| Что освобождается | Условие | Куда возвращается |
|---|---|---|
| блок | объект удалён | в список свободных своего пула — мгновенно, без участия ОС |
| пул | освободились все его блоки | в запас пустых пулов арены, может быть переназначен другому классу |
| арена | освободились все её пулы | операционной системе |
Вот эта асимметрия и есть причина репутации «Python не отдаёт память». Вниз память идёт мгновенно и по одному блоку, вверх — только целыми уровнями и только при полной пустоте. Один живой объект удерживает свой пул; один непустой пул удерживает арену в мегабайт.
Все три правила проще увидеть, чем прочитать: первая вкладка показывает, что происходит с запросом до выдачи памяти, вторая — переиспользование блока внутри пула, третья — почему процесс не отдаёт мегабайт из-за одного живого объекта.
Запрос округляется вверх до кратного 16. Блок будет на 12 Б больше, чем просили — это плата за то, что все блоки в пуле одинаковы.
sys._debugmallocstats(), одинаковый на 3.11.15, 3.12.3 и 3.13.13. Порядок выдачи блоков и их количество на рисунке упрощены: в настоящем пуле на 64 Б помещается 255 блоков, а к моменту запуска вашего кода интерпретатор уже держит тысячи занятых.Арена — непрерывный кусок адресного пространства, который интерпретатор берёт у операционной системы целиком. На 64-битной сборке её размер 1 048 576 байт, то есть 1 МиБ, и она нарезается ровно на 64 пулов.
64 пула по 16 384 байт. Заполненность на схеме — иллюстративная раскладка; точные числа занятости смотрите на уровне «Блок», они измерены.
sys._debugmallocstats() на CPython 3.13.13 в конце прогона измерительного скрипта. Размеры и число классов — константы сборки, занятость — снимок этого процесса. Константы сверены с Include/internal/pycore_obmalloc.h на теге v3.14.5: ARENA_BITS 20, POOL_BITS 14, ALIGNMENT 16, SMALL_REQUEST_THRESHOLD 512. Если вы помните «256 КБ и 4 КБ» — это верно для 32-битной сборки, где те же константы равны 18 и 12.Здесь и живёт вторая устаревшая цифра. Классическое описание — «арены по 256 КБ, пулы по 4 КБ, выравнивание 8 байт, 64 класса размеров» — верно для 32-битной сборки. На 64-битной ARENA_BITS равен 20, а POOL_BITS — 14, то есть 1 МиБ и 16 КиБ; выравнивание 16 байт, классов размеров 32. Интерпретатор говорит это сам:
Small block threshold = 512, in 32 size classes
2 arenas * 1048576 bytes/arena = 2,097,152
23 unused pools * 16384 bytes = 376,832
Это вывод sys._debugmallocstats() на голом старте 3.13.13. Читать его надо по-разному: первая строка и размеры арены с пулом — константы сборки, они одинаковы от запуска к запуску. А вот число арен и пулов — состояние конкретного процесса в конкретный момент; на трёх подряд запусках получилось 2 арены и 22, 23, 23 пула. В статье дальше есть таблица занятости по классам размеров — к ней это замечание относится в полной мере.
Что до «64 класса размеров» из классических описаний — цифра верная и относится именно к классам: на 32-битной сборке ALIGNMENT равен 8, и 512 ÷ 8 действительно даёт 64 класса. На 64-битной выравнивание 16, поэтому классов вдвое меньше. Совпадение с числом пулов в арене (1 МиБ ÷ 16 КиБ = 64) — просто совпадение, и путать эти две шестьдесятчетвёрки не стоит.
Две ловушки, если захотите повторить замер. Во-первых, sys._debugmallocstats() печатает в stderr, а не в stdout. Во-вторых — и это неочевидно — печатает он из C, поэтому contextlib.redirect_stderr его не перехватывает: подмена работает на уровне объекта sys.stderr, а не файлового дескриптора. Перехват «срабатывает», возвращает пустую строку, и разбор молча даёт пустой результат. Нужен os.dup2.
Что из этого следует на практике
Запрос до 512 байт включительно попадает в pymalloc и округляется вверх до ближайшего из 32 классов. Всё, что больше, идёт напрямую в системный malloc. Граница именно такая: в obmalloc.c условие записано как nbytes > SMALL_REQUEST_THRESHOLD, то есть ровно 512 байт ещё обслуживает pymalloc — в таблице классов это последний, 31-й.
Это объясняет две вещи, которые иначе выглядят странно. Округление до класса размера — та самая «квантизация»: в том же выводе на голом старте на неё ушло 7 552 байта. А арена возвращается операционной системе, только когда полностью опустела, — да и то не всегда: последнюю свободную арену аллокатор придерживает, чтобы не отдавать и тут же просить её обратно. Поэтому долгоживущий процесс, однажды создавший миллион объектов и удаливший их, может не вернуть память: достаточно, чтобы в каждой арене остался один живой объект.
Цена free-threading, измеренная линейкой
Теперь то, что в 2026 году нельзя опускать в статье про память.
PEP 703 (Sam Gross, статус Final, Python 3.13) убирает GIL. Для памяти это означает три конкретных изменения, и все три видны замером.
Первое: заголовок объекта стал вдвое больше. В обычной сборке struct _object — это счётчик и указатель на тип. В сборке без GIL — совсем другая структура: ob_tid (идентификатор владеющего потока), ob_mutex (пообъектная блокировка), ob_gc_bits, ob_ref_local (локальный счётчик) и ob_ref_shared (разделяемый, атомарный). Это и есть biased reference counting: обычный поток трогает свой локальный счётчик без атомарных операций, а чужие потоки — разделяемый.
Цена видна невооружённым глазом:
| Объект | 3.14 с GIL | 3.14t без GIL |
|---|---|---|
object() | 16 Б | 32 Б |
1.5 | 24 Б | 40 Б |
пустой dict | 64 Б | 64 Б |
пустой list | 56 Б | 56 Б |
Шестнадцать байт добавляется каждому объекту, у которого нет ничего, кроме заголовка. А вот почему list и dict весят одинаково в обеих сборках — вопрос интереснее, и «оно потерялось в выравнивании» тут неверный ответ.
Дело в другом. Объект, за которым следит циклический сборщик, в обычной сборке несёт перед собой служебную структуру PyGC_Head — два указателя, 16 байт, и sys.getsizeof их учитывает. В сборке без GIL этой структуры нет вовсе: состояние сборщика переехало в поле ob_gc_bits внутри самого заголовка. Получается ровный размен: заголовок вырос на 16 байт, PyGC_Head на 16 байт исчез.
Арифметика сходится на пустом списке: 40 байт полей плюс 16 байт PyGC_Head в сборке с GIL — и 56 байт полей без всякого PyGC_Head в сборке без него. В обоих случаях 56. У object(), за которым сборщик не следит, компенсировать нечего — там рост виден целиком.
Второе: pymalloc заменён на mimalloc. PEP 703 объясняет причину прямо: реализация pymalloc не потокобезопасна без GIL. Замена видна в том же sys._debugmallocstats() — у двух сборок разный словарь:
с GIL: Small block threshold = 512, in 32 size classes
без GIL: Small block threshold = 16384, in 73 size classes
Medium block threshold = 131072
Large object max size = 16777216
Ни «арен», ни «пулов» во второй сборке нет вовсе. Это другой аллокатор, а не настроенный по-другому старый.
Третье: бессмертных объектов стало больше. Объекты, живущие всё время работы программы — None, True, малые целые, интернированные строки, — получают фиксированный счётчик ссылок и перестают его менять (PEP 683, Python 3.12). Значение константы менялось: в 3.12 и 3.13 getrefcount(None) возвращает 4294967295, в 3.14 — 3221225472, что ровно 3ULL << 30 из Include/refcount.h.
Но интереснее сравнение сборок на одной версии:
| Выражение | 3.14 с GIL | 3.14t без GIL |
|---|---|---|
getrefcount(None) | 3221225472 | 3221225472 |
getrefcount(257) | 3 | 3221225472 |
Число 257 — не малое целое, в обычной сборке это самый обыкновенный объект со счётчиком 3. В сборке без GIL записанный в коде литерал 257 оказывается бессмертным.
Формулировать это надо аккуратно, иначе получится неправда. Бессмертно не «число 257», а константа кодового объекта: та же семёрка, полученная вычислением, ведёт себя обычно.
sys.getrefcount(257) # 3221225472 — литерал из кода
a = 256; b = a + 1
sys.getrefcount(b) # 2 — то же значение, обычный объектВывод снят на 3.14.0rc2t. Причина в том, что free-threaded сборка интернирует и делает бессмертными большинство констант кодовых объектов (Objects/codeobject.c, функция should_immortalize_constant) — так снимается состязание за их счётчик между потоками. Общая логика та же, что в PEP 703: бессмертие убирает атомарные операции над счётчиком. Но список объектов, которые PEP объявляет бессмертными, — это набор из PEP 683 (интернированные строки, малые целые, статические типы, True/False/None), и константы кодовых объектов в него не входят: их добавила уже реализация.
Что здесь гарантия языка, а что — деталь реализации
Разделение принципиальное, потому что почти всё в этой статье — второе.
| Утверждение | Статус |
|---|---|
| Объект освобождается, когда на него не осталось ссылок | Деталь CPython. Спецификация языка не обещает когда |
| Циклы собирает отдельный сборщик | Деталь CPython |
Пороги (2000, 10, 10) | Деталь CPython, менялась в 3.13 и внутри 3.14 |
| Арена 1 МиБ, пул 16 КиБ | Деталь CPython, зависит от разрядности сборки |
| Заголовок объекта 16 или 32 байта | Деталь CPython, зависит от режима сборки |
weakref не удерживает объект | Задокументированное поведение стандартной библиотеки |
gc.collect() собирает циклы | Гарантия интерфейса, но не времени и не количества |
Строка про немедленное освобождение — самая важная. В CPython del последнее_имя действительно вызывает __del__ сразу, и на этом построена куча кода с файлами и сокетами. Но это свойство подсчёта ссылок, а не языка: на PyPy или Jython тот же код освободит объект когда-нибудь. Именно поэтому существует with.
Со слабой ссылкой ровно та же оговорка, и она есть в самой документации: weakref гарантирует, что ссылка не удерживает объект, но не гарантирует, что после исчезновения последней сильной ссылки она немедленно начнёт возвращать None — до фактического уничтожения объекта она может его ещё отдавать. То, что в примере выше r() вернул None сразу, — снова заслуга подсчёта ссылок, а не обещание библиотеки.
Что стоит помнить
Три цифры из этой статьи устареют — вопрос только в том, какая первой. Полезнее не запомнить их, а запомнить, как проверить: gc.get_threshold() для порогов, sys._debugmallocstats() для аллокатора, sys.getsizeof рядом с sys._is_gil_enabled() для стоимости объекта.
И одно наблюдение напоследок. История с инкрементальным сборщиком — про то, что даже хорошая оптимизация может не пережить встречи с production: её выпустили, она уменьшила паузы, вызвала рост потребления памяти и была откачена в патч-релизе. Между 3.14.3 и 3.14.5 у вас разные сборщики мусора. Ни одна строка вашего кода при этом не изменилась.
Частые заблуждения
«Пороги сборщика — (700, 10, 10)».
Так было до 3.13. Измерено: 3.11 и 3.12 дают (700, 10, 10), 3.13 — (2000, 10, 10), 3.14.0 — (2000, 10, 0), 3.14.5 — снова (2000, 10, 10). В исходниках на теге v3.12.0 стоит 700, на v3.13.0 — 2000. Самое неприятное: дефолтные значения никогда не были описаны в документации gc, поэтому изменение прошло тихо.
«Память в Python освобождает сборщик мусора».
Основную работу делает счётчик ссылок, и делает её немедленно: как только Py_DECREF доводит ob_refcnt до нуля, объект освобождается прямо в этом вызове. Циклический сборщик существует ради единственного случая, который счётчику недоступен, — групп объектов, ссылающихся друг на друга и недостижимых извне.
«В цикле у объектов счётчик ссылок нулевой, поэтому их и не удаляют».
Наоборот: измерено, что после del обоих имён счётчики равны единице, а не нулю. Каждый объект честно держит другой. Именно потому, что счётчики ненулевые, механизм подсчёта ссылок считает пару живой — и нужен второй проход, который спрашивает не «есть ли ссылка», а «достижим ли объект».
«Арены по 256 КБ, пулы по 4 КБ, 64 класса размеров».
Это 32-битная сборка. На 64-битной ARENA_BITS = 20 и POOL_BITS = 14 (pycore_obmalloc.h, тег v3.14.5), то есть 1 МиБ и 16 КиБ, выравнивание 16 байт, классов размеров 32. Интерпретатор печатает размеры сам: 2 arenas * 1048576 bytes/arena, 23 unused pools * 16384 bytes — размеры тут константы сборки, а количества меняются от запуска к запуску. Про «64 класса» важно не перепутать: цифра верная именно для классов, потому что на 32-битной сборке выравнивание 8 и 512 ÷ 8 = 64.
«sys.getrefcount(x) показывает число ссылок на объект».
Показывает на единицу больше: аргумент функции — тоже ссылка. И размер помехи зависит от способа замера: то же значение, снятое через функцию-помощник, оказывается больше уже на два, потому что параметр этой функции — ещё одна ссылка. Измерено на 3.13.13: на месте 1, через обёртку 2.
«Отключение GIL — это про скорость, к памяти отношения не имеет».
Имеет, и измеримое. Заголовок объекта вырастает с 16 до 32 байт: вместо счётчика и типа там появляются ob_tid, ob_mutex, ob_gc_bits и два счётчика ссылок — локальный и разделяемый. sys.getsizeof(1.5) даёт 24 байта с GIL и 40 без него. У отслеживаемых сборщиком объектов рост при этом не виден: там одновременно исчезает PyGC_Head на те же 16 байт, поэтому пустой список весит 56 в обеих сборках. Заодно pymalloc заменён на mimalloc — у сборок разный вывод sys._debugmallocstats().
Проверка знаний
Объект создан, на него ссылается одно имя. Что вернёт sys.getrefcount(obj), если вызвать его напрямую в той же строке?
Источники и что читать дальше
13 ИСТОЧНИКОВ
- Include/refcount.h — подсчёт ссылок и бессмертные объектыИсходный код CPython. Определения Py_INCREF/Py_DECREF и константы _Py_IMMORTAL_INITIAL_REFCNT (3ULL << 30) — то самое число, которое возвращает getrefcount для None. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Include/refcount.h
- Include/object.h — раскладка PyObject в обеих сборкахИсходный код CPython. Две разные struct _object: под #ifndef Py_GIL_DISABLED и под #else. Вторая — та самая, где ob_tid, ob_mutex, ob_ref_local и ob_ref_shared. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Include/object.h
- Include/internal/pycore_obmalloc.h — константы pymallocИсходный код CPython. ARENA_BITS, POOL_BITS, ALIGNMENT и SMALL_REQUEST_THRESHOLD, а также комментарий о том, почему арены берутся через mmap. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Include/internal/pycore_obmalloc.h
- Include/internal/pycore_interp_structs.h — стартовые пороги GCИсходный код CPython. GC_GENERATION_INIT: 2000, 10, 10 для сборки с GIL и отдельная ветка для free-threaded. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Include/internal/pycore_interp_structs.h
- Include/internal/pycore_runtime_init.h (3.12) — пороги до измененияИсходный код CPython. Тот же блок инициализации на теге v3.12.0, где первый порог ещё равен 700. Нужен, чтобы показать, что число менялось. Тег CPython 3.12.0.https://github.com/python/cpython/blob/v3.12.0/Include/internal/pycore_runtime_init.h
- Python/gc.c (3.13) — выбор поколения для сборкиИсходный код CPython. gc_select_generation(): цикл от старшего поколения к младшему и условие long_lived_pending < long_lived_total / 4, из-за которого превышения threshold2 недостаточно для полной сборки. Там же — обнуление счётчиков собранных поколений и инкремент счётчика следующего. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Python/gc.c
- Include/internal/pycore_gc.h (3.12) — структура поколенияИсходный код CPython. NUM_GENERATIONS равно 3; в struct gc_generation одно поле count с комментарием «count of allocations or collections of younger generations» — та самая двусмысленность, из-за которой три порога выглядят однородными. Тег CPython 3.12.0.https://github.com/python/cpython/blob/v3.12.0/Include/internal/pycore_gc.h
- Objects/obmalloc.c (3.13) — арены, пулы, блоки и классы размераИсходный код CPython. Вычисление номера класса сдвигом ((nbytes - 1) >> ALIGNMENT_SHIFT), правило «один пул обслуживает один класс» (pool->szidx), список свободных блоков pool->freeblock и условие возврата арены системе — только когда свободны все её пулы. Тег CPython 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Objects/obmalloc.c
- What's New In Python 3.14 — раздел Garbage collectionОфициальная документация. Прямая формулировка о том, что инкрементальный сборщик из 3.14.0–3.14.4 откатили в 3.14.5 из-за сообщений о росте потребления памяти в production.https://docs.python.org/3.14/whatsnew/3.14.html#incremental-garbage-collection
- Документация модуля gc (3.14)Официальная документация. Смысл трёх порогов и пометки «Changed in version 3.14 / 3.14.5». Дефолтные значения здесь не описаны — и никогда не были.https://docs.python.org/3.14/library/gc.html
- Документация модуля weakrefОфициальная документация. Слабая ссылка не удерживает объект; после освобождения вызов ссылки возвращает None.https://docs.python.org/3/library/weakref.html
- PEP 683 — Immortal Objects, Using a Fixed RefcountPEP. Eric Snow и Eddie Elizondo, Python 3.12, статус Final. Откуда взялись объекты с фиксированным счётчиком ссылок.https://peps.python.org/pep-0683/
- PEP 703 — Making the Global Interpreter Lock Optional in CPythonPEP. Sam Gross, Python 3.13, статус Final. Biased reference counting, замена pymalloc на mimalloc, пообъектные блокировки и бессмертие как способ убрать состязание за счётчик.https://peps.python.org/pep-0703/