Модуль: Runtime и память · Уровень: Senior+

TL;DR#

Escape analysis — это анализ компилятора, определяющий, переживёт ли значение функцию, в которой оно создано. Если переживёт (или компилятор не может доказать обратное), значение размещается в куче; иначе — на стеке. Анализ консервативен: при неопределённости выбирается куча. Смотреть решения можно через go build -gcflags="-m" (или -m -m для подробностей). Типовые причины escape: возврат указателя, попадание значения в интерфейс, замыкания, захватывающие переменную по ссылке, слишком большой объект, отправка указателя в канал, передача в fmt.Println (через any).

Теория#

Зачем нужен escape analysis#

Цель — максимально много разместить на стеке, потому что стек дешёв и не нагружает GC. Анализ строит граф «потока» значений и адресов: куда утекает адрес переменной. Если адрес «вытекает» за границу функции (returns, globals, heap, interface, channel), переменная escapes to heap.

Принцип консервативности: компилятор обязан отправить в кучу всё, в безопасности чего на стеке он не уверен. Ложно-положительные escape (лишние аллокации) допустимы; ложно-отрицательные (use-after-free) — недопустимы.

Как смотреть#

# базовый отчёт о решениях
go build -gcflags="-m" ./...

# подробнее: ПОЧЕМУ принято решение (цепочки)
go build -gcflags="-m -m" ./...

# заодно показать решения по инлайнингу
go build -gcflags="-m -m" ./... 2>&1 | grep -E "escapes|inlin|leaking|moved"

Пример:

package demo

func newInt() *int {
    x := 0      // ./demo.go:4:2: moved to heap: x
    return &x   // &x убегает через return
}

func localOnly() int {
    y := 42     // остаётся на стеке — адрес не берётся
    return y
}
$ go build -gcflags="-m" demo.go
./demo.go:4:2: moved to heap: x

Сообщения, которые надо уметь читать:

СообщениеЧто значит
moved to heap: xПеременная x размещена в куче (escape)
&x escapes to heapАдрес x утёк, поэтому x в куче
x escapes to heapЗначение убегает (часто через интерфейс)
... argument does not escapeПараметр не утекает — отлично
leaking param: pПараметр p утекает (его адрес/содержимое уходит наружу)
leaking param content: pУтекает то, на что указывает p (одноуровнево)
can inline f / inlining call to fФункция инлайнится

Типовые причины escape#

1. Возврат указателя на локальную переменную#

func make() *T { t := T{}; return &t } // t moved to heap

Классика: адрес переживает функцию.

2. Сохранение в интерфейс / any#

func boxed() {
    x := 42
    var i any = x   // x escapes: интерфейс хранит указатель на данные
    _ = i
}

Интерфейс — это пара (type, data ptr). Чтобы положить значение в any, нужен адрес → значение убегает. Это причина, почему fmt.Println(x) часто вызывает escape: сигнатура Println(a ...any) боксирует аргументы.

func logIt() {
    n := 1234
    fmt.Println(n) // n escapes to heap (упаковка в any для variadic)
}

3. Замыкания, захватывающие по ссылке#

func counter() func() int {
    c := 0           // c moved to heap
    return func() int {
        c++          // замыкание держит ссылку на c
        return c
    }
}

Если захваченная переменная модифицируется и/или замыкание переживает функцию, переменная едет в кучу (становится частью closure-объекта). Захват только для чтения внутри той же функции может остаться на стеке.

4. Слишком большой объект#

func big() {
    var buf [1 << 20]byte // > порога → moved to heap
    _ = buf
}

Объекты больше внутреннего лимита (исторически ~64 КБ для стек-объектов) принудительно идут в кучу, чтобы не раздувать фрейм.

5. Slice, растущий через append (неизвестный размер)#

func grow(n int) []int {
    s := make([]int, 0)   // если n неизвестен на этапе компиляции →
    for i := 0; i < n; i++ {
        s = append(s, i)  // backing array escapes to heap
    }
    return s
}

Если размер make не константа или slice возвращается/убегает, backing array идёт в кучу. make([]int, 16) с константой и без утечки может остаться на стеке.

6. Отправка указателя в канал#

