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

TL;DR#

В Go компилятор сам решает, где разместить значение — на стеке горутины или в куче, — опираясь на escape analysis. Стек дешёв: аллокация это сдвиг указателя SP, а освобождение — возврат при выходе из функции, без участия GC. Куча дороже: запрос у аллокатора (mcache/mcentral/mheap), последующая работа GC и давление на пропускную способность. Стеки горутин начинаются с 8 КБ (исторически было 2 КБ для системных потоков-g0 и для горутин в разных версиях), растут не сегментами, а целостным копированием (contiguous stacks) через morestack/copystack.

Теория#

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

СвойствоСтекКуча
Кто владеетКаждая горутина имеет свой стекОбщая на весь процесс
АллокацияSP -= size (один инструкция)mcache → mcentral → mheap, возможен mmap
ОсвобождениеАвтоматически при RETЧерез GC (mark & sweep, concurrent)
Стоимость GCНулевая (GC только сканирует, но не освобождает поэлементно)Полная: сканирование, маркировка, sweep
ЛокальностьОтличная (горячий стек в кэше)Хуже, фрагментация
КонкурентностьНет синхронизации (стек приватен)mcache per-P снимает блокировку на быстром пути

Ключевой принцип Go: семантика владения определяется компилятором, а не программистом. Вы пишете x := &T{} — и где окажется T, решит escape analysis, а не ключевое слово new/&. Это отличает Go от C/C++, где malloc/stack-объявление жёстко задают регион.

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

Каждая горутина (g) имеет поле stack с границами lo/hi и «барьер» stackguard0:

// runtime/runtime2.go (упрощённо)
type stack struct {
    lo uintptr // нижняя граница (стек растёт вниз, к lo)
    hi uintptr // верхняя граница
}

type g struct {
    stack       stack   // [lo, hi)
    stackguard0 uintptr // проверяется в прологе функции; обычно lo + _StackGuard
    stackguard1 uintptr // то же для g0/системного стека
    // ...
}

Стек растёт вниз (от hi к lo). stackguard0 — это «красная зона»: если SP опускается ниже неё, нужно увеличить стек.

Stack guard и пролог функции (morestack check)#

Компилятор вставляет в пролог почти каждой функции проверку: хватит ли стека под фрейм. Это и есть «morestack check».

func work() {
    var buf [4096]byte // большой фрейм
    _ = buf
}

Дизассемблер покажет нечто такое (amd64):

go build -gcflags="-S" ./... 2>&1 | head -60
TEXT main.work(SB)
    MOVQ    (TLS), CX            ; получить *g из TLS
    LEAQ    -4072(SP), AX        ; AX = SP - размер_фрейма
    CMPQ    AX, 16(CX)           ; сравнить с g.stackguard0
    JLS     morestack_call       ; если AX <= stackguard0 → нужно больше стека
    ; ... тело функции ...
    RET
morestack_call:
    CALL    runtime.morestack_noctxt(SB)
    JMP     main.work(SB)        ; повторить пролог после роста

Оптимизация: NOSPLIT. Маленькие листовые функции, чей фрейм гарантированно влезает в «запас» (_StackSmall, ~128 байт), помечаются nosplit и не получают пролог-проверку. Это убирает накладные расходы на горячем пути. Слишком глубокая цепочка nosplit-функций может переполнить запас — компилятор тогда выдаёт ошибку nosplit stack overflow.

Рост стека: contiguous stacks и copystack#

Когда проверка срабатывает, runtime.morestack запускает рост стека. Современный Go (с 1.3) использует непрерывные (contiguous) стеки: выделяется новый блок вдвое больше, и весь старый стек копируется в него.

// концептуально, runtime/stack.go: copystack
func copystack(gp *g, newsize uintptr) {
    old := gp.stack
    new := stackalloc(uint32(newsize)) // новый блок, обычно 2x
    // 1. скопировать байты [old.lo, SP] → новый стек
    memmove(...)
    // 2. КОРРЕКТИРОВКА УКАЗАТЕЛЕЙ: все указатели, ведущие
    //    внутрь старого стека, сдвигаются на (new.lo - old.lo)
    adjustpointers(...)  // фреймы, sudog, defer, panic — всё патчится
    gp.stack = new
    stackfree(old)
}

