Производительность систем Память: аллокации, фрагментация, утечки, влияние GC
0%

Память: аллокации, фрагментация, утечки, влияние GC

Память: аллокации, фрагментация, утечки, влияние GC

CPU-профиль из https://courses.digitable.life/post/performance/03-cpu-profiling/ почти всегда заканчивается одинаково. В топе оказываются runtime.mallocgc, scanobject, __memmove_avx_unaligned_erms, G1ParScanThreadState, PyObject_Malloc — и инженер, искавший «медленный алгоритм», обнаруживает, что процессор занят перекладыванием байтов. Это не сбой профайлера, а нормальное состояние типичного сервиса: работа с памятью — самая частая причина того, что код на CPU-профиле выглядит быстрым, а сервис медленным.

Память коварна тем, что её стоимость платится не там, где возникает. Вы аллоцируете буфер в парсере — платит сборщик мусора через двести миллисекунд. Выбрали map[string][]byte вместо среза структур — платит кэш процессора на каждом обходе. Забыли ограничить кэш — платит OOM killer в три часа ночи. Поэтому и здесь работает принцип трека: сначала прибор, потом гипотеза, и только потом правка кода.

Ключевая мысль, из которой выводится всё остальное: память — это две разные величины, и путать их нельзя. Есть уровень (сколько занято сейчас: RSS, live set) и есть поток (скорость аллокаций: байт в секунду, объектов на запрос). Уровень отвечает за OOM и за стоимость железа. Поток отвечает за латентность: он определяет частоту работы GC, число страничных отказов и скорость вымывания кэша. Инструменты, симптомы и лечение у них разные. Первый вопрос любого расследования — какая из двух величин болит.

1. Четыре числа, которые все называют «памятью»

Половина споров в чате инцидента — это спор людей, смотрящих в разные счётчики.

Счётчик Что означает Где взять Когда полезен
VSZ вся размеченная адресация, включая нетронутое VmSize в /proc/PID/status почти никогда; у Go и JVM он огромен по дизайну
RSS страницы, подключённые к физической памяти VmRSS, ps -o rss базовое «сколько ест процесс»
PSS RSS с делением разделяемых страниц на число пользователей /proc/PID/smaps_rollup когда суммируете память по многим процессам
USS только приватные страницы Private_Clean+Private_Dirty «сколько освободится, если убить процесс»
live heap объём достижимых объектов по мнению рантайма runtime/metrics, jcmd GC.heap_info единственное число, которым управляет ваш код
memory.current всё, что cgroup записала на процесс, включая page cache /sys/fs/cgroup/memory.current именно по нему вас убивает Kubernetes
# Быстрый срез по процессу. VmHWM — исторический пик RSS: единственный способ
# узнать «сколько было в пике» без непрерывного семплирования.
grep -E 'VmSize|VmRSS|VmHWM|RssAnon|RssFile' /proc/$PID/status
grep -E 'Rss|Pss|Private|Anonymous' /proc/$PID/smaps_rollup

# В контейнере смотреть надо на cgroup, а не на процесс:
cat /sys/fs/cgroup/memory.current   # сколько числится сейчас
cat /sys/fs/cgroup/memory.max       # лимит, после которого приходит OOM killer
cat /sys/fs/cgroup/memory.stat      # anon, file, slab, sock — разбивка по видам

Самое частое недоумение: «рантайм говорит 300 MB, RSS показывает 780 MB, кто врёт?» Никто. Разницу дают метаданные аллокатора и GC, стеки потоков и горутин, свободные страницы, ещё не возвращённые ядру, отображённые файлы (RssFile), нативные аллокации библиотек мимо кучи рантайма (zlib, OpenSSL, драйверы БД) и фрагментация. Каждое слагаемое измеримо отдельно — об этом вся остальная статья.

Адресное пространство процесса и три маршрута запроса памяти

2. Маршрут одной аллокации

Чтобы понимать цену, надо знать путь. malloc(33) или new Foo() — это не одна операция, а проход по дереву развилок, где каждая следующая на порядок дороже предыдущей.

Вывод первый: самая быстрая аллокация — та, которой не было. Ветка «стек» отличается от остальных не процентами, а порядками, и решение принимает компилятор. Его можно спросить:

go build -gcflags='-m -l' ./... 2>&1 | grep -E 'escapes to heap|does not escape'
# ./parser.go:41:13: make([]byte, 4096) does not escape
# ./parser.go:58:21: &Result{...} escapes to heap
# ./parser.go:58:21:   ... because: returned by function
# ./handler.go:77:33: buf escapes to heap
# ./handler.go:77:33:   ... because: passed to interface method Write

Последняя строка — самая частая причина «неожиданных» аллокаций: передача значения в интерфейс. fmt.Println(x) отправляет x в кучу, даже если это int. То же в C# — упаковка значимого типа при приведении к object, и в Java — автобоксинг int в Integer внутри коллекций.