func send(ch chan *T) {
    t := &T{}   // escapes: другая горутина может прочитать после выхода
    ch <- t
}

Канал переживает функцию, значит указатель доступен другим горутинам неопределённое время → куча.

7. Сохранение в глобальную переменную / поле, живущее дольше#

var sink *int
func leak() { x := 1; sink = &x } // x moved to heap

8. Адрес, переданный в функцию, которая «leaks param»#

Если вызываемая функция помечена leaking param, передача туда адреса локала заставит его убежать. Если же та функция гарантированно не утекает параметр (does not escape), вызов безопасен для стека.

Leaking param: уровни утечки#

// leaking param: p   → сам указатель p утекает (например, сохраняется или возвращается)
func keep(p *int) *int { return p }

// leaking param content: p → утекает разыменованное содержимое
func store(p *[]int, v int) { *p = append(*p, v) }

// p does not escape → лучший случай, аргумент остаётся на стороне вызывающего
func read(p *int) int { return *p }

Эта аннотация — часть межпроцедурного анализа: escape analysis смотрит, как функция обращается с параметрами, и кэширует «escape-сводку» (summary) функции, чтобы вызывающие могли принять решение, не залезая внутрь.

Связь с инлайнингом#

Инлайнинг и escape analysis работают в паре и усиливают друг друга:

func get() *int { x := 0; return &x }   // в отрыве — x escapes

func caller() int {
    p := get()   // если get() заинлайнится, тело раскроется в caller,
    return *p    // и компилятор увидит, что адрес НЕ покидает caller → стек!
}

После инлайнинга get() его x оказывается локальным для caller, адрес не убегает наружу — escape устраняется. Поэтому:

  • Инлайнинг происходит до/во взаимодействии с escape analysis в SSA-конвейере.
  • Маленькие функции (бюджет инлайнинга, budget) лучше для устранения аллокаций.
  • //go:noinline может внезапно добавить аллокации, ломая эту оптимизацию.
# увидеть и инлайнинг, и escape вместе
go build -gcflags="-m -m" ./... 2>&1 | grep -E "inlin|escape|heap"

Оптимизации, помогающие избежать escape#

// Плохо: возвращаем указатель → escape
func New() *Config { return &Config{Timeout: 30} }

// Лучше для горячего пути: вернуть значение (копия на стек вызывающего)
func New() Config { return Config{Timeout: 30} }

// Переиспользование буфера вместо аллокации на каждый вызов
var bufPool = sync.Pool{New: func() any { return new([4096]byte) }}

// Передавать []byte и писать в него, а не возвращать новый slice
func Format(dst []byte, x int) []byte { return strconv.AppendInt(dst, int64(x), 10) }

Приёмы: возврат значения вместо указателя на горячем пути; sync.Pool для переиспользования; AppendX-функции вместо аллокации; избегание any/interface{} в hot loop; предвыделение slice с константной ёмкостью; не передавать в fmt/логгер в горячем цикле (или использовать типизированные методы вроде zap без рефлексии).

Подводные камни / gotchas#

  • -m показывает решения, а не факт аллокации. Финальный факт — это -benchmem (allocs/op) и pprof. Всегда подтверждайте бенчмарком.
  • fmt.Println(x) боксирует. Любая передача в ...any упаковывает аргумент → escape. В горячем коде это ловушка для аллокаций.
  • Замыкание ≠ всегда escape. Захват по чтению, не переживающий функцию (например, в for ... { go ... } без выхода), может остаться на стеке; модификация или переживание функции — escape.
  • make без константного размера → куча. make([]int, n) с переменным n почти всегда escape, даже если slice локальный.
  • Возврат интерфейса прячет escape. func() error { return &myErr{} } — указатель в интерфейсе убегает, причём это часто незаметно.
  • //go:noinline и границы пакета мешают анализу. Escape analysis межпроцедурный, но опирается на summary; вызовы через интерфейс (динамическая диспетчеризация) и noinline ухудшают вывод и провоцируют consервативный escape.
  • Указатель на элемент slice/map. Взятие адреса элемента может заставить весь backing array убежать.
  • new(T) сам по себе не значит куча. new лишь даёт zero-value и адрес; если адрес не убегает, объект на стеке.

Вопросы на собеседовании#

