День 1: Модель памяти Go — Stack vs Heap
🎯 Темы дня
- Стек горутины: устройство, рост, ограничения
- Heap: зачем нужен, как аллоцируется
- Escape Analysis: правила и как его читать
- Практика:
-gcflags="-m"и бенчмарки
📖 1. Стек горутины
Устройство
Каждая горутина имеет собственный стек — непрерывный участок памяти для локальных переменных и вызовов функций.
Параметры:
- Начальный размер: 2 KB (в Go 1.4+; раньше было 4 KB)
- Максимальный размер: 1 GB (на 64-битных системах)
- Растёт динамически по мере необходимости
Как работает рост стека
Когда стек заполняется, Go:
- Аллоцирует новый стек в 2 раза больше.
- Копирует всё содержимое старого стека в новый.
- Обновляет все указатели на локальные переменные.
- Освобождает старый стек.
Важно: Именно поэтому нельзя хранить указатели на элементы стека — при копировании они станут невалидными. Компилятор это учитывает и переносит такие переменные в heap (escape analysis).
Пример роста стека
gofunc 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 — это общая память для всех горутин. Используется, когда:
- Переменная должна жить дольше функции, в которой создана.
- Размер неизвестен на этапе компиляции.
- Переменная слишком большая для стека.
- На переменную есть указатели из разных горутин.
На схеме — две области памяти и причина, по которой переменная уезжает в 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.
Как запустить
bashgo build -gcflags="-m" main.go
Для более детального вывода:
bashgo build -gcflags="-m -m" main.go # очень подробно
Правила escape analysis
Переменная уходит в heap, если:
- Возвращается указатель на локальную переменную:
gofunc foo() *int { x := 42 return &x // "moved to heap: x" }
- Присваивается интерфейсной переменной:
gofunc foo() { x := 42 var i interface{} = x // "moved to heap: x" }
- Передаётся в функцию, которая может её сохранить:
govar global *int func foo() { x := 42 global = &x // "moved to heap: x" }
- Замыкание захватывает переменную:
gofunc foo() func() int { x := 42 return func() int { return x } // "moved to heap: x" }
- Размер неизвестен на этапе компиляции:
gofunc foo(n int) { s := make([]int, n) // "moved to heap: s" (underlying array) }
- Слишком большая для стека:
gofunc foo() { var x [100000]byte // "moved to heap: x" }
Примеры анализа
Пример 1:
gofunc create() *User { u := User{Name: "Alice"} return &u }
Вывод: moved to heap: u — возвращаем указатель.
Пример 2:
gofunc process() { u := User{Name: "Alice"} fmt.Println(u.Name) }
Вывод: u does not escape — живёт и умирает в функции.
Пример 3:
gofunc 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" на этом коде и объясни, что куда попадёт:
gopackage 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:
gofunc 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, сколько памяти используется:
gofunc deep(n int) { var x [1024]byte if n > 0 { deep(n - 1) } _ = x }
Запусти с GODEBUG=gctrace=1 и посмотри на аллокации.
🗣️ Каверзные вопросы
-
Может ли стек горутины уменьшиться? Ответ: Да, после GC стек может быть уменьшен, если он сильно больше нужного. Но на практике это происходит редко.
-
Что быстрее: аллокация в stack или heap? Ответ: Stack — быстрее (просто сдвиг указателя). Heap — медленнее (поиск свободного места, возможная фрагментация).
-
Почему нельзя вернуть указатель на локальную переменную в C, но можно в Go? Ответ: В C стек физически освобождается при выходе из функции. В Go компилятор переносит такие переменные в heap, поэтому они живут дольше.
-
Что выведет
go build -gcflags="-m"для этого кода?gofunc foo() []int { return []int{1, 2, 3} }Ответ:
[]int{1, 2, 3} escapes to heap— слайс возвращается наружу, его underlying array уходит в heap. -
Как избежать аллокации в heap для маленькой структуры? Ответ: Не возвращать указатель, не передавать в
interface{}, не захватывать в замыкании.
🧩 Проверь себя
✅ Чек-лист дня
- Понимаю, как устроен стек горутины и как он растёт
- Могу объяснить, зачем нужен heap
- Умею запускать escape analysis (
-gcflags="-m") - Понимаю основные правила escape analysis
- Могу прочитать вывод компилятора и объяснить, почему переменная "убежала"
- Запустил бенчмарк stack vs heap и сравнил результаты