Самый тонкий момент — adjustpointers: после переезда стека все указатели на стек становятся невалидными, поэтому runtime обходит фреймы (по stack maps, которые генерирует компилятор), defer-цепочки, panic-записи, sudog в каналах — и переписывает каждый указатель. Именно из-за необходимости точно знать, что является указателем, Go требует точного (precise) GC и stack maps.

Уменьшение тоже бывает: при GC, если стек используется меньше чем на 1/4, его могут «сжать» (shrinkstack) до половины.

Сегментные стеки — историческая справка#

До Go 1.3 использовались segmented stacks («split stacks»): при нехватке выделялся новый сегмент, связанный с предыдущим. Проблема — «hot split»: если функция в цикле то выделяет, то освобождает сегмент на границе, каждый вызов платит за mmap/munmap и переключение. Это давало катастрофическую деградацию на ровном месте.

Segmented (≤1.2)Contiguous (≥1.3)
СтруктураСписок сегментовОдин непрерывный блок
РостНовый сегментУдвоение + копирование
ПатологияHot splitРазовая дорогая копия с amortized O(1)
Указатели на стекВалидны после ростаТребуют корректировки

Переход на непрерывные стеки потребовал точных stack maps, но избавил от hot split: удвоение даёт амортизированную стоимость O(1) на байт.

Начальные размеры стека#

// runtime/stack.go
const (
    _StackMin = 2048  // минимальный системный, исторический ориентир
)
  • Горутина стартует с 8 КБ полезного стека (_FixedStack = 2 КБ + системные нужды; фактический стартовый размер несколько раз менялся между версиями — в коде это StackMin/_FixedStack, итог ~8 КБ доступных пользователю в современных версиях).
  • g0 (системный стек планировщика) и стеки сигналов больше и фиксированы.
  • Максимальный размер стека ограничен maxstacksize (1 ГБ на 64-бит по умолчанию); превышение → fatal error: stack overflow (например, бесконечная рекурсия).
// бесконечная рекурсия → стек растёт удвоением до лимита → fatal
func boom(n int) int { return boom(n + 1) }

Стоимость аллокации: стек vs куча#

// Версия A: ничего не убегает — buf на стеке
func sumStack() int {
    var buf [64]int       // аллокация = коррекция SP, бесплатно
    for i := range buf { buf[i] = i }
    s := 0
    for _, v := range buf { s += v }
    return s
}

// Версия B: убегает в кучу
func sumHeap() *[64]int {
    buf := new([64]int)   // escape → mallocgc → GC будет это собирать
    return buf
}

Бенчмарк наглядно показывает разницу через allocs/op:

go test -bench=. -benchmem
BenchmarkSumStack-8   200000000   5.1 ns/op   0 B/op   0 allocs/op
BenchmarkSumHeap-8     30000000   42 ns/op   512 B/op  1 allocs/op

Куча дороже по трём осям: (1) сам путь аллокатора, (2) давление на GC (больше работы по сканированию/маркировке, чаще циклы), (3) косвенно — хуже локальность кэша.

Spilling (выгрузка регистров в стек)#

С Go 1.17 действует register-based ABI (ABIInternal): аргументы и возвраты передаются через регистры, а не через стек. Но регистров конечное число, и компилятор вынужден временно сохранять значения в стек — это spilling.

func f(a, b, c, d, e, f, g, h, i, j int) int {
    // первые ~9 целых уходят в регистры (amd64),
    // дальше и при нехватке — spill в стек-слоты фрейма
    return a + b + c + d + e + f + g + h + i + j
}

