voidlearn
 

3.1Стек и куча, escape analysis

Разберём escape analysis как решение компилятора о времени жизни. Научимся проверять его диагностиками и измерять эффект, не превращая placement в обещание API.

Две области памяти и почему они разные

У каждой горутины свой приватный стек, стопка кадров вызовов. Он линейный: регистр-указатель SP (stack pointer) показывает на вершину стопки, вызов функции сдвигает его на размер кадра, возврат сдвигает обратно. Учитывать занятое, искать свободное место, что-то освобождать не нужно: кадр просто перестаёт существовать. Поэтому стек и «дешёвый»: кадр выделяется и освобождается одной арифметической операцией над регистром, а она уже вшита в пролог и эпилог функции, которые компилятор генерирует всё равно. Стек не «быстрая память», процессор ничего про него не знает; бесплатно тут освобождение.

Куча у процесса одна, общая на всех. Сюда попадает всё, что должно пережить свой кадр: данные за указателями, объекты, на которые смотрят из нескольких горутин, всё, чей размер неизвестен на этапе компиляции. Стопки здесь нет, поэтому память приходится учитывать: кто-то должен помнить, какие куски заняты, находить свободное место под запрос и потом возвращать освободившееся. Этим занимается аллокатор, а вопрос «что уже не нужно» решает сборщик мусора: сама программа в Go память не освобождает.

Аллокатор Go устроен по образцу TCMalloc и похож на трёхуровневую систему складов. У каждого P (процессора планировщика) есть личный кэш mcache, из него берут без всяких блокировок: это быстрый путь, единицы наносекунд на мелкий объект. Если кэш опустел, аллокатор идёт в mcentral, общий склад на один размерный класс, а если пуст и он, то в mheap, который берёт у ОС целые страницы. Размерных классов 67, это фиксированные размеры (8, 16, 24, 32 байта и так далее до 32 КБ), и запрос округляется вверх до ближайшего: на 20 байт выдадут блок в 24, зато поиск свободного места сводится к «взять первый блок из списка нужного класса». Всё, что больше 32 КБ, выдаётся отдельно, напрямую из mheap. Внутри аллокатор оперирует спанами (mspan). Отдельно есть tiny-аллокатор: объекты меньше 16 байт без указателей внутри упаковываются по несколько штук в один 16-байтовый блок.

Но дороже всего в куче обходится не сама аллокация. Каждый объект в куче потом кто-то должен обойти, пометить как живой и в конце подмести. Аллокация в стеке не создаёт работы для сборщика вообще, а аллокация в куче создаёт её дважды: на маркировке и на sweep. Из этого складывается вся дальнейшая арифметика темы. Стек бесплатен на выходе, а куча платит трижды: при выдаче места, при обходе сборщиком и при уборке. Поэтому в отчёте бенчмарка колонка allocs/op часто важнее наносекунд.

Что поменялось в аллокаторе в свежих версиях
  • Go 1.27: мелкие аллокации дешевле, до 30 %. Раньше mallocgc был одной универсальной функцией: по запрошенному размеру она в рантайме вычисляла размерный класс, проверяла флаги, выбирала ветку. Теперь компилятор для объектов до 80 байт подставляет процедуру, специализированную под конкретный размерный класс: все эти вычисления и ветвления сделаны заранее, на этапе компиляции, и в рантайме остаётся почти только «взять блок из списка». Платим за это размером бинаря, он вырастает примерно на 60 КБ. Отключается GOEXPERIMENT=nosizespecializedmalloc.
  • Go 1.26: база кучи рандомизируется на 64-битных платформах, то есть куча при каждом запуске начинается с нового адреса. Сделано это ради безопасности, скорость тут ни при чём: эксплойту, который полагается на предсказуемые адреса объектов, становится сильно тяжелее. Есть побочный эффект: адреса в двух запусках больше не совпадают, так что логи вида 0xc000123456 между запусками не сравнишь. Отключается GOEXPERIMENT=norandomizedheapbase64.
Стек горутины свой у каждой горутины SP свободная часть стека parse() buf [64]byte n int, p *T handle() main() выделить кадр = SP -= const освободить кадр = SP += const &T{} убегает на кучу Куча одна на процесс, обслуживается GC живые объекты - GC их обходит и метит мусор - вернётся в spans на фазе sweep путь аллокации mcache mcentral mheap быстрый путь через mcache - без блокировок, единицы нс; но каждый объект добавляет работы сборщику. Стек дешевле не потому, что «эта память быстрее», а потому что освобождение бесплатно: кадр исчезает по return. У кучи цена размазана: поиск места при аллокации + обход объекта сборщиком + уборка на фазе sweep.
Стек и куча. Слева линейная область: всё в ней живёт ровно столько, сколько кадр. Справа общая область с учётом, аллокатором и сборщиком. Стрелка между ними и есть escape analysis.

