Модуль
1Указатели: основы2Value и pointer receivers3Устройство типов в памяти4Стек, куча и escape analysis← вы здесь
Урок 4~12 минут

Стек, куча и escape analysis

Стек и куча: два вида памяти

Каждая Go-программа работает с двумя принципиально разными областями памяти.

Стек (stack) — быстрый, автоматический:

  • Каждая горутина имеет свой стек
  • Начинается с ~2–8 КБ, растёт динамически до 1 ГБ
  • Выделение и освобождение — просто сдвиг указателя стека
  • Память освобождается автоматически при возврате из функции

Куча (heap) — гибкий, управляемый GC:

  • Общая для всей программы
  • Выделение дороже — нужно найти свободный блок
  • Освобождение — задача сборщика мусора
  • GC добавляет паузы и накладные расходы
Горутина A        Горутина B
┌─────────┐       ┌─────────┐
│  стек   │       │  стек   │       Куча (общая)
│ func()  │       │ func()  │      ┌─────────────┐
│  main() │       │  main() │      │  объект 1   │
└─────────┘       └─────────┘      │  объект 2   │
                                   │    ...      │
                                   └─────────────┘

Escape analysis: компилятор решает сам

В Go не нужно вручную выбирать — стек или куча. Компилятор делает escape analysis при компиляции и решает за тебя:

go
func stackExample() {
    x := 42       // x остаётся на стеке — не "убегает"
    fmt.Println(x)
} // x освобождается автоматически при выходе
 
func heapExample() *int {
    x := 42       // x "убегает" в heap!
    return &x     // возвращаем адрес — x должен пережить функцию
}

Во втором случае x должен жить после завершения heapExample — компилятор перемещает его в heap.


Смотрим на escape analysis

Флаг -gcflags='-m' показывает решения компилятора:

bash
go build -gcflags='-m' main.go
# или для запуска:
go run -gcflags='-m' main.go
go
package main
 
import "fmt"
 
func escape() *int {
    x := 42
    return &x
}
 
func noEscape() int {
    x := 42
    return x
}
 
func main() {
    p := escape()
    n := noEscape()
    fmt.Println(*p, n)
}

Вывод:

./main.go:6:2: moved to heap: x
./main.go:11:2: x does not escape
./main.go:17:13: ... argument does not escape

Читать просто: moved to heap — аллокация, does not escape — стек.


Что заставляет переменную "убежать" в heap

1. Возврат указателя на локальную переменную:

go
func newUser(name string) *User {
    u := User{Name: name}  // moved to heap
    return &u
}

2. Присваивание в интерфейс:

go
var i interface{} = 42  // 42 escapes to heap — boxing

3. Передача в горутину:

go
x := 100
go func() {
    fmt.Println(x)  // x escapes to heap
}()

4. Слайс или map с динамическим размером:

go
n := 1000
s := make([]int, n)  // если n не константа — heap

5. Замыкание захватывает переменную:

go
func makeAdder(x int) func(int) int {
    return func(n int) int { return x + n }  // x escapes to heap
}

Оптимизация: уменьшаем heap-аллокации

Каждая heap-аллокация — потенциальная работа для GC. В горячих путях это важно.

Передавай по указателю крупные структуры, возвращай по значению мелкие:

go
// Плохо для горячего пути: аллокация при каждом вызове
func newPoint(x, y float64) *Point {
    return &Point{x, y}
}
 
// Хорошо: стек, нет аллокации
func newPoint(x, y float64) Point {
    return Point{x, y}
}

Переиспользуй объекты через sync.Pool:

go
var bufPool = sync.Pool{
    New: func() any { return make([]byte, 0, 1024) },
}
 
func process(data []byte) {
    buf := bufPool.Get().([]byte)
    defer bufPool.Put(buf[:0])  // вернуть пул, сбросив длину
    // используем buf...
}

Предаллоцируй слайсы с нужной capacity:

go
// Плохо: N аллокаций при росте
result := []int{}
for _, v := range input {
    result = append(result, v*2)
}
 
// Хорошо: одна аллокация
result := make([]int, 0, len(input))
for _, v := range input {
    result = append(result, v*2)
}

Утечки памяти в Go

Go управляет памятью через GC, но утечки всё равно возможны — просто иначе.

1. Горутина-зомби (goroutine leak):

go
// Утечка: горутина заблокирована навсегда
func leak() {
    ch := make(chan int)
    go func() {
        val := <-ch  // ждёт вечно — никто не пишет в ch
        fmt.Println(val)
    }()
    // функция завершилась, ch никто не закрыл
    // горутина занимает ~2-8KB стека и никогда не умрёт
}

Каждая живая горутина удерживает свой стек в памяти. 10 000 зависших горутин — десятки мегабайт.

Исправление — всегда обеспечивай горутине способ завершиться:

go
func noLeak(ctx context.Context) {
    ch := make(chan int)
    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-ctx.Done():
            return  // завершаемся при отмене контекста
        }
    }()
}

2. Слайс удерживает большой backing array:

go
func getLargeData() []int {
    huge := make([]int, 1_000_000)
    // заполняем...
    return huge[:3]  // возвращаем 3 элемента, но весь массив в памяти!
}
 
// Исправление: copy освобождает оригинал
func getLargeData() []int {
    huge := make([]int, 1_000_000)
    result := make([]int, 3)
    copy(result, huge[:3])
    return result  // huge можно собрать GC
}

3. Глобальный кэш без ограничений:

go
var cache = map[string][]byte{}
 
func store(key string, data []byte) {
    cache[key] = data  // растёт бесконечно, GC не поможет
}

Используй sync.Map с TTL или groupcache/ristretto для кэшей с ограничением.


Профилирование памяти

Когда нужно найти реальную утечку — pprof:

go
import (
    "net/http"
    _ "net/http/pprof"
)
 
func main() {
    go http.ListenAndServe(":6060", nil)
    // ... твой код
}
bash
# Снять профиль heap
go tool pprof http://localhost:6060/debug/pprof/heap
 
# Найти горутины-зомби
go tool pprof http://localhost:6060/debug/pprof/goroutine
 
# В интерактивном режиме:
(pprof) top10       # топ 10 по памяти
(pprof) list main   # построчно по функции
(pprof) web         # граф в браузере

Инструмент goleak помогает найти утечки горутин в тестах:

go
import "go.uber.org/goleak"
 
func TestMyFunc(t *testing.T) {
    defer goleak.VerifyNone(t)
    // ... тест
    // goroutine leak будет обнаружен после теста
}
Компилятор Go сам решает — стек или куча. Используй go build -gcflags='-m' чтобы увидеть что «убежало» в heap. Чем меньше heap-аллокаций — тем меньше работы у GC.

Стек автоматически управляется компилятором. Куча управляется сборщиком мусора (GC).

СтекStack
+ Быстрый доступ (LIFO) + Автоматическое освобождение - Ограниченный размер
[top of stack]
Нет фреймов
[bottom of stack]
КучаHeap
+ Произвольный размер + Переживает функцию - Управляется GC (паузы)
Куча пуста
Объектов в heap: 0
🎯
Миссия 1 из 4
Где хранятся локальные переменные функции по умолчанию?