Вывод второй: цена нелинейна и зависит от развилки. Порядки величин ниже — с той же оговоркой, что и для таблицы задержек из https://courses.digitable.life/post/performance/02-benchmarking/: это карта масштабов, а не константы. На вашем железе, аллокаторе и размере объектов числа будут другими, и получить их можно только замером.

Что происходит Порядок Комментарий
стековая переменная доли наносекунды часто вообще бесплатно
попадание в кэш потока (tcache, mcache) единицы наносекунд горячий путь, ради него всё и строилось
поход в центральный список с блокировкой десятки наносекунд при контеншне хуже на порядки, см. https://courses.digitable.life/post/performance/07-concurrency-performance/
minor page fault и зануление страницы ~микросекунда первое касание каждой новой страницы
mmap / munmap единицы микросекунд плюс инвалидация TLB на всех ядрах
промах кэша при обращении к «холодному» объекту ~100 нс главная скрытая цена, тема https://courses.digitable.life/post/performance/05-cache-and-locality/

3. Три налога, которые не видно в микробенчмарке

Микробенчмарк аллокации в цикле покажет «12 нс/op» и обманет: два из трёх слагаемых настоящей цены в измерение горячего цикла по определению не попадают.

Налог первый — страничные отказы. Аллокатор получает от ядра адресный диапазон, но не память. Физическая страница подключается при первом обращении через minor page fault: прерывание, вход в ядро, поиск страницы, зануление 4 KiB (ядро обязано не отдавать чужие данные), обновление таблиц. Это видно приборно:

perf stat -e page-faults,minor-faults,major-faults,dTLB-load-misses,cycles,instructions ./service --bench
       1 284 553      page-faults               #    0,412 K/sec
       1 284 401      minor-faults
             152      major-faults              # это уже диск: своп или чтение файла
      18 402 117      dTLB-load-misses          #    2,31% of all dTLB accesses
 112 480 003 921      cycles
  98 004 552 118      instructions              #    0,87  insn per cycle

Больше миллиона minor faults за прогон — это заметное время в ядре, не видимое ни в одном профиле прикладного кода. major-faults важнее качественно: любое ненулевое значение означает поход на диск за памятью, то есть конец предсказуемой латентности.

Налог второй — вымывание кэша. Новый объект вытесняет из L1 что-то полезное, зануление свежей страницы прокачивает через кэш 4 KiB. Рабочий набор перестаёт помещаться в кэш, IPC падает ниже единицы (строка insn per cycle выше), и профиль показывает «медленным» код, который сам по себе быстрый. Разбирается в https://courses.digitable.life/post/performance/05-cache-and-locality/.

Налог третий — работа сборщика мусора. Для трассирующего GC верно приближение: объём работы за цикл пропорционален живому объёму, а частота циклов — скорости аллокации. Микробенчмарк с одной короткой аллокацией GC почти не запускает, поэтому третий налог в его ns/op не входит вовсе — классический самообман из https://courses.digitable.life/post/performance/02-benchmarking/.

4. Приборы: чем измерять уровень и чем поток

Правило маршрутизации: сначала решите, вопрос про уровень или про поток, потом берите инструмент. RSS растёт монотонно и приходит OOMKilled — вопрос про уровень, нужны снимки живых объектов. Пилообразный p99, CPU в GC, низкий IPC — вопрос про поток, нужны события аллокации. RSS высокий, а рантайм говорит «мало» — вопрос про фрагментацию и нативную память, нужны smaps_rollup и статистика аллокатора.

Go: четыре профиля, которые часто путают

Опция pprof Что показывает Отвечает на вопрос
-inuse_space байты живых объектов сейчас «кто держит память» — утечки
-inuse_objects количество живых объектов «кто держит память мелкими кусками»
-alloc_space байты, выделенные за всё время «кто создаёт давление на GC»
-alloc_objects количество аллокаций за всё время «где много мелких аллокаций» — обычно самое полезное
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap

# Дифференциальный профиль — главный приём охоты на утечки.
curl -s localhost:6060/debug/pprof/heap > h1.pb.gz && sleep 900
curl -s localhost:6060/debug/pprof/heap > h2.pb.gz
go tool pprof -base h1.pb.gz -inuse_space h2.pb.gz   # что ВЫРОСЛО за 15 минут
(pprof) top
Showing nodes accounting for 812.44MB, 94.31% of 861.42MB total
      flat  flat%   sum%        cum   cum%
  402.51MB 46.73% 46.73%   402.51MB 46.73%  internal/cache.(*store).Put
  188.02MB 21.83% 68.56%   612.53MB 71.11%  api.(*Handler).enrichResponse
   96.00MB 11.14% 79.70%    96.00MB 11.14%  bytes.growSlice
   72.91MB  8.46% 88.16%    72.91MB  8.46%  encoding/json.(*decodeState).literalStore

