Перейти к содержимому

Escape Analysis и Go Memory Model — стек/куча и happens-before

Что это: escape analysis — оптимизация компилятора, решающая, где живёт объект (stack vs heap). Go Memory Model — формальные гарантии порядка операций в многопоточной программе. Зачем знать на Middle 1: на собесе спрашивают “почему этот объект на heap?” — нужно уметь читать вывод -gcflags=-m. Memory Model — обязательная теория для конкурентного программирования; “будет ли race?” — типовой вопрос.


  1. Базовая концепция
  2. Под капотом
  3. Тонкие моменты / Gotchas (12+)
  4. Производительность и compiler optimizations
  5. Когда использовать / когда НЕ использовать
  6. Вопросы на собесе Middle 1 (25)
  7. Practice — задачи (7)
  8. Источники

В Go (как и в большинстве языков) — две области памяти:

Stack (для каждой goroutine):

  • Маленькая (стартует с 8 KB, может расти до 1 GB).
  • LIFO — пушим/попим фреймы.
  • Освобождается автоматически при выходе из функции.
  • Быстрая (cache-friendly, нет GC).

Heap (общая для всех goroutine):

  • Большая (ограничена RAM).
  • Случайный доступ.
  • Освобождается сборщиком мусора (GC).
  • Медленнее (allocation + GC overhead).

Идеально — всё на стеке. Но не всегда возможно — иногда объект escapes (убегает) в heap.

Компилятор Go (cmd/compile/internal/escape) анализирует код и решает: может ли объект жить на стеке или должен убежать на heap.

Объект убегает на heap, если:

  1. Возвращается указатель из функции.
  2. Захватывается в closure (escape via closure).
  3. Кладётся в interface.
  4. Сохраняется в глобальную переменную.
  5. Передаётся в channel.
  6. Слайс растёт (append с превышением cap).
  7. Размер неизвестен в compile-time.

Если ни одно из этих — объект остаётся на стеке.

Окно терминала
go build -gcflags="-m" main.go
go build -gcflags="-m -m" main.go # более подробно

Выводит решения escape analysis:

./main.go:5:6: can inline foo
./main.go:8:9: &User{} escapes to heap
./main.go:8:9: moved to heap: u

Memory Model описывает: какие изменения, сделанные в одной goroutine, гарантированно видны в другой.

Ключевое понятие — happens-before (HB):

  • Если событие A happens-before B, то изменения A видны в B.
  • Без HB — нет гарантий (data race возможен).

HB устанавливается:

  • Внутри одной goroutine (последовательное выполнение).
  • Через channel: send HB receive.
  • Через sync.Mutex: Unlock HB следующий Lock.
  • Через sync.Once: Do() HB всё, что вызывается после.
  • Через atomic: с Go 1.19+ — explicit ordering.
  • Через init: package init HB main.

Компилятор:

  1. Строит dataflow граф программы.
  2. Помечает переменные как “escapes” если они “ускользают” в внешний скоуп.
  3. Маленькие объекты, не ускользающие — на стек.

Простой пример:

func noEscape() {
x := 42 // local
_ = x // not used outside
} // x на стеке, освобождается на return
func escape() *int {
x := 42
return &x // &x ускользает в return → x на heap
}
Окно терминала
$ go build -gcflags="-m -m" main.go
./main.go:10:6: can inline foo with cost 4 as: func() *int { x := 42; return &x }
./main.go:11:6: x escapes to heap:
./main.go:11:6: flow: ~r0 = &x:
./main.go:11:6: from &x (address-of) at ./main.go:12:9
./main.go:11:6: from return &x (return) at ./main.go:12:2
./main.go:11:6: moved to heap: x

Здесь компилятор объясняет: x escapes, потому что его адрес возвращается из функции.

func slice1() {
s := make([]int, 10) // на стеке (size known, mostly small)
_ = s
}
func slice2() {
s := make([]int, 10000) // на heap (большой размер)
_ = s
}
func slice3(n int) {
s := make([]int, n) // на heap (size unknown в compile-time)
_ = s
}
func slice4() []int {
s := make([]int, 10)
return s // escape (returned)
}

⚠️ Правило: компилятор может оставить slice на стеке только если:

  • Размер известен в compile-time.
  • Размер мал (<64KB примерно, точное число зависит от версии).
  • Не “ускользает”.
func foo() {
x := 42
var y any = x // ← x escapes (boxing в interface)
_ = y
}

Любое присваивание в interface{} для value types ведёт к heap (потому что interface хранит указатель).

Для pointer types — указатель уже есть, escape только если объект сам ускользает:

