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

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 возникает, когда:

  1. Две goroutines обращаются к одной переменной
  2. Хотя бы один доступ — запись
  3. Нет синхронизации между доступами

Пример 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

Золотое правило: пишите сначала простой и понятный код, потом профилируйте и оптимизируйте узкие места!