Escape analysis: что это на самом деле

Escape analysis, статический анализ компилятора (cmd/compile/internal/escape), решает, где будет жить переменная. Пока у кого-то на руках есть её адрес, память освобождать нельзя, поэтому для каждой переменной компилятор пытается доказать: «ни одна ссылка на неё не переживёт текущий кадр». Если удалось, переменная кладётся на стек. Если нет, говорят, что она «убегает» (escapes), и её приходится класть в кучу. Без такого анализа в кучу пришлось бы класть всё подряд, и программа стала бы в разы медленнее.

Обрати внимание на асимметрию. Анализ консервативный: при малейшем сомнении он выбирает заведомо безопасный вариант, а безопасна тут куча. Объект в куче сработает всегда, просто дороже. До объекта на стеке кто-то может дотянуться после выхода из функции, и это уже порча памяти. Поэтому любая неопределённость (вызов через интерфейс или функцию-значение) решается в пользу кучи. Компилятор не спрашивает «убежит ли?», он спрашивает «могу ли я доказать, что не убежит?», и молчание засчитывается как «убежит».

Анализ межпроцедурный, но не «глобальный». Для каждой функции компилятор считает сводку по параметрам: какой параметр «утекает» в возвращаемое значение, какой «утекает» в кучу, какой не утекает вообще. Эти сводки пишутся в export data пакета, поэтому анализ работает и через границы пакетов, и по той же причине в выводе -m ты видишь строки вида leaking param: name.

Причина побегаПримерПочему
Возврат указателяreturn &User{}ссылка переживает кадр по определению
Запись указателя в кучуglobal = &x, obj.Field = &x, s = append(s, &x)объект в куче не может ссылаться на стек
Упаковка в интерфейсfmt.Println(x), var a any = xинтерфейс хранит указатель на данные; компилятор чаще всего не видит конкретного использования
Захват замыканием, которое переживает функциюgo func(){ use(x) }(), return func(){...}переменная переезжает в объект-замыкание в куче
Отправка в каналch <- &xчитатель - другая горутина с другим стеком
Неизвестный на компиляции размерmake([]byte, n), где n - переменнаяв кадре нельзя зарезервировать место под неизвестное число байт
Слишком большой объектvar buf [20 << 20]byte, make([]byte, 1<<20)лимиты компилятора: 128 КБ для явных переменных (до Go 1.24 было 10 МБ), 64 КБ для неявных (new, &T{}, литералы)
Непрозрачный вызоввызов через интерфейс или переменную-функциютела не видно - считаем, что аргументы утекают
Два мифа, на которых ловят
  • «new выделяет в куче, а литерал на стеке». Нет. new(T) и &T{} для компилятора одно и то же; оба могут остаться на стеке, если указатель не убегает.
  • «Большой объект всегда в куче». Не «большой», а «больше лимита компилятора»: 64 КБ для неявных аллокаций и 128 КБ для явно объявленных переменных. Массив на 60 КБ прекрасно живёт на стеке, просто стек ради него вырастет.

Как посмотреть решения компилятора

$ go build -gcflags="-m" ./...          # решения по своим пакетам
$ go build -gcflags="-m -m" ./...       # второй уровень: с объяснением цепочки
$ go build -gcflags="all=-m" ./...      # включая зависимости
$ go build -gcflags="-m -l" ./...       # -l отключает инлайн: видно решения "как есть"
$ go test  -gcflags="-m" -bench=. ./pkg # то же для тестов и бенчмарков
package main

import (
    "fmt"
    "os"
)

type User struct {
    Name string
    Age  int
}

func newUser(name string) *User {
    return &User{Name: name}
}

func sum(vals []int) int {
    s := 0
    for _, v := range vals { s += v }
    return s
}

func readSize() int { return len(os.Args) * 1024 }   // размер известен только в рантайме

func main() {
    u := newUser("bob")
    fmt.Println(u.Age)
    buf := make([]byte, 1024)
    _ = buf
    n := readSize()
    big := make([]byte, n)
    _ = big
    _ = sum([]int{1, 2, 3})
}

Вот что компилятор печатает на самом деле. Вывод снят командой go build -gcflags="-m" . на go1.27, идёт в stderr и приведён целиком:

$ go build -gcflags="-m" .
# esc
./m.go:13:6: can inline newUser
./m.go:17:6: can inline sum
./m.go:23:6: can inline readSize
./m.go:26:14: inlining call to newUser
./m.go:27:13: inlining call to fmt.Println
./m.go:30:15: inlining call to readSize
./m.go:33:9: inlining call to sum
./m.go:13:14: leaking param: name
./m.go:14:9: &User{...} escapes to heap
./m.go:17:10: vals does not escape
./m.go:26:14: &User{...} does not escape
./m.go:27:13: ... argument does not escape
./m.go:27:15: u.Age escapes to heap
./m.go:28:13: make([]byte, 1024) does not escape
./m.go:31:13: make([]byte, n) does not escape
./m.go:33:15: []int{...} does not escape