flat — байты, выделенные непосредственно в функции; cum — вместе со всем, что она вызвала. Узел с flat == cum — конечная точка, где память и оседает. В heap-профиле ширина кадра flame graph означает байты или объекты, а не время: читается так же, как в статье про CPU, но ось другая.

Важно про смещение: heap-профиль в Go семплирующий, runtime.MemProfileRate по умолчанию 512 KiB. Профайлер компенсирует это масштабированием, суммы близки к правде, но редкие крупные аллокации представлены шумно, а редкие мелкие могут не попасть вовсе. На стенде ставят MemProfileRate = 1 и мирятся с замедлением; в проде значение не трогают.

Поток аллокаций в бенчмарке считается точно и без семплирования:

$ go test -run='^$' -bench=ParseEvent -benchmem -count=10 ./internal/parser
BenchmarkParseEvent-8    214383    5412 ns/op    8704 B/op    137 allocs/op

allocs/op — самая устойчивая метрика производительности из существующих: детерминирована, не зависит от шума соседей и частоты процессора, годится порогом в CI (https://courses.digitable.life/post/performance/12-optimization-workflow/). Падение со 137 до 12 — это факт, а не наблюдение.

Для наблюдения за уровнем в проде берите runtime/metrics, а не runtime.ReadMemStats: последний останавливает мир на время сбора.

// Полный список дескрипторов: https://pkg.go.dev/runtime/metrics
var samples = []metrics.Sample{
    {Name: "/gc/heap/live:bytes"},              // живой объём после разметки — «уровень»
    {Name: "/gc/heap/allocs:bytes"},            // счётчик выделенного; производная — «поток»
    {Name: "/gc/pauses:seconds"},               // гистограмма STW-пауз
    {Name: "/memory/classes/heap/free:bytes"},  // свободно у рантайма, но не отдано ядру
    {Name: "/memory/classes/total:bytes"},      // всё, что рантайм взял у ОС
    {Name: "/sched/goroutines:goroutines"},     // рост горутин — тоже утечка памяти
}

func Collect() []metrics.Sample { metrics.Read(samples); return samples } // без остановки мира

JVM, .NET, Python, нативный код, браузер

# JVM. Что делает GC — читаем лог (unified logging, JDK 9+), а не гадаем.
java -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags -XX:+UseG1GC -jar app.jar
# Кто аллоцирует — семплирование по TLAB, накладные расходы порядка процента.
java -XX:StartFlightRecording=settings=profile,filename=rec.jfr -jar app.jar
jfr print --events ObjectAllocationSample rec.jfr | head -50
./profiler.sh -e alloc -d 60 -f alloc.html <pid>      # async-profiler, сразу flame graph
# Кто держит память — снимок кучи (STW на секунды!) и dominator tree в Eclipse MAT.
jcmd <pid> GC.heap_info && jcmd <pid> GC.heap_dump /tmp/heap.hprof

dotnet-counters monitor -p <pid> --counters System.Runtime  # Heap Size, Allocation Rate, % Time in GC
dotnet-gcdump collect -p <pid>                              # граф объектов без полного дампа

valgrind --tool=massif --time-unit=B ./app && ms_print massif.out.*
heaptrack ./app                                    # на порядок быстрее massif
clang++ -fsanitize=address -g app.cc               # LeakSanitizer на выходе
MALLOC_CONF="prof:true,lg_prof_sample:19" ./app    # heap-профиль jemalloc, читается jeprof
[12.481s][info][gc] GC(37) Pause Young (Normal) (G1 Evacuation Pause) 1204M->318M(2048M) 14.203ms
[19.902s][info][gc] GC(38) Pause Young (Concurrent Start) (G1 Humongous Allocation) 1288M->402M(2048M) 18.117ms
[24.010s][info][gc] GC(41) Pause Full (G1 Compaction Pause) 1944M->1902M(2048M) 2841.664ms

1204M->318M(2048M) читается как «до сборки / после сборки / размер кучи»; здоровый молодой GC опускает первое число почти до live set. Диагноз в последней строке: Pause Full на 2,8 секунды, после которой осталось 1902M из 2048M, означает, что живых объектов слишком много и G1 пришлось делать полное уплотнение. Ещё пара таких — и придёт OutOfMemoryError. Humongous Allocation — отдельный сигнал: объект больше половины региона G1 идёт особым путём, обычно это массив, который стоило бы переиспользовать. В MAT главный инструмент — dominator tree: он показывает не «кто ссылается», а «кто удерживает», и утечка выглядит как одна коллекция с гигантским retained size на верхушке.

Специфика .NET: объекты крупнее 85 000 байт попадают в Large Object Heap, которая по умолчанию не уплотняется. Классическая деградация — массивы чуть больше порога с плавающим размером; лечение — ArrayPool<T>.Shared и фиксированные размеры буферов.

import tracemalloc
tracemalloc.start(25)                     # 25 кадров стека на аллокацию
before = tracemalloc.take_snapshot()
serve_requests(n=10_000)
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:10]:   # тот же diff, что и pprof -base
    print(stat)
