Память: аллокации, фрагментация, утечки, влияние 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() — это не одна операция, а проход по дереву развилок, где каждая следующая на порядок дороже предыдущей.
из функции?"} B -->|"нет: escape analysis"| S["Стек: сдвиг указателя,
цена около нуля, GC не участвует"] B -->|"да"| C{"Размер"} C -->|"крупный: от 128 KiB в glibc,
от 32 KiB в Go, от 85 000 B в .NET"| L["Отдельный регион или LOH:
свой mmap, свои page faults"] C -->|"мелкий"| D["Округление до размерного класса"] D --> E{"Есть свободный блок
в кэше текущего потока?"} E -->|"да: типичный путь"| F["Снять с вершины списка:
единицы наносекунд, без syscall"] E -->|"нет"| G["Центральный список или арена:
блокировка либо атомики"] G --> H{"Есть свободные страницы
у аллокатора?"} H -->|"да"| I["Нарезать span на блоки"] H -->|"нет"| J["mmap или brk: системный вызов"] J --> K["Первое касание страницы:
minor page fault и зануление 4 KiB"] K --> M["Строка кода получила указатель"] I --> M F --> M L --> M S --> M
Вывод первый: самая быстрая аллокация — та, которой не было. Ветка «стек» отличается от остальных не процентами, а порядками, и решение принимает компилятор. Его можно спросить:
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. Как врут измерения памяти
- Heap-профиль семплирующий. Go берёт одну аллокацию на 512 KiB, JFR семплирует TLAB,
memrayв режиме семплирования — тоже. Прежде чем спорить о разнице в 5%, посмотрите на частоту семплирования. - Профиль — это выжившие.
-inuse_spaceпоказывает только дожившее до снимка. Объект, который живёт 200 мс и портит p99 ассистами GC, в нём не появится вообще; для потока есть только-alloc_*и события аллокации. - RSS не убывает при
free. Ни один аллокатор общего назначения не возвращает память ядру сразу. Не делайте вывода об утечке по RSS, не посмотрев на live set. - Первый прогон меряет прогрев. Арены растут, страницы подключаются, JIT компилирует. Пик памяти после первого прогона — стоимость старта, а не пик системы.
- Микробенчмарк не запускает GC. Функция, аллоцирующая 8 KB на вызов, в бенчмарке может не вызвать ни одной сборки, а в проде создаёт постоянное давление:
B/opиallocs/opчестны,ns/opсистематически занижен.GC.Collect()перед замером усугубляет — вы измеряете состояние, в котором прод не бывает. - Ошибка выжившего на уровне флота. Средний RSS по подам не включает те, что уже убил OOM killer. Смотрите на распределение и счётчик рестартов — та же логика, что с перцентилями в https://courses.digitable.life/post/performance/01-measuring/.
- Числа из чужих статей. «Аллокация в Go стоит 25 нс» верно ровно для того размера, железа и версии, где это померили. Проверяется одной командой
go test -benchна вашей машине.
12. Типичные ошибки
- Лечить фрагментацию как утечку и наоборот. Различаются одним взглядом на live set рантайма.
- Делать выводы о коде по RSS. RSS — интегральный показатель шести разных механизмов.
- Ставить
-Xmxравным лимиту контейнера. Гарантированный OOM при первом всплеске нативной памяти. - Оптимизировать аллокации, не глядя на
alloc_objects. Обычно 90% аллокаций делают три функции, и это не те функции, о которых вы думали. - Класть в
sync.Poolобъекты разного размера. Пул стабилизируется на максимуме и превращается в удержание памяти. - Кэш без верхней границы. Самая частая логическая утечка в мире; ограничение по количеству записей надёжнее ограничения по времени, потому что не зависит от притока.
- Считать
GOGC=offоптимизацией. Это отложенный отказ, а не ускорение. - Игнорировать рост числа горутин или потоков. Каждая горутина — минимум 8 KB стека плюс всё, что она держит замыканием.
- Забыть про метрику после починки. Если утечка была возможна, она возможна снова; метрика размера структуры дешевле следующего расследования.
Мини-итог
- Память — это две величины: уровень (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 на вашей нагрузке.
Источники
- Richard Jones, Antony Hosking, Eliot Moss. The Garbage Collection Handbook, 2nd ed., 2023; David Ungar. Generation Scavenging, 1984 — источник поколенческой гипотезы.
- Brendan Gregg. Systems Performance, 2nd ed., гл. 7 «Memory»; Memory Leak (and Growth) Flame Graphs.
- Go: A Guide to the Go Garbage Collector — лучшее объяснение
GOGCиGOMEMLIMITс интерактивными графиками; runtime/metrics; Profiling Go Programs. - JVM: JEP 333: ZGC, Garbage-First GC Tuning, Eclipse MAT, async-profiler.
- .NET: Fundamentals of garbage collection, Large Object Heap.
- Аллокаторы: jemalloc и его TUNING.md; TCMalloc design; mimalloc; glibc malloc internals.
- Инструменты: valgrind massif, heaptrack, memray, Chrome DevTools Memory, tracemalloc.
- Ядро: cgroup-v2, Transparent Hugepage Support, proc(5).
- Смежное на портале: https://courses.digitable.life/post/operating-systems/04-memory-management/ — виртуальная память и подкачка со стороны ядра; https://courses.digitable.life/post/data-structures/01-complexity-and-memory/ — как раскладка структур влияет на потребление; https://courses.digitable.life/post/frontend/14-web-performance/ — память в браузере.
Что дальше
Мы разобрали, сколько памяти тратится и когда она возвращается. Остался вопрос, который выглядит совсем иначе: почему два цикла с одинаковой алгоритмической сложностью и одинаковым числом аллокаций отличаются по времени в десять раз. Ответ лежит не в объёме памяти, а в порядке обращений к ней — то есть в кэшах, строках кэша и предвыборке.
Кэши и локальность: почему одинаковый по сложности код работает в 10 раз медленнее