Три строки здесь ломают привычные ожидания.

  • Про &User{} написано и «escapes to heap», и «does not escape». Первая строка (14:9) относится к телу newUser, где указатель уходит в возврат. Вторая (26:14) описывает то же выражение после подстановки функции в main: там уже видно, что u дальше вызова не живёт, и объект остаётся на стеке. Итог решает вторая строка, поэтому читай весь вывод, а не первую попавшуюся строку про нужное выражение. А -l нужен ровно для обратного: посмотреть, что было бы без инлайна.
  • u.Age escapes to heap: убегает не структура, а int, который упаковывается в any для fmt.Println. В реальном коде это самый частый «неожиданный» побег.
  • make([]byte, n) does not escape и есть ловушка: такое сообщение escape analysis не означает «на стеке». Размер неизвестен на компиляции, поэтому на стеке есть только запасной буфер в 32 байта (с Go 1.25), а всё, что больше, берётся в куче. Бенчмарк на go1.27 (size() помечена //go:noinline) показывает разницу прямо: make([]byte, 64) даёт 0 B/op и 0 allocs/op, а make([]byte, size(64)) даёт 64 B/op и 1 allocs/op.
Строка в выводеЧто означает
escapes to heapвыражение (обычно &T{}, new, make) аллоцируется в куче
moved to heap: xобычная локальная переменная перенесена в кучу - на неё взяли адрес, который убегает
does not escapeуказатель наружу не уходит. Обычно означает стек - но при неизвестном на компиляции размере (make([]byte, n)) больше 32 байт память всё равно берётся в куче
leaking param: pпараметр утекает наружу (например, попадает в возвращаемое значение или в глобал)
leaking param content: pутекает не сам указатель, а то, на что он указывает
parameter p leaks to ~r0 for f with derefs=0вывод -m -m: параметр уходит в первый результат
Дисциплина чтения -m

-gcflags="-m" без all= печатает только про пакеты из аргумента. И ещё: картину меняет инлайнинг (подробно в главе 3.4). Функция, чей аргумент «утекал», после подстановки может оказаться полностью на стеке. Поэтому смотри на итоговый вывод без -l, а -l включай, только чтобы понять, что происходит без инлайна.

Можно ли повлиять на решение

Директивы «положи это на стек» в Go нет и не будет, так решили сознательно: место аллокации считается деталью реализации. Влиять можно только формой кода.

// убегает: возвращаем указатель
func Parse(b []byte) (*Msg, error) {
    m := &Msg{}
    if err := m.decode(b); err != nil {
        return nil, err
    }
    return m, nil
}

// убегает: строка-результат уходит вызывающему (0–99 Itoa берёт из таблицы)
func Format(v int) string {
    return strconv.Itoa(v)
}

// убегает: интерфейс
log.Printf("id=%d", id)
// не убегает: пишем в буфер вызывающего
func Parse(b []byte, m *Msg) error {
    return m.decode(b)
}

// не убегает: дописываем в чужой буфер
func Format(dst []byte, v int) []byte {
    return strconv.AppendInt(dst, int64(v), 10)
}

// дешевле: на выключенном уровне аргументы не боксятся
if lg.Enabled(ctx, slog.LevelDebug) {
    lg.Debug("msg", "id", id)
}
  • Главный приём: отдавать буфер снаружи, то есть func(dst []byte, ...) []byte вместо func(...) []byte. Так устроены strconv.Append*, time.Time.AppendFormat, fmt.Appendf (Go 1.19).
  • Избегать any в горячем пути. Когда кладёшь значение в any, происходит боксинг: интерфейс хранит пару «тип + указатель на данные», поэтому само значение должно где-то лежать, и если оно убегает, то уезжает в кучу. На мелочи компилятор экономит (пустые структуры, маленькие целые берутся из готовой таблицы рантайма), но в общем случае каждый any-аргумент становится кандидатом в аллокацию. Дженерики здесь помогают: параметр типа не превращается в интерфейс для типов с одинаковой «GC-формой».
  • Не брать адрес там, где не нужно: метод с указательным receiver у локальной переменной может утащить её в кучу, если сам метод «пропускает» receiver наружу.
  • //go:noescape придуман не для обычного кода: это обещание компилятору про ассемблерную функцию (объявление без тела), что она не сохраняет переданные указатели. Соврёшь, и получишь порчу памяти.
  • sync.Pool не отменяет побег: объект всё равно в куче, просто он переиспользуется и не создаёт нового мусора.

Возврат структуры: по значению или по указателю

Здесь «зависит» и есть точный ответ, потому что в разные стороны тянут три силы.

  1. Копирование. Возврат по значению копирует структуру. Но с Go 1.17 на amd64 (с Go 1.18 и на arm64) работает register-based ABI: на amd64 до девяти целочисленных слов и пятнадцати float-регистров (на arm64 по шестнадцать) передаются в регистрах, без записи в память вообще. Структура на 2–4 поля возвращается практически бесплатно.
  2. Аллокация. Возврат указателя почти всегда означает escapes to heap: mallocgc плюс будущая работа сборщика. Счёт идёт на единицы наносекунд плюс амортизированная стоимость GC.
  3. Работа GC потом. []T из структур без указателей сборщик не сканирует вообще (спан помечен как noscan). []*T на миллион элементов означает миллион объектов, которые надо обходить каждый цикл.
Практическое правило

Маленькую структуру (примерно до 4–6 слов, то есть 32–48 байт), которую не надо мутировать, возвращай по значению. Большую, мутируемую или обязанную быть общей возвращай по указателю. Если у типа уже есть методы с указательным receiver, возвращай указатель, иначе получишь несогласованный API. Обычно же разница тонет в работе самой функции: в горячих путях измеряй, в остальных выбирай по читаемости.

Стек горутины: как он растёт

Горутина стартует с маленьким стеком: stackMin = 2 КБ. С Go 1.19 рантайм умнее: при сканировании стеков в GC он считает, сколько стека в среднем занято у всех горутин, и новым сразу выделяет столько, чтобы не платить за первые расширения.

Пролог почти каждой функции (кроме крошечных листовых и помеченных //go:nosplit) сравнивает SP с полем stackguard0 в структуре g. Если места не хватает, вызывается runtime.morestack, а тот зовёт newstack:

  1. выделяется новый стек как минимум вдвое больше старого (удваивается, пока не влезет новый кадр);
  2. содержимое копируется одним memmove;
  3. рантайм проходит по кадрам и чинит все указатели, которые указывали внутрь старого стека, а где они лежат, узнаёт по картам указателей, которые компилятор сгенерировал для каждой точки вызова;
  4. старый стек возвращается в пул.

Обратную операцию, shrinkstack, рантайм выполняет во время GC: если горутина использует меньше четверти своего стека, он ужимается вдвое. Потолок задаёт maxstacksize: 1 ГБ на 64-битных платформах (250 МБ на 32-битных), настраивается через debug.SetMaxStack. Превышение даёт fatal error: stack overflow, и это не паника: recover её не поймает, процесс умирает.

Стек не расширяется на месте - он переезжает целиком старый стек 2 КБ main handle parse нет места → пролог зовёт runtime.morestack указатель внутрь стека memmove: кадры копируются как есть и рантайм правит каждый указатель, который смотрел внутрь старого стека новый стек 4 КБ main handle parse свободно - места вдвое больше адрес переписан на новый Почему так можно в Go: компилятор строит точную карту указателей для каждого кадра, поэтому рантайм находит и правит все ссылки внутрь стека. В C указатель неотличим от целого числа - переместить стек нельзя, отсюда фиксированные 8 МБ на поток.
Copy stack. Чтобы расширить стек, рантайм выделяет новый блок, делает memmove и правит указатели по stack maps. Цена амортизируется удвоением, поэтому «дорогих» переездов за жизнь горутины единицы.
Связь, которую редко называют вслух

Кроме оптимизации, у escape analysis есть вторая роль: на нём держится корректность перемещаемых стеков. Рантайм при переезде чинит указатели только внутри стеков и регистров. Если бы указатель на стековую переменную мог оказаться в куче, чинить его было бы негде, и после переезда он смотрел бы в освобождённую память. Поэтому всё, до чего можно добраться из кучи, обязано быть в куче: анализ не «хотел бы», а должен вытолкнуть такую переменную. Скажешь это на собесе, и сразу станет видно, что ты понимаешь механизм, а не выучил список правил.

Вопросы

6
Суть: стек - приватная линейная область горутины, выделение = сдвиг регистра и освобождение бесплатное; куча - общая область с аллокатором и сборщиком, где каждый объект оплачивается дважды: при выделении и при последующем обходе GC.

Стек

У каждой горутины свой стек (стартует с 2 КБ в Go 1.4+ и растёт удвоением до maxstacksize, по умолчанию 1 ГБ на 64-битных). Память под кадр функции резервируется в прологе одним вычитанием из указателя стека, освобождается в эпилоге одним сложением. Списков свободных блоков, поиска подходящего размера и блокировок здесь нет: стек не разделяется между горутинами. Данные кадра почти наверняка горячие в L1: адреса, к которым только что обращались.

Куча

Куча одна на процесс и устроена как TCMalloc. Путь аллокации: mcache (локальный кэш текущего P, без блокировок) → mcentral (общий на размерный класс, наборы спанов в нём lock-free) → mheap (страницы, глобальный лок) → mmap у ОС. Объекты раскладываются по 67 размерным классам до 32 КБ, всё крупнее идёт как large object напрямую из mheap (под него отведён нулевой класс, поэтому в исходниках NumSizeClasses = 68). Плюс tiny-аллокатор: несколько объектов меньше 16 байт без указателей упаковываются в один блок. Свежая деталь на добивку: с Go 1.27 мелкие аллокации (до 80 байт) идут через процедуры, специализированные по размерному классу на этапе компиляции, и это экономит до 30 % на самом горячем пути аллокатора.

Что где оказывается

  • Всегда на стеке: аргументы, возвращаемые значения (часто вообще в регистрах благодаря register ABI с Go 1.17) и локальные переменные, если компилятор доказал их «неутечку». Параметр, чей адрес убегает, компилятор переносит в кучу, как и локальную переменную.
  • Всегда в куче: глобальные переменные (технически они в сегменте данных, но сканируются как корни), всё, на что ссылаются из кучи, всё непрозрачного размера, кроме мелочи: с Go 1.25 слайс переменного размера до 32 байт остаётся на стеке.
  • Решает компилятор: всё остальное, этим и занимается escape analysis. При этом new() и &T{} не гарантируют кучу, а литерал структуры не гарантирует стек.

Почему «дешевле» - по пунктам

  1. Выделение: одна арифметическая операция против mallocgc с поиском размерного класса, проверкой кэша и (иногда) с уходом в mcentral. С Go 1.27 этот разрыв немного сократился: для объектов до 80 байт компилятор подставляет процедуру, специализированную под конкретный размерный класс, и мелкая аллокация дешевеет до 30 % (ценой ~60 КБ размера бинаря).
  2. Освобождение: бесплатно и мгновенно, против отложенной работы sweeper-а.
  3. Работа GC: стек сканируется один раз за цикл целиком, объекты в куче обходятся по графу.
  4. Локальность: кадры лежат подряд, объекты в куче разбросаны, отсюда больше промахов кэша.
Фраза, после которой вопрос закрывается

«Разница не столько в скорости самой аллокации: mallocgc из mcache тоже быстрый, единицы наносекунд. Разница в том, что объект в куче создаёт работу в будущем: его надо промаркировать в следующем цикле GC и подмести. Стековая переменная для сборщика вообще ничего не создаёт. Поэтому в горячем пути смотрят не на время аллокации, а на allocs/op.»

Суть: статический анализ компилятора, который пытается доказать, что ни одна ссылка на переменную не переживёт кадр функции. Доказал - стек; не смог - куча. Анализ консервативный: любое сомнение решается в пользу кучи.

Формально компилятор строит граф «кто на кого может ссылаться»: вершины - переменные и аллокации, рёбра - присваивания с весом derefs (число разыменований минус взятий адреса). Затем ищет пути, по которым адрес попадает в кучу или переживает свою переменную. Для каждой функции получается сводка по параметрам, которая уезжает в export data пакета, поэтому анализ работает и через границы пакетов. Отсюда строки вида leaking param: name в выводе -m.

Правила побега (в порядке частоты)

  1. Возврат указателя на локальную переменную, например return &User{}. Ссылка по определению живёт дольше кадра.
  2. Запись указателя в объект, который сам в куче: global = &x, s = append(s, &x), obj.F = &x. Рантайм держит инвариант: из кучи нельзя ссылаться на стек, иначе перемещение стека всё сломает.
  3. Упаковка в интерфейс: fmt.Println(x), var a any = x. Интерфейс хранит указатель на данные, а метод вызывается непрозрачно, значит, компилятор обязан считать, что данные утекают. Исключение бывает, когда компилятор девиртуализует вызов и видит тело.
  4. Захват замыканием, которое переживает функцию, как в go func(){ use(x) }() или return func(){...}. Объект-замыкание уезжает в кучу; мелкие неизменяемые переменные копируются в него, а те, что замыкание меняет, переезжают в кучу отдельно.
  5. Отправка в канал (ch <- &x): читатель сидит на другом стеке.
  6. Неизвестный на компиляции размер: make([]byte, n) с переменной n, make(map[K]V, n). В кадре нельзя зарезервировать «сколько-нибудь», поэтому на стеке есть только запас на мелкий случай: 32 байта у слайса (с Go 1.25), одна группа на 8 элементов у мапы.
  7. Слишком большой объект. У компилятора есть лимиты: 128 КБ (до Go 1.24 было 10 МБ) для явных локальных переменных и 64 КБ для неявных аллокаций (new, &T{}, литералы, make с константой).
  8. Непрозрачный вызов через переменную-функцию или интерфейс: тела не видно, поэтому считаем, что параметры утекают. Рекурсия к этому не относится: рекурсивные функции компилятор анализирует вместе и видит их тела, так что параметры вполне могут не утекать.
func a() *int   { x := 1; return &x }        // moved to heap: x
func b()        { x := 1; sink = &x }        // moved to heap: x
func c(v int)   { fmt.Println(v) }            // v escapes to heap (интерфейс)
func d() func() { x := 1; return func(){ x++ } } // moved to heap: x + func literal escapes to heap
func e(n int)   { _ = make([]byte, n) }       // does not escape: до 32 байт на стеке (Go 1.25+), больше - куча
func f()        { _ = make([]byte, 4096) }    // does not escape: константа, влезает в кадр
func g(p *T)    { p.X = 1 }                   // p does not escape: только читаем/пишем поля

// go build -gcflags="-m -l", вывод go1.27 дословно:
//   ./m.go:9:19: moved to heap: x
//   ./m.go:10:19: moved to heap: x
//   ./m.go:11:30: ... argument does not escape
//   ./m.go:11:31: v escapes to heap
//   ./m.go:12:19: moved to heap: x
//   ./m.go:12:34: func literal escapes to heap
//   ./m.go:13:27: make([]byte, n) does not escape
//   ./m.go:14:27: make([]byte, 4096) does not escape
//   ./m.go:15:8: p does not escape
//
// Про e(): «does not escape» тут значит только «указатель наружу не
// уходит». Размер n на компиляции неизвестен, кадр под него заранее
// не зарезервируешь. С Go 1.25 компилятор держит на стеке буфер в 32 байта
// и идёт в кучу, только если n больше. Целиком на стеке живёт f() с константой.
// Проверено на go1.27: e(16) - 0 аллокаций, e(64) - одна.
Три подвоха

«Указатель = куча». Нет: func g(p *T) выше не вызывает аллокации, вызывающий может держать T на своём стеке. Передать вниз по стеку указатель на стековую переменную в Go совершенно нормально.

«Escape analysis нужен ради производительности». Ради неё тоже, но ещё это условие корректности перемещаемых стеков: если бы указатель на стек мог осесть в куче, при morestack его некому было бы починить.

«Побеги вредны». Сами по себе нет. Плохо, когда побег сидит в горячем цикле и его можно убрать без ущерба для читаемости. Один &User{} на запрос никого не убьёт.

Суть: -gcflags="-m" печатает решения escape analysis и инлайнера; второй -m добавляет объяснение цепочки; -l выключает инлайн, чтобы видеть картину «как написано».
$ go build -gcflags="-m" ./...            # свои пакеты
$ go build -gcflags="-m -m" ./...         # с цепочкой причин
$ go build -gcflags="all=-m" ./...        # включая зависимости
$ go build -gcflags="-m -l" ./pkg         # без инлайна
$ go test -gcflags="-m" -bench=. ./pkg    # то же для бенчей
$ go build -gcflags="-m" ./... 2>&1 | grep 'escapes to heap' | sort | uniq -c | sort -rn

Что читать в выводе

СтрокаСмысл
escapes to heapвыражение (&T{}, new, make) аллоцируется в куче
moved to heap: xобычная локальная переменная перенесена в кучу
does not escapeобычно остаётся на стеке - то, чего мы хотим; но слайс переменного размера больше 32 байт всё равно уйдёт в кучу
leaking param: pпараметр утекает наружу функции (в возврат или в кучу)
leaking param content: pутекает не сам указатель, а то, на что он указывает
can inline f / inlining call to fрешения инлайнера - тут же

Чем добить

Одного -m мало: он показывает решения, а не стоимость. В пару к нему берут:

  • go test -bench=. -benchmem покажет allocs/op и B/op, то есть сколько это стоит на самом деле.
  • go build -gcflags="-m=2" эквивалентен -m -m.
  • -gcflags="-d=ssa/check_bce/debug=1" найдёт, где остались проверки границ.
  • go tool pprof -alloc_objects ответит, кто реально аллоцирует в проде, а не в теории.
Дисциплина

Не читай -m по всему проекту: там тысячи строк и 95% из них к делу не относятся. Порядок такой: сначала профиль (-alloc_objects) находит горячую функцию, потом -m по одному пакету объясняет, почему она аллоцирует. Начать с -m и сразу оптимизировать значит оптимизировать вслепую.

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

Приёмы, которые реально работают

  1. Отдавать буфер снаружи. func F(dst []byte, ...) []byte вместо func F(...) []byte. Так устроены strconv.AppendInt, time.Time.AppendFormat, fmt.Appendf (Go 1.19).
  2. Принимать указатель на результат вместо возврата указателя: func Parse(b []byte, out *Msg) error.
  3. Не пропускать значение через any в горячем пути: fmt.Sprintf боксит каждый аргумент. Вместо него бери strconv, strings.Builder, дженерики (для типов с одинаковой GC-формой инстанцирование не создаёт интерфейс).
  4. Ограничить область жизни: не сохранять указатель в глобальную структуру «на всякий случай», не передавать в горутину то, что можно передать копией.
  5. Константные размеры: make([]byte, 4096) остаётся на стеке, а make([]byte, n) больше 32 байт нет. Иногда помогает верхняя граница: var buf [256]byte; b := buf[:n] при n <= 256.

Чего делать не надо

  • Для оптимизации Go-кода //go:noescape не годится. Это обещание компилятору про ассемблерную функцию (объявленную без тела), что она не сохраняет переданные указатели. Если соврать, при перемещении стека память испортится.
  • sync.Pool не отменяет побег: объект по-прежнему в куче, он просто переиспользуется.
  • unsafe ради «оставить на стеке» почти всегда хуже аллокации, которую ты пытался убрать.
Почему директивы нет

Если бы существовал //go:stackalloc, компилятор был бы обязан проверять, что программист не соврал, а это тот же самый escape analysis. Иначе остаётся только доверять программисту, то есть открыть дыру в безопасности памяти. Команда Go выбрала третий путь: улучшать сам анализ. За последние релизы он научился, например, оставлять на стеке мелкие слайсы, размер которых известен только в рантайме, и часть случаев с девиртуализацией интерфейсов.

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

Три силы

  1. Стоимость копии. С Go 1.17 на amd64 (с Go 1.18 и на arm64) работает register-based ABI: на amd64 9 целочисленных регистров и 15 float-регистров под аргументы и результаты, на arm64 по 16. Структура из 2–4 машинных слов возвращается вообще без записи в память.
  2. Стоимость аллокации. Возврат *T из функции почти всегда заканчивается строкой escapes to heap, а за ней стоят mallocgc и будущая работа сборщика.
  3. Стоимость для GC потом. Слайс []T из структур без указателей помечен как noscan и не сканируется вообще. []*T на миллион элементов даёт сборщику миллион объектов, которые он обходит каждый цикл. Разница тут на порядки, и она важнее копий.
СитуацияВыборПочему
Структура до ~4–6 слов, не мутируетсяпо значениюкопия в регистрах, ноль аллокаций
Крупная структура (десятки полей, массивы внутри)по указателюкопия дороже аллокации
Нужна мутация, видимая вызывающемууказательиначе меняешь копию
Объект разделяется между горутинамиуказателькопия разъедется
Хранится миллионами в слайсезначениеодин объект вместо N, noscan, локальность
Внутри есть sync.Mutex, sync.WaitGroup, atomic.*указателькопировать нельзя, go vet ругнётся
У типа уже есть методы с указательным receiverуказательконсистентность API важнее наносекунд
type Point struct {
    X, Y float64
}   // 16 байт, noscan

// хорошо: возврат в регистрах, аллокаций 0
func Mid(a, b Point) Point { return Point{(a.X+b.X)/2, (a.Y+b.Y)/2} }

// плохо в горячем пути: что ни вызов, то mallocgc и работа GC
func MidPtr(a, b *Point) *Point { return &Point{...} }

// а ещё важнее, как хранить:
pts := make([]Point,  1_000_000)  // 1 объект, спан noscan, GC его не обходит
ptr := make([]*Point, 1_000_000)  // 1_000_001 объект, каждый цикл GC обходит миллион
Как на этом ловят

Заготовленный ответ «указатели всегда быстрее, потому что не копируют» действует как красная тряпка. Дальше спросят: «а сколько стоит аллокация?», «а что будет с GC, если таких объектов миллион?», «а если структура 16 байт, что дешевле: скопировать 16 байт или сходить в mallocgc?». В правильном ответе звучат слова «замерил» и -benchmem: в 90% функций разница тонет в том, что функция делает по существу.

Суть: стек начинается с 2 КБ и при нехватке места копируется целиком в новый вдвое больший блок, а рантайм правит все указатели, смотревшие внутрь старого стека. Возможно это потому, что компилятор строит точные карты указателей для каждого кадра.

Механика

  1. Пролог почти каждой функции сравнивает SP с полем stackguard0 в структуре g. Проверка занимает пару инструкций, ветка предсказуемая.
  2. Места не хватило: вызывается runtime.morestack, горутина попадает в newstack.
  3. Выделяется новый блок как минимум вдвое большего размера (стеки берутся из пулов по размерам: 2, 4, 8, 16 КБ и дальше из mheap).
  4. memmove копирует старые кадры в новый блок.
  5. Рантайм проходит по кадрам, по stack maps находит все слоты-указатели и прибавляет дельту к тем, что указывали внутрь старого стека. Плюс правит указатели в defer-записях и в g.
  6. Старый блок освобождается, выполнение продолжается с того же места.

Сжатие тоже есть: если при сканировании стека в фазе маркировки занято меньше четверти, рантайм может скопировать стек в блок вдвое меньше (shrinkstack). Верхний предел задаёт runtime/debug.SetMaxStack, по умолчанию 1 ГБ на 64 битах; превысишь его и получишь fatal error: stack overflow (это не паника, её не поймать).

Почему это возможно в Go и невозможно в C

В Go указатели точно типизированы, а адресной арифметики нет. Компилятор для каждой точки вызова знает, какие слоты кадра содержат указатели, и записывает это в метаданные, так что рантайм может найти и переписать каждую ссылку. В C указатель неотличим от long: «починить» его невозможно, поэтому размер стека фиксируется заранее (типично 8 МБ на поток), а переместить его нельзя. Отсюда прямое следствие: миллион горутин по 2 КБ реален, а миллион потоков по 8 МБ нет.

История и грабли, о которых приятно знать

До Go 1.3 использовались сегментированные стеки: вместо копирования подцеплялся новый сегмент. Отсюда рождался «hot split»: цикл, вызывающий функцию ровно на границе сегмента, на каждой итерации выделял и освобождал сегмент, и производительность падала в разы. Copy stack эту проблему убрал: удвоение амортизирует цену, за жизнь горутины переездов единицы. Практический вывод для кода: глубокая рекурсия и жирные локальные массивы в рекурсивных функциях = лишние копирования стека; и //go:nosplit в обычном коде ни к чему: проверка в прологе снимается, и цепочке таких функций остаётся запас около 800 байт. Молча вылезти за него не выйдет: линкер проверяет цепочки и падает с «nosplit stack over … byte limit».

Что стоит запомнить

  • Escape analysis решает, может ли объект пережить область вызова.
  • Решение проверяется для конкретного compiler build.
  • Измерение определяет, окупилась ли смена кода.

Частые ошибки

  • Взятие адреса не доказывает само по себе heap allocation.
  • Escape diagnostics не заменяют benchmark.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Разбери результат

В функции makeValue локальная структура возвращается по указателю. Что может сказать compiler escape analysis, и можно ли заключить это только по синтаксису?

func makeValue() *Item {
  item := Item{ID: 7}
  return &item
}
func main() { fmt.Println(makeValue().ID) }
Ответ и разбор

Программа печатает 7. Значение должно жить достаточно долго для использования после возврата; компилятор решает, как его разместить. Сам факт &item не доказывает конкретно heap allocation: проверь go build -gcflags=-m для текущего компилятора и профилируй реальную сборку.

Код-ревью

2. Проверь вывод и исправь

Автор переписал func get() *Item { return &Item{ID: 7} } на значение и обещает ноль аллокаций. Как проверить это утверждение честно?

func get() *Item { return &Item{ID: 7} }
Ответ и разбор

Сравни оба варианта в одном benchmark с тем же потребителем, запусти go test -bench=... -benchmem и проверь escape diagnostics конкретной версии Go. Не делай heap placement API-контрактом: compiler может менять решение между версиями. Сохраняй вариант, который даёт нужный API и измеренное улучшение.

Применение

3. Проследи время жизни замыкания

Функция newCounter уже вернулась. Какой результат дадут два последовательных вызова next? Почему n остаётся доступным и чего не хватает для конкурентных вызовов?

func newCounter() func() int {
    n := 0
    return func() int { n++; return n }
}
next := newCounter()
fmt.Println(next(), next())
Ответ и разбор

Получится 1 2: оба вызова замыкания меняют одну захваченную переменную n. Go сохраняет её время жизни, пока она нужна замыканию; возврат функции не создаёт висячую ссылку. Конкретное размещение проверяют escape diagnostics текущего компилятора. Конкурентные вызовы конфликтуют при n++, поэтому нужен mutex вокруг чтения и изменения либо другой явно выбранный протокол доступа.

Применение

4. Разбери другое ограничение

Benchmark дал такие результаты для одного и того же входа и потребителя:

pointer: 120 ns/op, 1 alloc/op
value:   110 ns/op, 1 alloc/op

Можно ли заключить, что value return убрал аллокацию или стабильно ускорил функцию?

Ответ и разбор

Нет: обе версии показывают 1 alloc/op, значит этот запуск не подтверждает устранение аллокации. Разница 10 ns/op в одном измерении не доказывает устойчивое ускорение; повтори benchmark несколько раз и сравни результат, например, через benchstat. Проверь, что потребитель, входные данные и настройки одинаковы, а работу нельзя удалить оптимизатору. Решение о сигнатуре принимай по измерениям и контракту API, а не по форме pointer/value.