# handlers/enrich.py:88: size=214 MiB (+214 MiB), count=1902331 (+1902331), average=118 B

tracemalloc встроен и точен по строкам, но замедляет процесс в разы; для прод-подобных прогонов лучше memray с flame graph и режимом --native (обязателен при NumPy и C-расширениях). Помните и про особенность CPython: подсчёт ссылок освобождает объекты сразу, циклы собирает отдельный поколенческий сборщик, а pymalloc держит арены — «утечка» здесь часто оказывается невозвращённой ареной.

В браузере инструмент один и хороший: вкладка Memory в Chrome DevTools — heap snapshot с колонками Shallow/Retained Size, сравнение двух снимков, allocation timeline и фильтр Detached для узлов, оторванных от DOM, но кем-то удерживаемых. Подробнее — «Веб-производительность».

5. Фрагментация: память есть, а выделить нельзя

Внутренняя и внешняя фрагментация, страничное закрепление

Внутренняя фрагментация — потери внутри выданного блока из-за округления до размерного класса. Аллокаторы не выдают произвольное число байт: у них фиксированная сетка классов (в Go — 68 классов до 32 KiB, у tcmalloc и jemalloc похожая логика), потому что так свободные списки становятся O(1) и не требуют поиска подходящего куска. Запрос в 33 байта получает блок 48 байт, 31% уходит в никуда, плюс заголовок блока в glibc. Отсюда следствие: размер структуры важен, и не только из-за кэша. Сортировка полей по убыванию размера убирает выравнивающие дыры и иногда сдвигает объект в меньший класс:

// 32 байта: bool + 7 паддинга, int64, bool + 7 паддинга, int64
type BadLayout struct{ Active bool; Count int64; Deleted bool; Size int64 }

// 24 байта: те же данные, поля отсортированы по убыванию размера
type GoodLayout struct{ Count, Size int64; Active, Deleted bool }

// Проверка: unsafe.Sizeof или анализатор fieldalignment из golang.org/x/tools.
// На 10 млн объектов разница с учётом размерных классов — сотни мегабайт.

Внешняя фрагментация — свободной памяти достаточно, но она размазана дырами, и непрерывного куска нужного размера нет. Уплотняющий (moving) GC решает это по построению, перенося живые объекты; поэтому JVM с G1/ZGC и .NET кучу уплотняют, а Go — нет (он non-moving и полагается на размерные классы, которые внешнюю фрагментацию сильно ограничивают). malloc уплотнять не может в принципе: он раздал указатели, и двигать их нельзя.

Третий эффект, самый практичный, — страничное закрепление. Ядро учитывает память страницами по 4 KiB, и если на странице жив хотя бы один объект, она остаётся в RSS целиком. Отсюда каноническое «я освободил 90% объектов, а RSS не упал»: выжившие 10% размазаны по всем страницам.

# glibc: число арен по умолчанию 8 × число ядер — на 64 ядрах это очень много памяти.
export MALLOC_ARENA_MAX=2
# Из кода после пиковой фазы: malloc_trim(0) вернёт свободные вершины арен ядру,
# malloc_stats() / malloc_info() напечатают сводку по аренам.

# jemalloc: главный числовой признак фрагментации — resident к allocated.
MALLOC_CONF="stats_print:true" ./app
#   allocated: 1204 MiB   — сколько реально просило приложение
#   resident:  2088 MiB   — сколько страниц удерживается → ratio 1,73, это тревога
MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000" ./app

Норма отношения resident / allocated для долгоживущего сервиса — 1,1–1,3; устойчиво выше 1,5 означает фрагментацию, а не утечку, и лечится другими средствами. Кстати о наблюдаемости: в Go 1.12–1.15 рантайм отдавал страницы через MADV_FREE (ядро забирает их только под давлением), из-за чего RSS выглядел раздутым и мониторинг паниковал; в Go 1.16 вернулись к MADV_DONTNEED именно ради читаемости RSS.

Ходы против фрагментации по возрастанию радикальности: уменьшить разброс размеров (пулы буферов фиксированного размера); разделить долгоживущие и короткоживущие объекты по аренам; сменить аллокатор на jemalloc или tcmalloc (для многопоточных сервисов на glibc это иногда даёт минус 30–40% RSS без единой строчки кода — но с замером, а не по вере); в крайнем случае — регулярный перезапуск воркеров, честный приём, который зря считают позорным.

6. Утечки: определение, таксономия, метод поиска

