open atlas
↑ К треку
Go с нуля до senior GO · 06 · 01

Память и escape-анализ: кто решает «стек или куча» и сколько на самом деле стоит аллокация

Escape-анализ решает «стек или куча» на компиляции: в кучу уходит то, что переживает кадр, попадает в interface или захвачено замыканием. Читайте -gcflags=-m, помните: стек растёт копированием, а настоящая цена аллокации — давление на GC, а не ~25 нс самого malloc.

GO Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Сервис оформления заказов тратил 31% CPU на сборку мусора, и первой реакцией команды было «потюнить GC». Кто-то вместо этого снял профиль аллокаций. На вершине оказался не разбор JSON и не драйвер базы — а обёртка логгера, вызываемая на каждом запросе и форматирующая request ID через fmt.Sprintf("%d", id). Одна невинная строка. Поскольку fmt принимает ...interface{}, каждое целое, переданное туда, упаковывалось в кучу: миллион запросов в час, три строки лога на запрос, три heap-аллокации на строку. Компилятор сообщал об этом всё время — go build -gcflags=-m печатал id escapes to heap любому, кто посмотрит. Замена форматирования на горячем пути на strconv.AppendInt в переиспользуемый буфер срезала скорость аллокаций на 60%, а GC — с 31% до 9% CPU. Никто ничего не тюнил. Просто перестали аллоцировать — а чтобы перестать, надо сначала понять, почему компилятор решил, что вы аллоцируете.

За следующие пятнадцать минут вы увидите, почему fmt.Sprintf оказался виновником, как заставить компилятор показать своё решение и что делать вместо этого — чтобы в следующий раз, когда GC CPU взлетит, вы не тянулись первым делом к ручкам GC.

«Стек или куча» — решение компилятора, а не синтаксиса

В C malloc означает кучу, а локальная переменная — стек. В Go синтаксис ничего не значит: new(T), &T{} и обычный var t T означают одно — «мне нужен T», а где он будет жить, решает escape-анализ, доказательный проход на этапе компиляции. Про каждое значение компилятор спрашивает: может ли оно пережить кадр стека, который его создал? Если доказуемо нет — значение живёт на стеке: аллокация — сдвиг указателя кадра (практически бесплатно), освобождение — возврат из функции (буквально бесплатно). Если да — или если компилятор не может доказать, что нет, — значение убегает в кучу, где аллокатор размещает его (~25 нс для мелкого объекта по быстрому пути mcache), а сборщик мусора обязан его потом найти, пометить и подмести.

В этой асимметрии вся суть темы. Стековая аллокация не стоит ничего ни сейчас, ни потом. Heap-аллокация стоит немного сейчас и — именно это показывают профили аллокаций — повторяющийся налог потом: каждый живой байт кучи удлиняет следующий цикл GC, а каждый выделенный байт приближает его. Компилятор покажет свои рассуждения, если спросить:

// $ go build -gcflags=-m ./... 2>&1 | grep -E 'escapes|moved'
//
// ./handler.go:14:6: moved to heap: user        ← &user переживает кадр
// ./handler.go:22:13: id escapes to heap        ← передан в fmt (interface)
// ./handler.go:30:12: make([]byte, n) escapes   ← размер неизвестен при компиляции
// ./handler.go:41:9: func literal escapes       ← замыкание хранится дольше вызова

func newUser(name string) *User {
	user := User{Name: name} // "moved to heap": указатель возвращается,
	return &user             // значит значение обязано пережить этот кадр
}

Запускайте -gcflags=-m (дважды, -m -m, для подробных доказательств) на горячих пакетах. Вывод читается как судебное решение: не «это медленно», а «вот почему я не смог оставить это на стеке».

Что вызывает escape — пять главных подозреваемых

Escape-анализ консервативен: всё, что нельзя доказать локальным, уходит в кучу. На практике почти все escape в сервисном коде дают пять паттернов:

  1. Возврат указателя на локальную переменную. return &user — кадр умирает, значение не может. Это нормально и идиоматично: конструкторы и должны аллоцировать. Расточительство — делать это на каждой итерации горячего цикла.
  2. Помещение в interface. fmt.Println(id), logger.Info("n", anyValue) — преобразование конкретного значения в interface{} обычно аллоцирует его в куче: вызываемая сторона получает непрозрачную коробку, сквозь которую компилятор не видит. Это баг из пролога — и самый частый случайный escape в кодовых базах Go.
  3. Захват замыканием по ссылке. Литерал func(){ ... }, который захватывает локальную переменную и сам сохраняется, возвращается или передаётся в go, — захваченная переменная переезжает в кучу, чтобы оба кадра могли её разделить.
  4. Размер неизвестен на этапе компиляции. make([]byte, n) с рантаймовым n убегает; make([]byte, 4096) с константой может остаться на стеке (неявные аллокации стекопригодны до 64 КБ). Одинаковая форма кода — разное доказательство.
  5. Передача указателей через границы горутин. Значение, отправленное в канал или записанное в поле структуры, переживающей вызов, по определению уже убежало.

