ЗАМЕР
bench/memory/measure.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/python/memory/memory-management
- Как запустить
python3.13 bench/memory/measure.py > bench/memory/measurements.json python3.13 bench/memory/generations.py
Запись прогона
Замеры для статьи «Управление памятью»
Ничего не смоделировано: каждое число — то, что вернул сам интерпретатор в момент прогона.
| Файл | Что это |
|---|---|
measure.py |
снимает трассы для визуализаций: счётчики ссылок, поколения сборщика, арены и пулы |
generations.py |
счётчики поколений: каскад по шагам, разница против числа созданных, что будит сборщика |
generations.json |
трасса последнего прогона generations.py |
measurements.json |
свод, из которого взяты числа статьи |
mem-3.12.json, mem-3.13.json, mem-3.14.7.json, mem-3.14.7t.json, mem-3.14.5.json |
сырые прогоны по версиям, включая свободнопоточную 3.14.7t |
python3.13 bench/memory/measure.py > bench/memory/measurements.json
python3.13 bench/memory/generations.py
Почему этот каталог появился поздно
Скрипт лежал в tmp/, а tmp/ стоит в .gitignore — то есть в репозитории
его не было, и числа статьи нельзя было перезапустить. Правило «числа в статьях
берутся отсюда, а не из головы» для уроков выполнялось, для статей — нет.
Перенос исправляет это.
Правила, которые здесь важнее обычного
Память меряется tracemalloc на батче, а не sys.getsizeof — это общее
правило замеров, и в статье про память оно центральное: getsizeof даёт ответ
с обратным знаком на __slots__.
Время между версиями не сравнивается. Файлы по версиям лежат рядом не для того, чтобы вычесть одно из другого, а чтобы показать, что́ в каждой из них интерпретатор сообщает о себе сам.
tracemalloc_vs_rss.py — диагностическая рамка «tracemalloc против RSS»
Добавлен 27.08.2026 для правки статьи: слова tracemalloc в статье про память
не было ни разу, при том что это штатный инструмент диагностики роста памяти.
Скрипт не меряет ни время, ни размеры объектов — он показывает три
состояния, по которым различают причину роста:
| что видно | что это значит |
|---|---|
RSS растёт, tracemalloc — нет |
память взята мимо аллокатора объектов (mmap, буфер C-библиотеки) или объекты уже освобождены, а арена удержана |
tracemalloc растёт, RSS — нет |
новые объекты легли в арены, которые процесс уже держит |
| растут оба | обычный живой рост: объекты созданы и живы |
python3.13 bench/memory/tracemalloc_vs_rss.py
RSS читается из /proc/self/status (VmRSS), поэтому скрипт работает только
на Linux; на других системах колонка RSS будет пустой, и он об этом скажет.
Запись прогона: 27.08.2026, Python 3.13.7, Linux, 2 vCPU
PY 3.13.7
gc.isenabled(): True
1a. mmap на 64 МиБ, все страницы тронуты (аллокатор объектов не участвует):
после mmap + touch RSS +64.19 МиБ tracemalloc +0.00 МиБ
ожидаемый знак: RSS вырос, tracemalloc ~0
1b. 400 000 объектов созданы, затем удалены:
на пике (объекты живы) RSS +47.55 МиБ tracemalloc +9.24 МиБ
после del + gc.collect() RSS +31.53 МиБ tracemalloc +0.00 МиБ
ожидаемый знак: tracemalloc вернулся к нулю, RSS остался поднят
2. Тот же цикл ещё три раза подряд, пик каждого:
круг 1 RSS +16.03 МиБ tracemalloc +9.24 МиБ
круг 2 RSS +5.00 МиБ tracemalloc +9.24 МиБ
круг 3 RSS +5.00 МиБ tracemalloc +9.24 МиБ
ожидаемый знак: tracemalloc одинаков на всех кругах,
RSS от круга к кругу растёт всё меньше — арены уже у процесса
3. Что tracemalloc показывает про источник (top-3 строки):
bench/memory/tracemalloc_vs_rss.py:30 0.6 КиБ в 11 блоках
bench/memory/tracemalloc_vs_rss.py:48 0.3 КиБ в 7 блоках
bench/memory/tracemalloc_vs_rss.py:47 0.1 КиБ в 4 блоках
итог tracemalloc: current 0.00 МиБ, peak 9.26 МиБ
итог RSS : 58.11 МиБ
Что этот прогон показал и чего он не показывает
Ключевая строка — блок 2. tracemalloc даёт 9,24 МиБ на каждом из трёх
кругов, одно и то же число; RSS за те же круги прибавляет 16,03 → 5,00 →
5,00 МиБ. Это и есть демонстрация того, что два счётчика меряют разное:
tracemalloc считает запрошенные байты, RSS — страницы, которые процесс
держит у ядра. Первый круг после освобождения ещё дозапрашивает у ядра, к
третьему — уже нет.
Блок 1a — самый чистый случай «RSS вырос, tracemalloc нет»: 64 МиБ через
mmap минуя PyObject_Malloc. tracemalloc перехватывает только домены
аллокатора CPython, поэтому в трассе этих байтов нет вовсе. Так же выглядит
любой буфер, взятый сторонней C-библиотекой.
Чего в прогоне нет. Числа блока 1b нельзя читать как «объекты весят 47 МиБ»:
в RSS входит и служебная таблица самого tracemalloc (по записи на
прослеживаемый блок). Именно поэтому RSS на пике впятеро больше traced. Для
измерения размера объектов есть measure.py; этот скрипт — про разность
показаний, а не про абсолютные величины.
Абсолютные мегабайты привязаны к машине и к прогону. Воспроизводится знак
каждой строки и постоянство колонки tracemalloc в блоке 2, а не сами числа.
Скрипт
226 строк"""
Снимает реальные трассы для визуализаций статьи про управление памятью.
Ничего здесь не смоделировано: каждое число — то, что вернул сам
интерпретатор в момент прогона. Запуск:
python3.13 bench/memory/measure.py > bench/memory/measurements.json
"""
import gc
import json
import sys
out = {
"python": sys.version.split()[0],
"implementation": sys.implementation.name,
"gil_enabled": getattr(sys, "_is_gil_enabled", lambda: True)(),
"maxsize_bits": sys.maxsize.bit_length() + 1,
}
# --- 1. Счётчик ссылок по шагам ------------------------------------------
# sys.getrefcount сам создаёт временную ссылку на аргумент, поэтому его
# ответ всегда на единицу больше «настоящего» числа. Вычитаем её явно, а не
# молча: расхождение на единицу — самая частая причина недоумения при
# первом знакомстве с refcount.
#
# И ровно поэтому здесь НЕ вынесена функция-помощник rc(obj). Проверено на
# 3.13.13: при вызове через функцию параметр obj — это ещё одна ссылка, и
# ответ становится больше уже на две, а не на одну. Замер счётчика ссылок
# сам меняет то, что замеряет, и размер помехи зависит от того, как именно
# вы замеряете. Поэтому выражение везде записано на месте.
trace = []
class Thing:
__slots__ = ("name",)
def __init__(self, name):
self.name = name
a = Thing("payload")
trace.append({"code": 'a = Thing("payload")', "refcount": sys.getrefcount(a) - 1, "note": "одна ссылка: имя a"})
b = a
trace.append({"code": "b = a", "refcount": sys.getrefcount(a) - 1, "note": "второе имя на тот же объект"})
box = [a]
trace.append({"code": "box = [a]", "refcount": sys.getrefcount(a) - 1, "note": "список тоже держит ссылку"})
del b
trace.append({"code": "del b", "refcount": sys.getrefcount(a) - 1, "note": "имя удалено, объект жив"})
box.clear()
trace.append({"code": "box.clear()", "refcount": sys.getrefcount(a) - 1, "note": "список отпустил ссылку"})
out["refcount_trace"] = trace
# Насколько сам замер искажает результат: то же значение, снятое на месте и
# через функцию-помощник.
def _rc_via_helper(obj):
return sys.getrefcount(obj) - 1
probe = Thing("probe")
out["measurement_overhead"] = {
"inline": sys.getrefcount(probe) - 1,
"via_helper": _rc_via_helper(probe),
}
# --- 2. Что происходит, когда счётчик доходит до нуля ---------------------
freed = []
class Loud:
def __init__(self, name):
self.name = name
def __del__(self):
freed.append(self.name)
x = Loud("сразу")
del x
out["deallocated_immediately"] = list(freed)
# --- 3. Цикл: почему одного счётчика мало --------------------------------
freed.clear()
gc.collect()
class Node:
def __init__(self, name):
self.name = name
self.ref = None
def __del__(self):
freed.append(self.name)
left = Node("left")
right = Node("right")
left.ref = right
right.ref = left
cycle = {
"left_refcount_after_link": sys.getrefcount(left) - 1,
"right_refcount_after_link": sys.getrefcount(right) - 1,
}
del left
del right
cycle["freed_after_del"] = list(freed)
cycle["objects_in_gc_before_collect"] = sum(
1 for o in gc.get_objects() if isinstance(o, Node)
)
collected = gc.collect()
cycle["gc_collect_returned"] = collected
cycle["freed_after_collect"] = list(freed)
out["cycle"] = cycle
# --- 4. Пороги и счётчики поколений --------------------------------------
gc.collect()
gc.enable()
out["thresholds"] = list(gc.get_threshold())
counts = [{"allocated": 0, "counts": list(gc.get_count())}]
holder = []
for i in range(1, 6):
# Каждый объект в цикле отслеживается сборщиком, поэтому счётчик
# нулевого поколения растёт предсказуемо.
holder.append([None])
if i in (1, 2, 3, 4, 5):
counts.append({"allocated": i, "counts": list(gc.get_count())})
out["generation_counts_sample"] = counts
out["stats"] = gc.get_stats()
# --- 5. Раскладка pymalloc ------------------------------------------------
import contextlib
import io
import re
# Две ловушки подряд, обе молчаливые.
#
# Первая: sys._debugmallocstats() печатает в stderr, а не в stdout.
# Вторая, и более коварная: печатает он из C через fprintf, поэтому
# contextlib.redirect_stderr его не ловит — эта подмена работает на уровне
# объекта sys.stderr, а не файлового дескриптора. Перехват "срабатывает",
# возвращает пустую строку, разбор даёт пустой словарь, и измерение молча
# не измеряет ничего. Поэтому подменяем сам дескриптор 2.
import os
import tempfile
with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as tmp:
saved_fd = os.dup(2)
try:
sys.stderr.flush()
os.dup2(tmp.fileno(), 2)
sys._debugmallocstats()
sys.stderr.flush()
finally:
os.dup2(saved_fd, 2)
os.close(saved_fd)
tmp.seek(0)
raw = tmp.read()
arena = {}
m = re.search(r"(\d+) arenas \* (\d+) bytes/arena", raw)
if m:
arena["arenas"] = int(m.group(1))
arena["arena_bytes"] = int(m.group(2))
m = re.search(r"(\d+) unused pools \* (\d+) bytes", raw)
if m:
arena["unused_pools"] = int(m.group(1))
arena["pool_bytes"] = int(m.group(2))
m = re.search(r"Small block threshold = (\d+), in (\d+) size classes", raw)
if m:
arena["small_request_threshold"] = int(m.group(1))
arena["size_classes"] = int(m.group(2))
m = re.search(r"# bytes in allocated blocks\s+=\s+([\d,]+)", raw)
if m:
arena["allocated_block_bytes"] = int(m.group(1).replace(",", ""))
m = re.search(r"# bytes lost to pool headers\s+=\s+([\d,]+)", raw)
if m:
arena["pool_header_bytes"] = int(m.group(1).replace(",", ""))
m = re.search(r"# bytes lost to quantization\s+=\s+([\d,]+)", raw)
if m:
arena["quantization_bytes"] = int(m.group(1).replace(",", ""))
# Занятость по классам размеров — это и есть содержимое пулов.
classes = []
for line in raw.splitlines():
m = re.match(r"\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*$", line)
if m:
classes.append(
{
"class": int(m.group(1)),
"size": int(m.group(2)),
"pools": int(m.group(3)),
"in_use": int(m.group(4)),
"available": int(m.group(5)),
}
)
arena["size_class_table"] = classes
if arena.get("arena_bytes") and arena.get("pool_bytes"):
arena["pools_per_arena"] = arena["arena_bytes"] // arena["pool_bytes"]
out["pymalloc"] = arena
# --- 6. Размеры объектов --------------------------------------------------
out["object_sizes"] = {
"object()": sys.getsizeof(object()),
"float": sys.getsizeof(1.5),
"empty tuple": sys.getsizeof(()),
"empty list": sys.getsizeof([]),
"empty dict": sys.getsizeof({}),
}
# --- 7. Бессмертные объекты ----------------------------------------------
out["immortal_refcounts"] = {
"None": sys.getrefcount(None),
"True": sys.getrefcount(True),
"0": sys.getrefcount(0),
"256": sys.getrefcount(256),
"257": sys.getrefcount(257),
}
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)