Модуль: 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 -60TEXT 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=. -benchmemBenchmarkSumStack-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»).