Стек, куча и escape analysis
Стек и куча: два вида памяти
Каждая Go-программа работает с двумя принципиально разными областями памяти.
Стек (stack) — быстрый, автоматический:
- Каждая горутина имеет свой стек
- Начинается с ~2–8 КБ, растёт динамически до 1 ГБ
- Выделение и освобождение — просто сдвиг указателя стека
- Память освобождается автоматически при возврате из функции
Куча (heap) — гибкий, управляемый GC:
- Общая для всей программы
- Выделение дороже — нужно найти свободный блок
- Освобождение — задача сборщика мусора
- GC добавляет паузы и накладные расходы
Горутина A Горутина B
┌─────────┐ ┌─────────┐
│ стек │ │ стек │ Куча (общая)
│ func() │ │ func() │ ┌─────────────┐
│ main() │ │ main() │ │ объект 1 │
└─────────┘ └─────────┘ │ объект 2 │
│ ... │
└─────────────┘
Escape analysis: компилятор решает сам
В Go не нужно вручную выбирать — стек или куча. Компилятор делает escape analysis при компиляции и решает за тебя:
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' показывает решения компилятора:
go build -gcflags='-m' main.go
# или для запуска:
go run -gcflags='-m' main.gopackage 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. Возврат указателя на локальную переменную:
func newUser(name string) *User {
u := User{Name: name} // moved to heap
return &u
}2. Присваивание в интерфейс:
var i interface{} = 42 // 42 escapes to heap — boxing3. Передача в горутину:
x := 100
go func() {
fmt.Println(x) // x escapes to heap
}()4. Слайс или map с динамическим размером:
n := 1000
s := make([]int, n) // если n не константа — heap5. Замыкание захватывает переменную:
func makeAdder(x int) func(int) int {
return func(n int) int { return x + n } // x escapes to heap
}Оптимизация: уменьшаем heap-аллокации
Каждая heap-аллокация — потенциальная работа для GC. В горячих путях это важно.
Передавай по указателю крупные структуры, возвращай по значению мелкие:
// Плохо для горячего пути: аллокация при каждом вызове
func newPoint(x, y float64) *Point {
return &Point{x, y}
}
// Хорошо: стек, нет аллокации
func newPoint(x, y float64) Point {
return Point{x, y}
}Переиспользуй объекты через sync.Pool:
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:
// Плохо: 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):
// Утечка: горутина заблокирована навсегда
func leak() {
ch := make(chan int)
go func() {
val := <-ch // ждёт вечно — никто не пишет в ch
fmt.Println(val)
}()
// функция завершилась, ch никто не закрыл
// горутина занимает ~2-8KB стека и никогда не умрёт
}Каждая живая горутина удерживает свой стек в памяти. 10 000 зависших горутин — десятки мегабайт.
Исправление — всегда обеспечивай горутине способ завершиться:
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:
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. Глобальный кэш без ограничений:
var cache = map[string][]byte{}
func store(key string, data []byte) {
cache[key] = data // растёт бесконечно, GC не поможет
}Используй sync.Map с TTL или groupcache/ristretto для кэшей с ограничением.
Профилирование памяти
Когда нужно найти реальную утечку — pprof:
import (
"net/http"
_ "net/http/pprof"
)
func main() {
go http.ListenAndServe(":6060", nil)
// ... твой код
}# Снять профиль 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 помогает найти утечки горутин в тестах:
import "go.uber.org/goleak"
func TestMyFunc(t *testing.T) {
defer goleak.VerifyNone(t)
// ... тест
// goroutine leak будет обнаружен после теста
}