func bar() {
x := &MyStruct{}
var y any = x // x: уже на heap (потому что pointer escape into interface)
}
func newCounter() func() int {
count := 0
return func() int { // ← closure капчит count
count++
return count
}
}

count захвачен closure → escape to heap. Closure хранит указатель на heap-allocated count.

func noEscape() {
x := 42
print(&x) // print — небольшая, может быть inlined
}

Если print инлайнен — компилятор видит, что &x не уезжает, и оставляет x на стеке.

Лимит inlining: ~80 “стоимостных” единиц (точное число — cmd/compile/internal/inline/inl.go). Превышение — не инлайнится.

Из официального документа https://go.dev/ref/mem:

Channel (буфер k, send i, receive i):

  • The send of the k-th value on a channel of capacity C happens before the (k+C)-th receive completes.
  • Closing a channel happens before a receive that returns because the channel is closed.

Mutex:

  • The n-th call to m.Unlock() happens before the (n+1)-th call to m.Lock() returns.

Once:

  • The function call f from once.Do(f) happens before any return from once.Do(f).

Atomic (Go 1.19+):

  • atomic.Store(addr, x) happens before atomic.Load(addr) that returns x (for the same addr).
  • This applies even between different goroutines.

Из spec:

A data race occurs when two goroutines access the same variable concurrently and at least one of the accesses is a write.

Если есть data race — программа undefined behavior: компилятор может оптимизировать как угодно, результаты непредсказуемы.

Окно терминала
go run -race main.go
go test -race ./...

Race detector:

  • Использует shadow memory (по 2 байта shadow на каждый байт user memory).
  • Для каждого доступа к памяти записывает: goroutine ID, vector clock.
  • При конфликте (два доступа без HB) — выводит warning.

Overhead: 5-10x slowdown, 5-10x memory. Только для testing.

ch := make(chan int)
go func() {
x := 42 // write
close(ch)
}()
<-ch // ← после receive, гарантированно видим x = 42
fmt.Println(x)

close happens-before receive (включая receive of zero value). Поэтому запись x видна после receive.

var globalX = computeX() // package init
func main() {
// globalX гарантированно инициализирован
use(globalX)
}

Package init happens-before main. Между разными package init — порядок определяется import graph.


Gotcha 1: возврат указателя на локальную переменную — ОК

Заголовок раздела «Gotcha 1: возврат указателя на локальную переменную — ОК»

Многих смущает после C/C++:

func newInt() *int {
x := 42
return &x // OK! x перемещается на heap.
}

В C это UB (stack memory invalidated). В Go — escape analysis обнаруживает, перемещает на heap. Безопасно, но stoit чуть дороже (heap alloc).

func F1() []int {
s := make([]int, 10)
return s // escape
}
func F2(out *[]int) {
*out = make([]int, 10) // escape (out может быть указатель на global)
}
func F3() {
s := make([]int, 10) // не escape (если не уходит дальше)
_ = s
}

Слайсы — частая причина “почему мой код медленный”. Используй -gcflags=-m чтобы видеть.

func appendStuff(s []int) []int {
s = append(s, 1, 2, 3) // если cap < len + 3 → новая аллокация на heap
return s
}

append либо использует существующий cap, либо аллоцирует новый bigger array (cap2 для маленьких, cap1.25 для больших). Новая аллокация — всегда heap.

func foo() {
x := 42
fmt.Println(x) // x escape (boxed в any)
}

Любая функция, принимающая any (как Println) — boxing. Объект ускользает в heap.

type Reader interface { Read([]byte) (int, error) }
func Read(r Reader, buf []byte) { // r — интерфейс, объект за ним — на heap
r.Read(buf)
}

Если r — это *os.File, объект os.File уже на heap (создан через os.Open). Если r — это struct value, она ушла в heap при boxing.

func foo() {
x := 42
defer fmt.Println(&x) // ← x escape (захвачен в defer closure)
}

defer создаёт closure, capturing variables. Captured variables — escape.

Но в Go 1.14+ оптимизация open-coded defer для простых случаев избегает closure → не escape.

func foo() {
x := 42
go func() {
fmt.Println(x) // ← x escape (goroutine может жить дольше функции)
}()
}

Goroutine — отдельный stack. Captured variables всегда escape to heap (нельзя ссылаться на stack чужой goroutine).

m := map[string]int{}
go func() { m["a"] = 1 }()
go func() { _ = m["a"] }()

Race detector ловит. Но даже без race detector — Go runtime fail-fast для concurrent map access:

fatal error: concurrent map read and map write

(Это специальный assert внутри runtime/map.go).

