Learning Platform
Глоссарий Troubleshooting
Урок 06.03 · 28 мин
Начальный
resizemallocmemcpyfreegetsizeof

Три шага resize

Когда динамический массив переполняется, происходит resize. На уровне реализации это три шага:

Шаги resize: malloc -> memcpy -> free

Три системных вызова, каждый со своей стоимостью. Главная цена — memcpy N элементов.

1. malloc(new_capacity)запрос нового блокааллокатор ищет свободный блок нужного размера; может вызывать sbrk/mmap при дефиците
2. memcpy(new, old, N * sizeof)копирование N элементовглавная цена: N * 8 байт на 64-битной системе; для миллиона элементов это 8 MB байт-в-байт
3. free(old)освобождение старогоаллокатор возвращает блок в свой pool; не всегда отдаёт ОС

Главная стоимость — шаг 2, memcpy. На современных CPU memcpy очень быстрый: десятки GB/sec. Но в момент resize вся работа сосредоточена в одной операции, и она блокирует append на это время. Это и есть spike, который мы видели на p99/p999.

Стоимость каждого шага

  • malloc(N) — O(1) в большинстве реализаций. Glibc malloc, jemalloc, tcmalloc держат пулы блоков разных размеров и быстро отдают нужный. Но если фрагментация большая, может потребоваться запрос у ОС (sbrk на Linux, VirtualAlloc на Windows) — это ~1-10 микросекунд.

  • memcpy(dst, src, N * 8) — O(N) по байтам. На SSD-RAM современный memcpy идёт со скоростью ~20-50 GB/sec на одном ядре. Миллион указателей (8 MB) копируется за ~200-400 микросекунд.

  • free(old) — O(1). Возвращает блок в pool аллокатора. Часто блок не отдаётся ОС, а ждёт реюз.

Суммарно: один resize большого list (1M элементов) стоит ~200-500 микросекунд. Это и есть тот «spike» в нашем замере (max = 8500 ns на p99.9 — но это на маленьких размерах, при больших spike дотягивает до миллисекунд).

Как замерить spike

Используем sys.getsizeof как индикатор resize:

import sys
import time

xs = []
prev_size = sys.getsizeof(xs)
spikes = []

for i in range(1_000_000):
    t0 = time.perf_counter_ns()
    xs.append(i)
    t1 = time.perf_counter_ns()
    new_size = sys.getsizeof(xs)
    if new_size != prev_size:
        # это resize: размер буфера изменился
        spikes.append((len(xs), t1 - t0, new_size - prev_size))
        prev_size = new_size

# покажем первые и последние ресайзы
print("первые 5 resize:")
for length, ns, delta in spikes[:5]:
    print(f"  len={length:>7,}  duration={ns:>7,} ns  buffer +{delta} bytes")

print(f"\nвсего resize: {len(spikes)}")
print(f"последний:    len={spikes[-1][0]:,}  duration={spikes[-1][1]:,} ns")

Типовой результат:

первые 5 resize:
  len=      1  duration=   1,800 ns  buffer +32 bytes
  len=      5  duration=     400 ns  buffer +32 bytes
  len=      9  duration=     650 ns  buffer +64 bytes
  len=     17  duration=     720 ns  buffer +64 bytes
  len=     25  duration=     900 ns  buffer +64 bytes

всего resize: 53
последний:    len=798,193  duration=2,250,000 ns

Последний resize стоил 2.25 миллисекунды! Это копирование ~800 тысяч указателей в новый буфер. На обычных append спайки в десятки наносекунд — этот в 30 000 раз дороже. Вот это и значит «O(n) worst-case».

Почему resize виден в latency

Допустим, ваш сервис принимает поток событий и складывает их в буфер. На 99% событий latency 0.05 мс. На 0.001% (resize) — 2.5 мс. Если у вас p99.9 SLA = 1 мс — вы его пробьёте.

Решений два:

  1. Pre-allocate. Если знаете финальный размер: xs = [None] * N. Никаких resize.
  2. Incremental resize. Размазать копирование: вместо «целого resize за один append» — на каждом append копировать 1-2 элемента в новый буфер «параллельно». Так делают real-time hash tables в Redis (rehash incremental).

В обычном DE-флоу — пакетная обработка, throughput — spike не страшен. Но junior должен понимать, что происходит и как замерить.