В: Что такое escape analysis и почему он консервативен? О: Это статический анализ компилятора, определяющий, переживёт ли значение свою функцию. Если адрес значения утекает наружу (return, глобал, интерфейс, канал, замыкание), значение размещается в куче. Консервативность означает: при невозможности доказать безопасность стека выбирается куча. Лишняя аллокация — приемлемая цена; use-after-free — нет.

В: Почему var i any = x вызывает escape переменной x? О: Интерфейсное значение — это пара (тип, указатель на данные). Чтобы положить x в any, нужно хранить указатель на его данные, а время жизни интерфейса компилятору неизвестно. Поэтому x перемещается в кучу. Это же объясняет, почему fmt.Println(x) (сигнатура ...any) часто аллоцирует.

В: Как инлайнинг влияет на escape analysis? Приведите пример. О: Инлайнинг раскрывает тело вызываемой функции в месте вызова, после чего её локальные переменные становятся локальными для вызывающего. Если в отрыве функция «возвращала указатель» (escape), то после инлайнинга компилятор может увидеть, что адрес не покидает вызывающего, и оставить значение на стеке. Поэтому маленькие инлайнируемые функции дают меньше аллокаций, а //go:noinline может их добавить.

В: Что означают leaking param, leaking param content и does not escape? О: does not escape — параметр не утекает, аргумент может остаться на стеке вызывающего (лучший случай). leaking param: p — сам параметр-указатель утекает (сохраняется/возвращается). leaking param content: p — утекает то, на что указывает p (одноуровнево). Эти сводки — основа межпроцедурного анализа.

В: Почему make([]int, n) с переменным n почти всегда уходит в кучу, а make([]int, 16) — нет? О: При константном размере и отсутствии утечки компилятор знает точный размер фрейма и может разместить backing array на стеке. При неизвестном на этапе компиляции n размер фрейма неопределён, и безопасно разместить можно только в куче. Также если slice возвращается/убегает — всегда куча.

В: Как достоверно проверить, что код не аллоцирует? О: go build -gcflags="-m" показывает решения компилятора, но окончательное подтверждение — бенчмарк с -benchmem и метрикой allocs/op, плюс testing.AllocsPerRun или профиль -memprofile/pprof. Флаги дают намерение, бенчмарк — факт.

В: Замыкание всегда вызывает escape захваченных переменных? О: Нет. Если замыкание не переживает функцию и переменная захвачена так, что компилятор доказывает её локальность, она может остаться на стеке. Escape наступает, когда замыкание возвращается/сохраняется или переживает создающую функцию, либо переменная модифицируется и разделяется — тогда она становится частью closure-объекта в куче.

В: Назовите способы уменьшить аллокации в горячем коде. О: Возвращать значения вместо указателей; избегать боксинга в any/interface{} (не использовать fmt в hot loop); sync.Pool для переиспользования буферов; AppendX-API вместо новых slice; предвыделять slice/map с известной ёмкостью; держать функции маленькими для инлайнинга; избегать ненужного взятия адреса элементов slice/map; типизированные логгеры без рефлексии.

На что копают на senior+#

  • Чтение -m -m: умение проследить цепочку «почему именно escape» (flow:, from ... (...)), а не просто констатировать факт.
  • Внутренняя модель: escape analysis как анализ на SSA с построением графа location/flow, escape summary функций, fixed-point итерация.
  • Граница с GC: понимание, что меньше escape → меньше работа GC → ниже GC CPU и паузы; связь с GOGC/GOMEMLIMIT.
  • Динамическая диспетчеризация: почему вызовы через интерфейс и рефлексия ухудшают анализ (нет статической цели → консервативный escape), и как девиртуализация/-d=ssa помогает.
  • Практика: testing.AllocsPerRun, -benchmem, профиль alloc_space/inuse_space, чтение pprof -alloc_objects, поиск «горячих» аллокаций.
  • Тонкости: порог размера для stack-объектов, поведение sync.Pool под GC (очистка пула на каждом цикле GC), почему Pool не панацея и иногда хуже простого стека.
  • Самостоятельная проверка гипотезы: построить минимальный пример, прогнать -gcflags="-m -m" + бенчмарк, объяснить расхождение между намерением компилятора и фактическими allocs/op.