Spill-слоты — это часть фрейма функции на стеке; они нужны при нехватке регистров, перед вызовами (caller-saved регистры) и для значений, чей адрес берётся. Spilling не означает escape в кучу — это по-прежнему стек, просто register pressure.

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

  • «Я взял &, значит куча» — миф. &x может остаться на стеке, если адрес не покидает функцию. И наоборот, значение без & может убежать (через интерфейс).
  • Большие фреймы тихо едут в кучу. Объекты больше порога (исторически ~64 КБ / maxstackobject) escape-analysis принудительно отправляет в кучу, даже если логически они локальны. Большой [N]byte на стеке — частая причина внезапных аллокаций.
  • Глубокая рекурсия = много copystack. Если рекурсия растёт линейно, стек удваивается логарифмическое число раз, но каждое удвоение копирует всё — для очень глубоких стеков это заметно. Хвостовой рекурсии в Go нет.
  • Указатель на локальную переменную безопасен (в отличие от C): если он убегает, компилятор переместит переменную в кучу. Dangling pointer на стек невозможен в безопасном Go.
  • //go:nosplit руками — опасно. Без пролога функция не может вырасти; цепочка nosplit может переполнить запас стека и привести к порче памяти. Использовать только в runtime/низкоуровневом коде.
  • Миллион горутин ≠ дёшево по памяти. 1M горутин по 8 КБ = 8 ГБ только под стеки, даже если они спят. Стеки сжимаются, но не до нуля.

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

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

В: Как растёт стек горутины и чем это отличается от Go до 1.3? О: Сейчас — contiguous stacks: при нехватке выделяется новый блок вдвое больше, старый целиком копируется (copystack), и все указатели на стек корректируются по stack maps. До 1.3 были segmented stacks: добавлялся новый сегмент. Проблема старого подхода — «hot split»: цикл на границе сегмента вызывал постоянные alloc/free сегмента и резкую деградацию. Непрерывные стеки дают амортизированную O(1) стоимость.

В: Что такое morestack check и где он находится? О: Это проверка в прологе функции: сравнивается SP - размер_фрейма с g.stackguard0. Если стека не хватает, вызывается runtime.morestack, который растит стек и повторяет пролог. Листовые маленькие функции помечаются nosplit и не несут этой проверки.

В: Почему рост стека требует корректировки указателей, а malloc в куче — нет? О: Потому что стек физически переезжает на новый адрес. Все указатели, ведущие внутрь старого стека (из фреймов, defer, panic, sudog), становятся невалидными и должны быть сдвинуты на разницу адресов. Куча не двигается (Go не использует moving/compacting GC для обычных объектов), поэтому указатели стабильны.

В: Чем именно аллокация на стеке дешевле кучи? О: Стек: одна арифметическая операция над SP, освобождение бесплатно при RET, нет работы для GC, отличная локальность кэша, нет синхронизации (стек приватен горутине). Куча: путь через mcache/mcentral/mheap (возможен mmap), последующая работа GC по сканированию/маркировке/sweep, давление на пропускную способность, фрагментация.

В: Что произойдёт при бесконечной рекурсии? О: Стек будет расти удвоением, пока не упрётся в maxstacksize (по умолчанию 1 ГБ на 64-бит), после чего runtime аварийно завершит процесс с fatal error: stack overflow. Это не паника и не восстанавливается через recover.

В: Что такое spilling и связан ли он с escape в кучу? О: Spilling — это временное сохранение значений из регистров в стек-слоты фрейма, когда регистров не хватает (register pressure), перед вызовами или когда берётся адрес значения. Это происходит на стеке и не связано с кучей. С Go 1.17 ABI регистровый, поэтому spilling стал более заметной темой при чтении ассемблера.

В: Можно ли в безопасном Go получить dangling pointer на освобождённый стек? О: Нет. Если адрес локальной переменной убегает за пределы функции, escape analysis переместит саму переменную в кучу, и указатель останется валидным. Поэтому возвращать &local из функции в Go безопасно — в отличие от C.

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

  • Понимание stack maps и связи с precise GC: почему точная информация о том, где на стеке лежат указатели, обязательна для contiguous stacks и для маркировки.
  • Детали copystack/adjustframe: что именно корректируется (фреймы по PC→stackmap, defer-записи, sudog в каналах, выполняющийся panic).
  • shrinkstack и его взаимодействие с GC: когда и почему стек уменьшается, почему это делается в safe point.
  • ABI 0 vs ABIInternal (регистровый ABI с 1.17), wrapper-функции на границе, влияние на spilling и на чтение -S вывода.
  • Влияние числа горутин на RSS: математика «N × stacksize», почему профиль goroutine и pprof важны для диагностики «утечки горутин» как памяти.
  • Тонкости nosplit: //go:nosplit, _StackSmall/_StackBig, как runtime гарантирует запас для системных вызовов и обработчиков сигналов (stack guard как «red zone»).