Утечка — это не «память растёт». Растущий кэш, прогрев пулов, буферы соединений — это рабочий набор, он растёт и выходит на плато. Утечка — память, достижимая для рантайма, но не нужная программе, объём которой растёт монотонно со временем или с числом обработанных запросов. Любой график, выходящий на плато, утечкой не является.

Метод поиска всегда один — дифференциальный: не «посмотреть профиль», а сравнить два профиля, снятых с интервалом на устойчивой нагрузке.

Три признака, отличающих утечку от прочего: рост монотонный, а не пилообразный; он пропорционален числу запросов, а не времени работы (если времени — ищите таймеры и фоновые задачи); он не исчезает после принудительной полной сборки (runtime.GC(), jcmd GC.run, GC.Collect()) — если исчезает, у вас не утечка, а слишком редкий GC.

// 1. Срез удерживает весь исходный массив. Классика Go.
func firstLine(payload []byte) []byte {
    i := bytes.IndexByte(payload, '\n')
    return payload[:i]                 // УТЕЧКА: 10 MB payload живы ради 40 байт
}
func firstLineFixed(payload []byte) []byte {
    i := bytes.IndexByte(payload, '\n')
    out := make([]byte, i)
    copy(out, payload[:i])             // копия обрывает связь с большим массивом
    return out
}

// 2. Обрезание среза не обнуляет указатели: без явного items[i] = nil перед
//    items[:0] объекты остаются достижимыми в невидимом хвосте массива.

// 3. Горутина, которая не завершится, держит свой стек и всё замыкание.
func leaky(ch chan Result) {
    go func() {
        process(<-ch)                  // никто не пишет и не закрывает ch — горутина вечна
    }()
}
// Диагностика: /debug/pprof/goroutine?debug=2 и /sched/goroutines:goroutines.
// Монотонный рост числа горутин — утечка памяти, даже если heap-профиль чист.
// 4. Браузер: обработчик держит замыкание, замыкание держит узел, узел не собирается.
function attach(node: HTMLElement, rows: Row[]): () => void {
  const handler = () => render(rows);          // замыкание держит rows целиком
  node.addEventListener("scroll", handler);
  return () => node.removeEventListener("scroll", handler);  // всегда возвращайте отписку
}
// Проверка: DevTools → Memory → Heap snapshot → фильтр "Detached".

Пятый классический случай — ThreadLocal в пуле потоков на JVM: поток пула живёт вечно, поэтому значение без finally { CTX.remove(); } живёт столько же. Тот же механизм у любого «контекста запроса», привязанного к переиспользуемому носителю.

7. GC: что им реально управляет

Про сборку мусора обычно спорят на уровне «какой коллектор лучше». Практически полезнее знать три вещи: жизненный цикл цикла сборки, компромисс, который нельзя обойти, и два рычага, которые у вас есть.

Компромисс, который нельзя обойти. У любого GC три желаемых свойства: высокая пропускная способность (мало CPU на сборку), низкая латентность (короткие паузы) и малый след (мало лишней памяти). Улучшаются любые два за счёт третьего. Дать куче вдвое больше памяти — сборки станут вдвое реже, пропускная способность и латентность улучшатся, след вырастет. Взять ZGC вместо Parallel — паузы упадут до долей миллисекунды, но вырастет доля CPU на барьеры. Памятью можно купить время — самый недооценённый и самый быстрый в применении рычаг оптимизации.

Два рычага частоты сборок. Для Go цель кучи равна live × (1 + GOGC/100), а частота циклов приблизительно alloc_rate / (live × GOGC/100). Из формулы оба рычага читаются буквально: уменьшить alloc_rate (меньше мусора на запрос — работает всегда, требует правок кода) либо увеличить live × GOGC/100 (отдать GC больше свободной памяти — работает мгновенно, требует только RAM).

# Контейнер с лимитом 1 GiB. Оставляем запас на стеки, нативную память и всплески.
export GOMEMLIMIT=800MiB   # мягкий лимит (Go 1.19+): у границы GC работает чаще,
export GOGC=400            # вплоть до непрерывного — лучше, чем OOMKilled, но 100% CPU
                           # в GC тоже отказ, поэтому /gc/cycles/total нужен алерт.