var ready int32
var data int
go func() {
data = 42
atomic.StoreInt32(&ready, 1)
}()
for atomic.LoadInt32(&ready) == 0 {}
fmt.Println(data) // 42 гарантировано? Да в Go 1.19+!

В Go ≤ 1.18 — НЕ гарантировано (memory model был слабее). С Go 1.19+ — atomic.Store HB atomic.Load (sequential consistency для atomic).

slice := []int{1, 2, 3}
for _, v := range slice {
go func() {
fmt.Println(v) // ← в Go ≤ 1.21: capture by reference, печатает последний
}()
}

В Go 1.22+ этот код работает корректно (каждая итерация — новая переменная). Но в старых версиях — race + неправильное поведение.

type Empty struct{}
func foo() *Empty {
e := Empty{}
return &e // ← &e может указывать на специальный zerobase, не escape
}

Zero-sized structs — особый случай, runtime использует общий &zerobase для них. Adress может быть одинаковым для разных &Empty{}. Не escape в обычном смысле.

ch := make(chan int, 1)
go func() {
x := 42
ch <- 1
fmt.Println(x) // запись после send — НЕ имеет HB-отношения с receiver
}()
<-ch

После <-ch НЕ гарантируется, что fmt.Println(x) в goroutine уже выполнен. Send HB receive, но НЕ наоборот.


Operation ns/op Notes
-----------------------------------------
Stack allocation ~0 just stack pointer adjustment
Heap allocation 10-50 mallocgc, write barriers
GC cost varies 1-100% CPU overhead

Heap allocations — основная причина latency и GC pressure. Каждый * reduces — выигрыш.

  1. Передавай маленькие struct по value, не по pointer:
func sum(p Point) int { return p.X + p.Y } // value, no escape
func sum2(p *Point) int { return p.X + p.Y } // pointer, может escape
  1. Pre-allocate slices с известной capacity:
s := make([]int, 0, n) // cap=n, не растёт
  1. Sync.Pool для temporary objects:
var bufPool = sync.Pool{
New: func() any { return &bytes.Buffer{} },
}
func process() {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
// ...
}
  1. Избегать interface на горячем пути:
func sum(nums []int) int { ... } // direct, no boxing
func sumI(nums []any) int { ... } // boxing every int

Compiler может переупорядочивать инструкции, если это не нарушает single-goroutine semantics. Между goroutines — без sync ничего не гарантировано.

var x, y int
go func() {
x = 1
y = 2
}()
go func() {
if y == 2 {
fmt.Println(x) // может быть 0! (compiler/CPU reorder)
}
}()

Чтобы заработало — нужен sync (channel, mutex, atomic).

  • Тестируй с -race в CI.
  • Race не всегда воспроизводится (не deterministic).
  • Race detector — false negative (могут пропустить race), но не false positive.

5. Когда использовать / когда НЕ использовать

Заголовок раздела «5. Когда использовать / когда НЕ использовать»

Использовать активно:

  • При оптимизации hot paths.
  • При работе с миллионами объектов.
  • В библиотеках, нацеленных на performance.

Не зацикливаться:

  • Premature optimization is the root of all evil.
  • Большинство allocations в обычном коде не критичны.
  • Сначала измерь (go test -bench -benchmem, pprof), потом оптимизируй.
  • Всегда синхронизировать concurrent access (channel, mutex, atomic).
  • Никогда не полагаться на “ну, скорее всего, увижу обновление”.
  • Использовать -race в тестах.

Q1: Что такое escape analysis?

A: Оптимизация компилятора Go: для каждой переменной определяет, может ли она жить на стеке или должна быть на heap. Если переменная не “ускользает” из функции — на стеке (быстрее, без GC).


Q2: Какие причины escape to heap?

A:

  1. Возврат указателя из функции.
  2. Захват в closure (включая goroutine, defer).
  3. Сохранение в interface (boxing).
  4. Большой размер (>~64KB).
  5. Slice/map с неизвестным размером (make([]int, n)).
  6. Передача в channel.
  7. Глобальная переменная.

Q3: Как посмотреть escape analysis?

A: go build -gcflags="-m" main.go — короткий вывод; -gcflags="-m -m" — подробный, с reasons.


Q4: Почему в Go можно вернуть указатель на локальную переменную?

A: Escape analysis перемещает переменную на heap. В отличие от C, stack memory не invalidates — компилятор гарантирует корректность.


Q5: Что такое stack growth в Go?

A: Goroutine стартует с маленьким стеком (8 KB). При нехватке — runtime выделяет новый, бóльший стек, копирует данные, обновляет указатели. Можно расти до 1 GB.