Освобождает ли память shrinking?

Динамический массив легко растёт. А что насчёт уменьшения? Если вы из списка на 1M элементов удалили 999 999 — буфер остался 1M-большой?

В CPython — нет, есть shrinking. Когда после удаления ob_size < allocated / 2, происходит resize вниз. Но это не agressive: shrink происходит только при pop или del. Если вы массово заменяете xs = old[:1], новый list получает свежий маленький буфер.

В Java ArrayList и C++ std::vector shrinking не происходит автоматически. Нужно явно вызвать trimToSize() или shrink_to_fit(). Это сознательное решение: shrinking — это тоже copy, и его лучше делать руками.

TIP

Если ваш список долго жил большим, а потом стал маленьким — память может застрять. Простой приём: xs = list(xs) (или xs = xs[:]) создаёт новый list с буфером ровно под текущий размер.

Что делает аллокатор при больших realloc

На очень больших размерах (десятки MB) malloc обращается напрямую к ОС через mmap. Эта память возвращается в ОС при free, и физически освобождается. На маленьких блоках (меньше 128 KB) аллокатор держит память «у себя» в free-list — даже после free ОС эту память не получит обратно, пока процесс не упадёт.

Это объясняет, почему python-процесс с большим list не отпускает память даже после del: аллокатор хранит освобождённые блоки в pool на случай новых выделений. Если ваше приложение генерирует и удаляет много больших списков, это нормально — будет «плато» по RSS, но не растущий leak.

Полный пример: scope resize и память

import sys
import gc

def show_rss():
    import resource
    rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    # на macOS rss в байтах, на Linux в KB
    return rss

# создаём большой list
xs = list(range(10_000_000))
print(f"after build:   getsizeof={sys.getsizeof(xs):,}  rss={show_rss():,}")

# удаляем 99% элементов
xs = xs[:100_000]
gc.collect()
print(f"after slice:   getsizeof={sys.getsizeof(xs):,}  rss={show_rss():,}")

# rss остаётся высоким, потому что free возвращает блоки в pool, не в ОС

getsizeof маленький (только под новый список), rss остаётся высоким — память застряла в аллокаторе. Это норма.

Попробуй сам

Постройте таблицу resize и оцените, сколько ваш CPU копирует элементов в секунду:

import sys
import time

def benchmark_resize_speed(N):
    xs = []
    prev_size = sys.getsizeof(xs)
    biggest_resize = (0, 0, 0)  # (length, ns, copied_bytes)
    for i in range(N):
        t0 = time.perf_counter_ns()
        xs.append(i)
        t1 = time.perf_counter_ns()
        new_size = sys.getsizeof(xs)
        if new_size != prev_size:
            duration = t1 - t0
            if duration > biggest_resize[1]:
                biggest_resize = (len(xs), duration, prev_size)
            prev_size = new_size
    return biggest_resize

length, ns, copied = benchmark_resize_speed(10_000_000)
mb_per_sec = copied / ns * 1000
print(f"largest resize: at len={length:,}, took {ns:,} ns")
print(f"copied {copied:,} bytes => bandwidth ~{mb_per_sec:.1f} MB/sec")

На современном железе должно быть 10-30 GB/sec для memcpy. Это и есть «нативный» memcpy с использованием SIMD-инструкций. Никакой Python в нём нет — это чистый C.

malloc и free в ОС: почему freed память не сразу возвращается системе
Проверка знанийKnowledge check
Вы сделали xs = list(range(10_000_000)), потом xs = xs[:100_000]. sys.getsizeof(xs) показывает маленькое число, но RSS процесса остался высоким. Почему?
ОтветAnswer
После xs = xs[:100_000] старый список действительно освобождён: его буфер пошёл в free(), счётчик ссылок ушёл в ноль. Но free() в C-аллокаторе (PyMalloc/glibc) обычно НЕ возвращает память в ОС — блок остаётся в pool'е аллокатора, готовый к переиспользованию. Поэтому RSS, который измеряет память выделенную процессу ОС, остаётся высоким. Это не утечка — просто стратегия аллокатора. Память будет переиспользована при следующих malloc. На очень больших блоках (>128 KB обычно) malloc использует mmap, и free возвращает память в ОС немедленно — там RSS падает. Поведение зависит от размера блока и аллокатора.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что происходит при resize динамического массива?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4