# Прогон ниже — с GOGC по умолчанию (100), чтобы арифметика цели была видна.
GODEBUG=gctrace=1 ./service 2>&1 | head
gc 41 @128.402s 1%: 0.081+42+0.009 ms clock, 0.64+18/83/0+0.077 ms cpu, 604->618->302 MB, 610 MB goal, 0 MB stacks, 0 MB globals, 8 P
gc 42 @129.884s 1%: 0.074+39+0.011 ms clock, 0.59+21/78/0+0.088 ms cpu, 598->615->299 MB, 604 MB goal, 0 MB stacks, 0 MB globals, 8 P
  • 1%суммарная доля процессорного времени в GC с момента старта. Больше 10% — красный флаг.
  • 0.081+42+0.009 ms clock — STW завершение подметания, конкурентная разметка, STW завершение разметки. Пользователь чувствует только первое и третье число, и они здесь в микросекундах — это норма.
  • 0.64+18/83/0+0.077 ms cpu — то же в процессорном времени; средний блок 18/83/0 это assist / фоновые воркеры / простой. Растущие ассисты означают, что аллокация обгоняет разметку, и именно ассист превращает GC в латентность конкретных запросов.
  • 604->618->302 MB — куча перед сборкой, на пике разметки, после сборки. Третье число и есть live set; его и надо строить на графике: монотонный рост — утечка, колебания — рабочий набор.
  • 604 MB goal в следующей строке — цель из предыдущего live set: 302 × (1 + 100/100). Удобная перекрёстная проверка того, что GOGC реально доехал до процесса, а не остался в README.
Рантайм / сборщик Модель Уплотняет Паузы Главный рычаг
Go конкурентный mark-sweep, без поколений нет десятки–сотни мкс GOGC, GOMEMLIMIT
JVM Parallel STW поколенческий, копирующий да десятки мс — секунды -Xmx, размер молодого поколения
JVM G1 (по умолчанию с JDK 9) регионный, поколенческий, инкрементальный да цель -XX:MaxGCPauseMillis -Xmx, цель паузы
JVM ZGC / Shenandoah конкурентный, с барьерами да, конкурентно доли мс почти независимо от кучи -Xmx, доля CPU
.NET server GC поколенческий, фоновый для gen2 да, кроме LOH единицы мс режим GC, ArrayPool
V8 Scavenger для молодых плюс Mark-Compact да миллисекунды, инкрементально --max-old-space-size
CPython подсчёт ссылок плюс сборщик циклов нет амортизированно избегать циклов, __slots__

Поколенческая гипотеза («большинство объектов умирает молодыми», Ungar, 1984) устойчиво выполняется для серверного кода: молодое поколение собирается копированием живых, а живых там мало, значит работа пропорциональна маленькому числу. Go поколений не имеет намеренно — escape analysis плюс размерные классы плюс отсутствие перемещения дают другой набор компромиссов, а не «худший GC».

8. Снижение давления: приёмы и их цена

Все приёмы ниже работают. Все они иногда делают хуже. Порядок неизменен: профиль (alloc_objects), гипотеза, правка, повторный замер benchstat, и только потом коммит.

// 1. Предразмер: append без cap растит массив удвоением, то есть примерно
//    log2(n) аллокаций и столько же копирований. map тоже принимает подсказку.
out := make([]Item, 0, len(in))
idx := make(map[string]int, len(in))

// 2. Переиспользование буферов. Работает для объектов ОДНОГО порядка размера
//    при высокой частоте использования.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func render(w io.Writer, v View) error {
    b := bufPool.Get().(*bytes.Buffer)
    defer func() {
        if b.Cap() > 64<<10 {
            return // раздутый буфер в пул не возвращаем, иначе пул сам станет утечкой
        }
        b.Reset()
        bufPool.Put(b)
    }()
    if err := tmpl.Execute(b, v); err != nil {
        return err
    }
    _, err := w.Write(b.Bytes())
    return err
}
// sync.Pool очищается на каждом цикле GC (с 1.13 есть victim cache, объект живёт
// один цикл). Это КЭШ, а не пул ресурсов: гарантий нет. При разбросе размеров пул
// стабилизируется на самых крупных экземплярах и начинает удерживать память.

// 3. Не аллоцировать вовсе: писать в буфер вызывающего. Идиома append-to-dst —
//    так устроены strconv.AppendInt и time.Time.AppendFormat.
func (e *Event) AppendJSON(dst []byte) []byte {
    dst = append(dst, `{"id":"`...)
    dst = append(dst, e.ID...)
    dst = append(dst, `","ts":`...)
    return strconv.AppendInt(dst, e.TS, 10)
}

// 4. Убрать копию при конверсии []byte в string. Только для чтения, только когда
//    доказано профилем и b гарантированно больше не меняется (Go 1.20+).
func key(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) }

Что даёт результат чаще всего, по убыванию отдачи: убрать лишний слой сериализации (JSON → структура → другая структура → JSON); перейти на потоковый разбор вместо чтения тела целиком (json.Decoder вместо ReadAll плюс Unmarshal); заменить []*T на []T там, где владение позволяет (минус аллокация и разыменование на элемент, плюс локальность); убрать boxing в горячем пути; предразместить коллекции; и только потом браться за пулы и unsafe.

$ benchstat before.txt after.txt
             │  before.txt  │              after.txt              │
             │    sec/op    │   sec/op     vs base                │
ParseEvent-8   5.412µ ± 2%   1.883µ ± 3%  -65.21% (p=0.000 n=10)
             │     B/op     │    B/op      vs base                │
