Memory Model и Stack/Heap
Введение
Представьте два способа хранения вещей:
Стопка тарелок (Stack) — вы кладете тарелки одну на другую. Взять можно только верхнюю. Быстро, просто, но ограничено.
Склад (Heap) — огромное помещение, где можно положить что угодно куда угодно. Гибко, но нужно помнить, где что лежит, и потом убирать за собой.
В программировании stack и heap — это два основных способа хранения данных в памяти. Понимание того, как Go управляет памятью, критически важно для написания эффективного кода.
Stack (Стек)
Как работает стек
Стек — это область памяти, которая работает по принципу LIFO (Last In, First Out — последним пришел, первым ушел).
┌─────────────┐ ← Stack pointer (вершина)
│ Local 3 │
├─────────────┤
│ Local 2 │
├─────────────┤
│ Local 1 │
├─────────────┤
│ Return │
├─────────────┤
│ Function │
└─────────────┘
Характеристики стека:
- ⚡ Очень быстрое выделение и освобождение (просто двигается указатель)
- 📏 Размер известен на момент компиляции
- 🔄 Автоматическое управление (нет GC overhead)
- 📦 Ограниченный размер (обычно несколько МБ)
- 🧵 Каждая goroutine имеет свой стек (начинается с 2 КБ, растет до 1 ГБ)
Что хранится на стеке
func calculate() int {
x := 10 // x на стеке
y := 20 // y на стеке
result := x + y // result на стеке
return result
}
// Когда функция завершается, весь стек автоматически очищается
На стеке хранятся:
- Локальные переменные примитивных типов
- Аргументы функций
- Возвращаемые значения
- Указатели (сами указатели, не данные на которые они указывают)
Пример работы стека
func main() { // Stack frame для main
x := 5 // x на стеке main
result := add(x, 3) // вызов add
fmt.Println(result)
}
func add(a, b int) int { // Новый stack frame
sum := a + b // sum на стеке add
return sum // sum возвращается, stack frame удаляется
}
Визуализация:
Шаг 1: main вызывается
┌──────────────┐
│ main frame │
│ x = 5 │
└──────────────┘
Шаг 2: add вызывается
┌──────────────┐
│ add frame │
│ a = 5 │
│ b = 3 │
│ sum = 8 │
├──────────────┤
│ main frame │
│ x = 5 │
└──────────────┘
Шаг 3: add завершается
┌──────────────┐
│ main frame │
│ x = 5 │
│ result = 8 │
└──────────────┘
Heap (Куча)
Как работает куча
Heap — это большая область памяти для динамического выделения. В отличие от стека, память на куче остается доступной пока на нее есть ссылки.
Heap (разделяемая между всеми goroutines)
┌─────────────────────────────────────┐
│ [Object 1] [Object 3] │
│ [Object 2] [Object 4] │
│ [Object 5] │
└─────────────────────────────────────┘
Характеристики кучи:
- 🐌 Медленнее стека (требует поиска свободного места)
- ♻️ Требует сборки мусора (GC)
- 📈 Не ограничена размером (в пределах доступной памяти)
- 🌐 Разделяется между всеми goroutines
- 🔗 Данные живут до тех пор, пока на них есть ссылки
Что хранится на куче
func createUser() *User {
user := &User{ // user выделяется на куче!
Name: "Alice",
Age: 30,
}
return user // возвращаем указатель - данные остаются на куче
}
// user существует после завершения функции
// GC удалит его, когда не останется ссылок
На куче хранятся:
- Объекты, на которые возвращаются указатели
- Слайсы и мапы (их данные, не сами переменные)
- Объекты неизвестного размера на момент компиляции
- Данные, которые нужны после завершения функции
- Большие структуры
Escape Analysis
Escape Analysis — это процесс, когда компилятор Go решает, где разместить переменную: на стеке или на куче.
Правило: "escapes to heap"
Переменная "убегает" на кучу, если:
1. Возвращается указатель из функции
func createInt() *int {
x := 42 // x убегает на кучу
return &x // возвращаем указатель
}
// Анализ: x должен жить после завершения функции
// Решение: выделить на куче
2. Переменная слишком большая
func largeArray() {
var arr [10000]int // Слишком большой - на кучу
// ...
}
// Анализ: размер превышает разумный для стека
// Решение: выделить на куче
3. Размер неизвестен на момент компиляции
func dynamicSlice(n int) []int {
slice := make([]int, n) // n неизвестен - данные на кучу
return slice
}
4. Переменная доступна из замыкания
func makeCounter() func() int {
count := 0 // count убегает на кучу
return func() int {
count++
return count
}
}
// Анализ: count используется в замыкании
// Решение: выделить на куче
5. Хранится в интерфейсе
func storeInInterface() interface{} {
x := 42 // x убегает на кучу
return interface{}(x)
}
// Анализ: интерфейс может хранить что угодно
// Решение: выделить на куче
Как проверить Escape Analysis
Используйте флаг компилятора -gcflags:
go build -gcflags="-m" main.go
Пример:
package main
type User struct {
Name string
Age int
}
func createOnStack() User {
return User{Name: "Alice", Age: 30}
}
func createOnHeap() *User {
return &User{Name: "Bob", Age: 25}
}
func main() {
u1 := createOnStack()
u2 := createOnHeap()
_, _ = u1, u2
}
Результат анализа:
$ go build -gcflags="-m -m" main.go
./main.go:9:9: User{...} does not escape
./main.go:13:9: &User{...} escapes to heap
./main.go:13:9: flow: ~r0 = &{storage for &User{...}}
./main.go:13:10: User{...} escapes to heap
Stack vs Heap: Примеры
Пример 1: Локальные переменные
func example1() {
// Все на стеке - быстро и эффективно
x := 10
y := 20
z := x + y
fmt.Println(z)
}
Пример 2: Возврат значения
func example2() User {
// User возвращается по значению - на стеке
return User{Name: "Alice", Age: 30}
}
func example3() *User {
// User возвращается по указателю - на куче
return &User{Name: "Bob", Age: 25}
}
Пример 3: Слайсы
func example4() {
// Переменная slice на стеке
// Но данные слайса на куче
slice := []int{1, 2, 3, 4, 5}
// Header слайса (ptr, len, cap) - на стеке
// Массив данных [1,2,3,4,5] - на куче
_ = slice
}
Визуализация:
Stack:
┌─────────────────┐
│ slice header: │
│ ptr ───────┼─┐
│ len = 5 │ │
│ cap = 5 │ │
└─────────────────┘ │
│
Heap: │
┌───────────────────▼───┐
│ [1][2][3][4][5] │
└───────────────────────┘
Пример 4: Замыкания
func example5() func() int {
count := 0 // count на куче (escape analysis)
return func() int {
count++ // используется после завершения example5
return count
}
}
func main() {
counter := example5()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
fmt.Println(counter()) // 3
}
Оптимизация: предотвращение escape
Техника 1: Возвращайте значения, а не указатели
// Медленнее - аллокация на куче
func createUserBad() *User {
return &User{Name: "Alice"}
}
// Быстрее - на стеке
func createUserGood() User {
return User{Name: "Alice"}
}
Когда это работает:
- Структура небольшая (< 10 полей)
- Не нужно изменять оригинал
- Производительность критична
Техника 2: Используйте sync.Pool для переиспользования
var userPool = sync.Pool{
New: func() interface{} {
return &User{}
},
}
func processUser(name string, age int) {
// Берем из пула вместо аллокации
user := userPool.Get().(*User)
user.Name = name
user.Age = age
// Работаем с user...
// Возвращаем в пул
userPool.Put(user)
}
Техника 3: Предвыделяйте слайсы
// Плохо - множество аллокаций
func collectBad() []int {
var result []int
for i := 0; i < 1000; i++ {
result = append(result, i) // реаллокации
}
return result
}
// Хорошо - одна аллокация
func collectGood() []int {
result := make([]int, 0, 1000) // предвыделили capacity
for i := 0; i < 1000; i++ {
result = append(result, i)
}
return result
}
Техника 4: Избегайте интерфейсов для примитивов
// Плохо - int boxing на кучу
func sumBad(values []interface{}) int {
sum := 0
for _, v := range values {
sum += v.(int) // boxing/unboxing
}
return sum
}
// Хорошо - нативный тип
func sumGood(values []int) int {
sum := 0
for _, v := range values {
sum += v
}
return sum
}
Memory Model
Go Memory Model определяет правила видимости изменений памяти между goroutines.
Happens-Before отношение
Событие A "happens-before" события B означает, что изменения, сделанные до A, видны после B.
Правило 1: Инициализация
var a string
func init() {
a = "hello" // happens-before main
}
func main() {
fmt.Println(a) // гарантированно увидит "hello"
}
Правило 2: Goroutine creation
var message string
func main() {
message = "hello"
go func() {
// Гарантированно увидит message = "hello"
// потому что присваивание happens-before go statement
fmt.Println(message)
}()
time.Sleep(time.Second)
}
Правило 3: Channel operations
var c = make(chan int)
var message string
func main() {
go func() {
message = "hello" // 1. Присваивание
c <- 1 // 2. Отправка в канал
}()
<-c // 3. Получение из канала
fmt.Println(message) // 4. Чтение
// 1 happens-before 2
// 2 happens-before 3 (channel synchronization)
// 3 happens-before 4
// Следовательно: 1 happens-before 4
// Гарантированно увидим "hello"
}
Правило 4: Mutex
var mu sync.Mutex
var data int
func write() {
mu.Lock()
data = 42 // happens-before Unlock
mu.Unlock()
}
func read() {
mu.Lock() // happens-after любой предыдущий Unlock
fmt.Println(data)
mu.Unlock()
}
Опасный код без синхронизации
var data int
var ready bool
// Goroutine 1
func writer() {
data = 42 // Может быть переупорядочено!
ready = true
}
// Goroutine 2
func reader() {
for !ready { // Может не увидеть ready = true
// spin
}
fmt.Println(data) // Может увидеть data = 0 !!!
}
Проблема: компилятор и процессор могут переупорядочить операции!
Решение: используйте синхронизацию:
var data int
var mu sync.Mutex
func writer() {
mu.Lock()
data = 42
mu.Unlock()
}
func reader() {
mu.Lock()
fmt.Println(data) // Гарантированно видит корректное значение
mu.Unlock()
}
Data Race
Data Race возникает, когда:
- Две goroutines обращаются к одной переменной
- Хотя бы один доступ — запись
- Нет синхронизации между доступами
Пример Data Race
package main
import "fmt"
func main() {
counter := 0
// Запускаем 1000 goroutines
for i := 0; i < 1000; i++ {
go func() {
counter++ // DATA RACE!
}()
}
time.Sleep(time.Second)
fmt.Println(counter) // Непредсказуемый результат
}
Обнаружение Data Race
go run -race main.go
Вывод:
==================
WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
main.main.func1()
/path/main.go:10 +0x3e
Previous write at 0x00c000018090 by goroutine 6:
main.main.func1()
/path/main.go:10 +0x3e
==================
Исправление Data Race
Вариант 1: Mutex
var counter int
var mu sync.Mutex
for i := 0; i < 1000; i++ {
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
}
Вариант 2: Atomic операции
var counter int64
for i := 0; i < 1000; i++ {
go func() {
atomic.AddInt64(&counter, 1)
}()
}
Вариант 3: Channel
done := make(chan bool)
counter := 0
go func() {
for range done {
counter++
}
}()
for i := 0; i < 1000; i++ {
done <- true
}
Практические советы
1. Профилируйте аллокации
import "testing"
func BenchmarkExample(b *testing.B) {
b.ReportAllocs() // Покажет количество аллокаций
for i := 0; i < b.N; i++ {
// ваш код
}
}
Запуск:
go test -bench=. -benchmem
2. Используйте pprof для анализа heap
import _ "net/http/pprof"
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ваш код
}
Анализ:
go tool pprof http://localhost:6060/debug/pprof/heap
3. Мониторьте размер стека goroutines
import "runtime/debug"
func main() {
debug.SetMaxStack(1024 * 1024 * 100) // 100 МБ на goroutine
}
4. Используйте инлайнинг
Компилятор может встроить маленькие функции, избегая создания stack frame:
//go:inline
func add(a, b int) int {
return a + b
}
Чек-лист оптимизации памяти
✅ Возвращайте значения вместо указателей для маленьких структур
✅ Предвыделяйте слайсы с известным capacity
✅ Используйте sync.Pool для часто создаваемых объектов
✅ Избегайте интерфейсов для примитивов в горячих путях
✅ Проверяйте escape analysis с -gcflags="-m"
✅ Используйте race detector в разработке
✅ Профилируйте с помощью pprof
✅ Избегайте глобальных переменных с указателями (они всегда на куче)
Заключение
Понимание работы памяти в Go критично для производительности:
Stack:
- Быстрый, автоматический
- Ограничен размером
- Идеален для локальных переменных
Heap:
- Гибкий, большой
- Требует GC
- Используется для данных с неизвестным временем жизни
Escape Analysis:
- Компилятор решает автоматически
- Можно оптимизировать, понимая правила
- Проверяйте с
-gcflags="-m"
Memory Model:
- Определяет видимость между goroutines
- Используйте синхронизацию (mutex, channels, atomic)
- Всегда используйте race detector
Золотое правило: пишите сначала простой и понятный код, потом профилируйте и оптимизируйте узкие места!