Q6: Влияет ли inlining на escape?

A: Да. Если функция инлайнена — компилятор видит весь код, лучше определяет escape. Часто inlined-функции не вызывают escape, не-inlined — вызывают.


Q7: Что такое Go Memory Model?

A: Формальные правила, описывающие, когда изменения в одной goroutine видны в другой. Основное понятие — happens-before: если A HB B, изменения A видны в B.


Q8: Что устанавливает happens-before?

A:

  1. Sequential execution в одной goroutine.
  2. Channel send HB receive.
  3. Mutex Unlock HB следующий Lock.
  4. sync.Once.Do HB всё после.
  5. Atomic (с Go 1.19+).
  6. Package init HB main.

Q9: Что такое data race?

A: Когда две goroutine обращаются к одной переменной concurrently, и хотя бы одна — запись, без синхронизации (без HB). Undefined behavior.


Q10: Как обнаружить race?

A: go run -race main.go или go test -race ./.... Race detector использует shadow memory и vector clocks. Overhead — 5-10x.


Q11: Channel close — happens-before что?

A: close(ch) happens-before любой receive из ch (включая receive of zero value после исчерпания).


Q12: Buffered channel — happens-before?

A: Для канала capacity C: k-й send HB (k+C)-й receive. Если C=0 (unbuffered) — k-й send HB k-й receive (синхронно).


Q13: sync.Mutex — happens-before?

A: n-й Unlock HB (n+1)-й Lock. То есть всё, что произошло до Unlock, видно после следующего Lock.


Q14: atomic.Store/Load — happens-before?

A: С Go 1.19+: atomic.Store HB atomic.Load (sequential consistency). До 1.19 — слабее, поведение зависело от платформы.


Q15: Будет ли race?

var x int
go func() { x = 1 }()
fmt.Println(x)

A: Да, классический race. Запись и чтение x без синхронизации.


Q16: Будет ли race?

var x int
ch := make(chan struct{})
go func() {
x = 1
close(ch)
}()
<-ch
fmt.Println(x)

A: Нет. close(ch) HB receive, поэтому x = 1 HB Println(x). Гарантировано печатается 1.


Q17: Будет ли race?

var x int
var mu sync.Mutex
go func() {
mu.Lock()
x = 1
mu.Unlock()
}()
mu.Lock()
fmt.Println(x)
mu.Unlock()

A: Нет race (если Lock в main вызван после goroutine Lock/Unlock). Но x может быть 0 или 1 — гонка по timing (если main lock’нул первым).


Q18: Что такое sync.Once?

A: Гарантирует, что функция выполнится ровно один раз, даже при concurrent вызовах из разных goroutine. Все вызывающие видят результат (happens-before).


Q19: Как Go оптимизирует defer?

A:

  • Go 1.13+: open-coded defer для статических вызовов (в одном-двух местах). Без heap-allocated structure.
  • Inlinable defers — на стеке.
  • Сложные defers (в цикле) — heap-allocated.

Q20: Что такое sync.Pool?

A: Pool временных объектов для переиспользования. Помогает уменьшить heap allocations:

var p = sync.Pool{New: func() any { return &Buffer{} }}
buf := p.Get().(*Buffer)
defer p.Put(buf)

GC может очистить pool (например, при STW). Не для долгоживущих объектов.


Q21: Почему inlining важен для производительности?

A: Inlining:

  1. Убирает function call overhead.
  2. Раскрывает escape analysis (компилятор видит весь контекст).
  3. Включает дополнительные оптимизации (constant folding, dead code elimination).

Без inlining — escape часто пессимистичен.


Q22: Может ли компилятор переупорядочить инструкции?

A: Да, если это не нарушает single-goroutine semantics. Между goroutine — без sync гарантий нет. Это причина существования memory model.


Q23: В чём цена heap allocation?

A:

  1. Сам malloc (~10-50 ns).
  2. Write barrier (для GC).
  3. Кеш-промахи (heap данные разбросаны).
  4. GC давление (больше мусора — чаще GC). В сумме — может быть x10 медленнее, чем stack.

Q24: Что такое write barrier?

A: Инструкция, выполняемая при записи указателя в heap-объект во время GC. Помогает GC отслеживать reachable objects. Включается на короткие промежутки (concurrent mark phase).


Q25: Как уменьшить GC pressure?

A:

  1. Меньше heap allocations (рассчитывай stacks, sync.Pool).
  2. Preallocate slices с правильным cap.
  3. Избегай interface boxing на горячем пути.
  4. Используй GOGC для тюнинга (default 100 — heap doubling).
  5. Profile через go tool pprof.