Вместе паттерны 2 и 3 — преобразования в interface и замыкания — дают подавляющее большинство случайных escape в сервисном коде; уберите эти два, и большинство горячих путей никогда не коснётся кучи.

Вердикт меняется и от инлайнинга: маленькая функция, встроенная в вызывающую, может позволить компилятору заново доказать локальность и отменить escape. Поэтому поведение escape может сдвигаться между версиями Go — изменилось доказательство, а не ваш код.

Викторина

Вы передаёте локальный int в fmt.Println, и -gcflags=-m сообщает, что он убегает в кучу. Каков фактический механизм?

Рост стека: почему 2 КБ не переполняются

Escape-анализ был бы бесполезен, если бы стеки не вмещали реальную нагрузку. Горутина стартует с ~2 КБ, и пролог каждой функции проверяет, помещается ли кадр. При переполнении рантайм не падает — он выделяет стек вдвое больше, копирует старый и переписывает каждый указатель, который указывал внутрь него (это возможно, потому что компилятор точно знает, какие слова — указатели). Старый сегмент освобождается; горутина продолжает, ничего не заметив. Стеки ещё и сжимаются: при GC горутине, использующей четверть стека, его урезают вдвое. Модель стоимости: рост — это копирование, O(размер стека), оплачиваемое редко и амортизированно почти бесплатно; патологический случай — паттерн вызовов, осциллирующий через границу роста и платящий за копирование снова и снова. Поэтому глубокая рекурсия на горячем пути в Go небесплатна, даже если ничего не падает: каждое удвоение от 2 КБ до, скажем, 1 МБ — это десять копирований. Отсюда же дешевизна обещания стековой аллокации: рантайм всегда может переместить стек, но никогда не может переместить кучу (об этом в уроке про GC — куча Go не компактируется).

Честный прайс-лист

Когда видите «GC тормозит» — спросите себя: тормозит из-за чего? Прежде чем касаться GOGC, посмотрите на скорость аллокаций — именно об этом раздел ниже.

Числа, которые стоит держать в голове: мелкая heap-аллокация через per-P mcache — примерно 20–40 нс; стековая — ~0 нс (кадр и так существует). Один escape — ничто. Сервисы убивает скорость: при 2 ГБ/мин аллокаций пейсер ставит циклы GC впритык, mark assist начинает напрямую облагать налогом аллоцирующие горутины, и внезапно «GC тормозит» — хотя честная формулировка: «мы аллоцируем 30 миллионов раз в минуту». Порядок рычагов на горячем пути: (1) не аллоцировать (вычистить escape через -m), (2) аллоцировать один раз и переиспользовать (sync.Pool, слайсы с заранее заданной ёмкостью, strconv.Append* в существующие буферы), (3) и только потом думать о ручках GC. Профиль аллокаций (pprof -sample_index=alloc_space) ранжирует, откуда именно берутся байты, — команда из пролога нашла ответ на одном экране.

Викторина

Рекурсия горутины выросла за пределы её текущего стека в 2 КБ. Что делает рантайм?

Вспомните перед уходом
  1. 01
    Назовите пять типичных паттернов, выталкивающих значение в кучу, и инструмент, показывающий рассуждения компилятора.
  2. 02
    Почему настоящая цена heap-аллокации — не ~25 нс malloc, и каков порядок рычагов для аллокационно-тяжёлого горячего пути?
Итог

Go выбирает между стеком и кучей доказательством, а не синтаксисом: escape-анализ спрашивает, может ли значение пережить свой кадр, и всё недоказуемо локальное уходит в кучу. Стековая аллокация — арифметика указателя кадра, освобождаемая возвратом; heap-аллокация стоит 20–40 нс по быстрому пути mcache и затем повторяющийся налог GC — больше живых байт для маркировки, больше выделенных байт, приближающих следующий цикл. Пять escape, доминирующих в реальных кодовых базах: возвращённые указатели на локальные, преобразования в interface (прежде всего fmt и структурные логгеры), замыкания с захватом по ссылке дольше вызова, make с рантаймовым размером (константы до 64 КБ остаются стекопригодными) и указатели, пересекающие границы горутин и долгоживущих структур. Каждый вердикт компилятор публикует через -gcflags=-m, а инлайнинг может перевернуть вердикт между релизами. Стеки стартуют с ~2 КБ и растут схемой «выделить вдвое — скопировать — переписать указатели» (непрерывные стеки, выбранные вместо сегментированных ради избавления от hot split) и сжимаются при GC, так что глубокая рекурсия платит за копирования, даже когда ничего не падает. Когда GC ест много CPU, честный диагноз обычно читается из профиля аллокаций: команда из пролога срезала GC с 31% до 9% не тюнингом, а удалением fmt.Sprintf с горячего пути. Сначала не аллоцировать, потом переиспользовать, и лишь изредка — тюнить. Теперь, когда встретите рост GC CPU, ваш первый шаг — -gcflags=-m на горячем пакете и профиль alloc_space, а не ручки GOGC.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.