День 1: Модель памяти Go — Stack vs Heap

🎯 Темы дня

  1. Стек горутины: устройство, рост, ограничения
  2. Heap: зачем нужен, как аллоцируется
  3. Escape Analysis: правила и как его читать
  4. Практика: -gcflags="-m" и бенчмарки

📖 1. Стек горутины

Устройство

Каждая горутина имеет собственный стек — непрерывный участок памяти для локальных переменных и вызовов функций.

Параметры:

  • Начальный размер: 2 KB (в Go 1.4+; раньше было 4 KB)
  • Максимальный размер: 1 GB (на 64-битных системах)
  • Растёт динамически по мере необходимости

Как работает рост стека

Когда стек заполняется, Go:

  1. Аллоцирует новый стек в 2 раза больше.
  2. Копирует всё содержимое старого стека в новый.
  3. Обновляет все указатели на локальные переменные.
  4. Освобождает старый стек.

Важно: Именно поэтому нельзя хранить указатели на элементы стека — при копировании они станут невалидными. Компилятор это учитывает и переносит такие переменные в heap (escape analysis).

Пример роста стека

go
func deep(n int) int {
    if n == 0 {
        return 0
    }
    var x [1024]byte // 1 KB на каждом уровне
    return deep(n-1) + int(x[0])
}

func main() {
    deep(100) // 100 * 1 KB = 100 KB стека
}

Что происходит:

  • Старт: 2 KB
  • После ~2 вызовов: рост до 4 KB
  • После ~4 вызовов: рост до 8 KB
  • И так далее, пока не достигнет нужного размера

Стоимость роста стека

Рост стека — не бесплатная операция:

  • Копирование памяти
  • Обновление указателей
  • Возможная остановка горутины

Практический вывод: Глубокая рекурсия с большими локальными переменными — плохая идея. Лучше итерация или вынос данных в heap.


📖 2. Heap — общая куча

Зачем нужен heap

Heap — это общая память для всех горутин. Используется, когда:

  1. Переменная должна жить дольше функции, в которой создана.
  2. Размер неизвестен на этапе компиляции.
  3. Переменная слишком большая для стека.
  4. На переменную есть указатели из разных горутин.
Стек горутины и heap: куда попадают переменные

На схеме — две области памяти и причина, по которой переменная уезжает в heap: локальные переменные и кадры вызовов живут в стеке горутины, который растёт и копируется; всё, что переживает функцию (вернули указатель), а также слайсы, мапы и каналы — в общей куче под управлением GC.

Как аллоцируется память в heap

Go использует аллокатор TCMalloc-подобный (Thread-Caching Malloc):

  • Память разбита на arenas (большие блоки, обычно 64 MB).
  • Внутри arenas — spans (группы страниц одинакового размера).
  • Для мелких объектов (< 32 KB) — size classes (8, 16, 32, 64, ... байт).
  • Для крупных объектов — напрямую из arenas.

Почему это важно: Аллокация мелких объектов быстрая (просто сдвиг указателя в size class). Крупных — медленнее.

GC и heap

Heap — это территория Garbage Collector. Всё, что аллоцировано в heap, будет собрано GC, когда станет недостижимым.


📖 3. Escape Analysis — ключ к пониманию

Что это такое

Escape Analysis — это анализ компилятора, который определяет, может ли переменная "убежать" из функции (т.е. использоваться за её пределами). Если да — переменная аллоцируется в heap. Если нет — в stack.

Как запустить

bash
go build -gcflags="-m" main.go

Для более детального вывода:

bash
go build -gcflags="-m -m" main.go  # очень подробно

Правила escape analysis

Переменная уходит в heap, если:

  1. Возвращается указатель на локальную переменную:
go
func foo() *int {
    x := 42
    return &x  // "moved to heap: x"
}
  1. Присваивается интерфейсной переменной:
go
func foo() {
    x := 42
    var i interface{} = x  // "moved to heap: x"
}
  1. Передаётся в функцию, которая может её сохранить:
go
var global *int

func foo() {
    x := 42
    global = &x  // "moved to heap: x"
}
  1. Замыкание захватывает переменную:
go
func foo() func() int {
    x := 42
    return func() int { return x }  // "moved to heap: x"
}
  1. Размер неизвестен на этапе компиляции:
go
func foo(n int) {
    s := make([]int, n)  // "moved to heap: s" (underlying array)
}
  1. Слишком большая для стека:
go
func foo() {
    var x [100000]byte  // "moved to heap: x"
}

Примеры анализа

Пример 1:

go
func create() *User {
    u := User{Name: "Alice"}
    return &u
}

Вывод: moved to heap: u — возвращаем указатель.

Пример 2:

go
func process() {
    u := User{Name: "Alice"}
    fmt.Println(u.Name)
}

Вывод: u does not escape — живёт и умирает в функции.

Пример 3:

go
func process() {
    u := User{Name: "Alice"}
    log.Println(u)  // ⚠️
}

Вывод: u escapes to heap — log.Println принимает interface{}, поэтому u упаковывается в интерфейс и уходит в heap.

Практический вывод: Избегай передачи value types в функции, принимающие interface{}, если хочешь избежать аллокаций.


💻 Практика (1–2 часа)

Задание 1: Анализ кода

Запусти go build -gcflags="-m" на этом коде и объясни, что куда попадёт:

go
package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func createUser() *User {
    u := User{Name: "Alice", Age: 30}
    return &u
}

func processUser(u User) {
    fmt.Println(u.Name)
}

func processInterface(i interface{}) {
    fmt.Println(i)
}

func main() {
    u1 := createUser()
    u2 := User{Name: "Bob", Age: 25}
    
    processUser(u2)
    processInterface(u2)
    
    _ = u1
}

Ожидаемый вывод:

  • u в createUser → heap (возвращаем указатель)
  • u2 в main → heap (передаётся в processInterface как interface{})
  • u в processUser → stack (копируется по значению)

Задание 2: Бенчмарк stack vs heap

Напиши бенчмарк, сравнивающий аллокацию в stack и heap:

go
func BenchmarkStackAlloc(b *testing.B) {
    for i := 0; i < b.N; i++ {
        u := User{Name: "Alice", Age: 30}
        _ = u
    }
}

func BenchmarkHeapAlloc(b *testing.B) {
    for i := 0; i < b.N; i++ {
        u := &User{Name: "Alice", Age: 30}
        _ = u
    }
}

Запусти: go test -bench=. -benchmem

Что смотреть:

  • allocs/op — количество аллокаций на операцию
  • B/op — количество байт, аллоцированных на операцию

Задание 3: Рост стека

Напиши рекурсивную функцию, которая заставляет стек расти, и посмотри через pprof, сколько памяти используется:

go
func deep(n int) {
    var x [1024]byte
    if n > 0 {
        deep(n - 1)
    }
    _ = x
}

Запусти с GODEBUG=gctrace=1 и посмотри на аллокации.


🗣️ Каверзные вопросы

  1. Может ли стек горутины уменьшиться? Ответ: Да, после GC стек может быть уменьшен, если он сильно больше нужного. Но на практике это происходит редко.

  2. Что быстрее: аллокация в stack или heap? Ответ: Stack — быстрее (просто сдвиг указателя). Heap — медленнее (поиск свободного места, возможная фрагментация).

  3. Почему нельзя вернуть указатель на локальную переменную в C, но можно в Go? Ответ: В C стек физически освобождается при выходе из функции. В Go компилятор переносит такие переменные в heap, поэтому они живут дольше.

  4. Что выведет go build -gcflags="-m" для этого кода?

    go
    func foo() []int {
        return []int{1, 2, 3}
    }

    Ответ: []int{1, 2, 3} escapes to heap — слайс возвращается наружу, его underlying array уходит в heap.

  5. Как избежать аллокации в heap для маленькой структуры? Ответ: Не возвращать указатель, не передавать в interface{}, не захватывать в замыкании.


🧩 Проверь себя

1. Что происходит, когда стек горутины переполняется?
2. Какая команда покажет, какие переменные уходят в heap?
3. Какие ситуации заставляют переменную «убежать» в heap? — выберите все подходящие
4. Почему в Go, в отличие от C, можно вернуть указатель на локальную переменную?
5. Каков начальный размер стека новой горутины в Go?
6. Что дешевле: аллокация в stack или в heap?

✅ Чек-лист дня

  • Понимаю, как устроен стек горутины и как он растёт
  • Могу объяснить, зачем нужен heap
  • Умею запускать escape analysis (-gcflags="-m")
  • Понимаю основные правила escape analysis
  • Могу прочитать вывод компилятора и объяснить, почему переменная "убежала"
  • Запустил бенчмарк stack vs heap и сравнил результаты