Где живёт s?

func F() []int {
s := make([]int, 10)
return s
}

Решение: s (точнее, underlying array) — на heap. Slice возвращается, ускользает из функции.

Подтверждение: go build -gcflags="-m":

./main.go:2:11: make([]int, 10) escapes to heap

Оптимизируйте:

func sumPairs(pairs []Pair) int {
total := 0
for _, p := range pairs {
var b Box = p // ← box — interface!
total += b.Sum()
}
return total
}

Решение: var b Box = p — boxing в interface, allocation per iteration. Если Pair реализует Sum() напрямую — убрать interface:

func sumPairs(pairs []Pair) int {
total := 0
for _, p := range pairs {
total += p.Sum() // direct call, no boxing
}
return total
}

Если нужен polymorphism — interface переходит на slice уровне:

func sumBoxes(boxes []Box) int {
total := 0
for _, b := range boxes { // b — interface, но boxing уже сделан
total += b.Sum()
}
return total
}

Будет ли race?

type Counter struct{ val int }
c := Counter{}
go func() { c.val++ }()
go func() { c.val++ }()
time.Sleep(time.Second)
fmt.Println(c.val)

Решение: Да. Две goroutine инкрементируют c.val без синхронизации. Результат — может быть 1 (race), 2 (повезло), даже мусор (хотя int — atomic на x86, но на других платформах — не обязательно).

Решение — atomic.AddInt64 или sync.Mutex.


Почему это медленно?

func process(items []any) int {
sum := 0
for _, item := range items {
if n, ok := item.(int); ok {
sum += n
}
}
return sum
}

Решение:

  1. items []any — каждый int boxed в any (allocation при создании списка).
  2. Type assertion .(int) — runtime check.

Оптимизация:

func process(items []int) int { // прямо []int
sum := 0
for _, n := range items {
sum += n
}
return sum
}

Если нужно несколько типов — generics:

func process[T Number](items []T) T { ... }

Будет ли race?

ch := make(chan int)
var x int
go func() {
x = 1
ch <- 1
}()
<-ch
go func() {
fmt.Println(x) // ← race?
}()

Решение: Нет race между goroutine 1 и main: send HB receive, поэтому x = 1 HB receive.

Но между receive и goroutine 2 — HB есть (так как создание goroutine HB её выполнение). Поэтому fmt.Println(x) видит x = 1.

⚠️ Но если бы две goroutine читали x одновременно без HB между ними — был бы race.


Найдите escape:

func newUsers(n int) []*User {
users := make([]*User, 0, n)
for i := 0; i < n; i++ {
u := User{ID: i}
users = append(users, &u)
}
return users
}

Решение:

  1. make([]*User, 0, n) — slice escape (возвращается).
  2. u := User{ID: i}&u берётся, добавляется в slice → escape per iteration.

То есть — N+1 heap allocations. Лучше:

func newUsers(n int) []*User {
users := make([]*User, n)
storage := make([]User, n) // один большой allocation
for i := 0; i < n; i++ {
storage[i] = User{ID: i}
users[i] = &storage[i]
}
return users
}

Теперь 2 allocations вместо N+1.


Будет ли race?

var initialized bool
var data []int
func Init() {
if !initialized {
data = []int{1, 2, 3}
initialized = true
}
}
func GetData() []int {
if !initialized {
Init()
}
return data
}

Решение: Да. initialized и data читаются/пишутся concurrent (если Init и GetData вызываются из разных goroutine).

Решение — sync.Once:

var once sync.Once
var data []int
func Init() {
once.Do(func() {
data = []int{1, 2, 3}
})
}
func GetData() []int {
Init()
return data // once.Do HB this return → data видна
}

  1. Go Memory Model — официальный документ: https://go.dev/ref/mem
  2. Go BlogAllocator: avoiding allocations (серия про escape).
  3. Dave CheneyEscape Analysis demystifiedhttps://dave.cheney.net/2014/06/07/five-things-that-make-go-fast
  4. Go sourcecmd/compile/internal/escape/, runtime/mgc.go, runtime/proc.go.
  5. The Go BlogUpdating the Go Memory Model (про atomic в Go 1.19+).
  6. William KennedyEscape Analysis in Go — серия talks.
  7. Habr 2024Stack vs Heap: escape analysis в Go.
  8. Russ CoxHardware Memory Modelshttps://research.swtch.com/hwmm (фундаментальное чтение).
  9. Kavya JoshiUnderstanding Go Runtime (talks про память).
  10. Go 1.19 release notes — про новый memory model для atomic.