ParseEvent-8  8.500Ki ± 0%   0.406Ki ± 0%  -95.22% (p=0.000 n=10)
             │  allocs/op   │ allocs/op   vs base                 │
ParseEvent-8    137.0 ± 0%     6.0 ± 0%   -95.62% (p=0.000 n=10)

И сразу обязательная оговорка: минус 65% на функции — это не минус 65% к p99 сервиса. Если разбор занимал 8% времени запроса, закон Амдала оставит около 5% выигрыша. Микробенчмарк открывает гипотезу, нагрузочный тест (https://courses.digitable.life/post/performance/11-load-testing/) её закрывает.

9. Разбор случая: OOMKilled раз в трое суток

Симптом: нагрузка ровная, RPS стабилен, но каждые 60–80 часов под перезапускается с причиной OOMKilled. Лимит 1 GiB, GOMEMLIMIT не выставлен.

Шаг 1. Отделить утечку от рабочего набора. График container_memory_working_set_bytes за неделю: пилы нет, рост линейный. Деление на счётчик http_requests_total даёт стабильные ~90 байт на запрос, которые не возвращаются. Ровный наклон плюс отсутствие плато — утечка.

Шаг 2. Спросить рантайм. GODEBUG=gctrace=1: третье число растёт 180 → 240 → 305 MB за сутки. Память достижима, значит это логическая утечка, а не фрагментация и не нативный код мимо кучи — иначе live set стоял бы на месте при растущем RSS.

Шаг 3. Дифференциальный профиль.

$ go tool pprof -base h1.pb.gz -inuse_space -top h2.pb.gz
Showing nodes accounting for 61.44MB, 98.7% of 62.24MB total
      flat  flat%   sum%        cum   cum%
   61.44MB 98.72% 98.72%    61.44MB 98.72%  github.com/org/svc/idem.(*Store).Remember

Шаг 4. Найти механизм в коде. idem.Store — таблица идемпотентности: map[string]time.Time, ключ пишется на каждый POST, чистка по таймеру раз в час удаляет записи старше суток. Утечка не в отсутствии чистки, а в её недостаточности: приток ключей выше оттока. Плюс ловушка Go: удаление ключей из map не уменьшает выделенные бакеты, память возвращается только при пересоздании map.

Шаг 5. Правка. LRU с жёстким лимитом на число записей вместо чистки по времени; лимит и период в конфиге; метрика idem_store_entries — потому что невидимую структуру данных через месяц сломают снова. Дополнительно GOMEMLIMIT=800MiB как страховка: при новой утечке под будет тормозить и алертить, а не умирать молча.

Шаг 6. Перемер 72 часа. Плато на 415 MB, idem_store_entries упирается в лимит и держится, OOM нет. Только теперь задача закрыта: «поправили, и вроде стало лучше» состоянием закрытия не является.

10. Контейнеры, cgroups и OOM

  • Убивает вас не размер кучи, а memory.current относительно memory.max, а туда входят анонимная память, стеки, ядерные структуры сокетов и page cache от прочитанных файлов.
  • OOM killer выбирает жертву внутри cgroup, так что в поде из нескольких контейнеров прилететь может соседу. Диагноз — в dmesg и memory.events:
dmesg -T | grep -i -E 'oom|killed process'
# [Sat Jul 25 03:14:22 2026] Memory cgroup out of memory: Killed process 1 (service)
#   total-vm:4821904kB, anon-rss:1002344kB, file-rss:18244kB, shmem-rss:0kB, UID:65532
cat /sys/fs/cgroup/memory.events   # oom 4 / oom_kill 4 — было ли и сколько раз
  • Рантайм обязан знать про лимит. Go до 1.19 про cgroup не знал вовсе — отсюда GOMEMLIMIT. JVM умеет -XX:MaxRAMPercentage=70 (контейнеро-осведомлённость по умолчанию с JDK 10). .NET читает лимит сам, DOTNET_GCHeapHardLimitPercent даёт явный контроль. Node — --max-old-space-size.
  • -Xmx не равен лимиту контейнера. Вне кучи живут metaspace, стеки потоков (около 1 MB на поток), code cache, direct byte buffers, нативные библиотеки. Стартовая точка — 60–75% лимита под кучу, остальное резерв; когда цифры не сходятся, включайте -XX:NativeMemoryTracking=summary и смотрите jcmd VM.native_memory summary.
  • В Kubernetes request влияет на планирование, limit — на убийство. Мониторить надо container_memory_working_set_bytes (именно его смотрит kubelet при вытеснении), а не usage_bytes, включающий переиспользуемый page cache. Подробности — https://courses.digitable.life/post/devops/06-kubernetes/.
  • THP (прозрачные огромные страницы) экономят TLB, но раздувают RSS и дают всплески латентности на дефрагментации. Для сервисов с чувствительным хвостом обычно ставят madvise вместо always, для баз данных вендоры часто рекомендуют выключить. Как всегда — измерьте оба варианта.

11. Как врут измерения памяти

  1. Heap-профиль семплирующий. Go берёт одну аллокацию на 512 KiB, JFR семплирует TLAB, memray в режиме семплирования — тоже. Прежде чем спорить о разнице в 5%, посмотрите на частоту семплирования.
  2. Профиль — это выжившие. -inuse_space показывает только дожившее до снимка. Объект, который живёт 200 мс и портит p99 ассистами GC, в нём не появится вообще; для потока есть только -alloc_* и события аллокации.
  3. RSS не убывает при free. Ни один аллокатор общего назначения не возвращает память ядру сразу. Не делайте вывода об утечке по RSS, не посмотрев на live set.
  4. Первый прогон меряет прогрев. Арены растут, страницы подключаются, JIT компилирует. Пик памяти после первого прогона — стоимость старта, а не пик системы.
  5. Микробенчмарк не запускает GC. Функция, аллоцирующая 8 KB на вызов, в бенчмарке может не вызвать ни одной сборки, а в проде создаёт постоянное давление: B/op и allocs/op честны, ns/op систематически занижен. GC.Collect() перед замером усугубляет — вы измеряете состояние, в котором прод не бывает.
  6. Ошибка выжившего на уровне флота. Средний RSS по подам не включает те, что уже убил OOM killer. Смотрите на распределение и счётчик рестартов — та же логика, что с перцентилями в https://courses.digitable.life/post/performance/01-measuring/.
  7. Числа из чужих статей. «Аллокация в Go стоит 25 нс» верно ровно для того размера, железа и версии, где это померили. Проверяется одной командой go test -bench на вашей машине.

12. Типичные ошибки

  1. Лечить фрагментацию как утечку и наоборот. Различаются одним взглядом на live set рантайма.
  2. Делать выводы о коде по RSS. RSS — интегральный показатель шести разных механизмов.
  3. Ставить -Xmx равным лимиту контейнера. Гарантированный OOM при первом всплеске нативной памяти.
  4. Оптимизировать аллокации, не глядя на alloc_objects. Обычно 90% аллокаций делают три функции, и это не те функции, о которых вы думали.
  5. Класть в sync.Pool объекты разного размера. Пул стабилизируется на максимуме и превращается в удержание памяти.
  6. Кэш без верхней границы. Самая частая логическая утечка в мире; ограничение по количеству записей надёжнее ограничения по времени, потому что не зависит от притока.
  7. Считать GOGC=off оптимизацией. Это отложенный отказ, а не ускорение.
  8. Игнорировать рост числа горутин или потоков. Каждая горутина — минимум 8 KB стека плюс всё, что она держит замыканием.
  9. Забыть про метрику после починки. Если утечка была возможна, она возможна снова; метрика размера структуры дешевле следующего расследования.

Мини-итог

  • Память — это две величины: уровень (RSS, live set — про OOM и стоимость железа) и поток (allocation rate — про латентность и GC). Первый шаг расследования — понять, какая болит.
  • «Память процесса» — минимум шесть разных чисел: VSZ бесполезен, RSS базовый, PSS для суммирования, live set показывает, чем управляет ваш код, memory.current — за что вас убьют.
  • Аллокация дороже своих наносекунд: страничные отказы, зануление и вымывание кэша, работа GC пропорционально выделенному. Микробенчмарк видит только первое слагаемое.
  • Фрагментация внутренняя (округление до размерных классов) и внешняя (нет непрерывного куска) — разные болезни; отношение resident / allocated отличает обе от утечки за один взгляд.
  • Утечка — монотонный рост достижимой, но ненужной памяти. Ищется дифференциально: два снимка на устойчивой нагрузке, pprof -base, dominator tree, фильтр Detached.
  • Работа GC пропорциональна живому объёму, частота — скорости аллокации. Отсюда ровно два рычага: меньше мусора или больше свободной памяти. Второй применяется за минуту (GOGC, GOMEMLIMIT, -Xmx) и часто закрывает вопрос без правок кода.
  • Все числа в статье — иллюстрация механики, а не константы. Достоверные цифры о вашей памяти дают только go test -benchmem, gctrace, heap-профиль и график RSS на вашей нагрузке.

Источники

Что дальше

Мы разобрали, сколько памяти тратится и когда она возвращается. Остался вопрос, который выглядит совсем иначе: почему два цикла с одинаковой алгоритмической сложностью и одинаковым числом аллокаций отличаются по времени в десять раз. Ответ лежит не в объёме памяти, а в порядке обращений к ней — то есть в кэшах, строках кэша и предвыборке.

Кэши и локальность: почему одинаковый по сложности код работает в 10 раз медленнее

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов