Стек или куча в Go: как аллокация struct влияет на GC

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему «просто struct» — это не так просто?

Вы когда-нибудь писали на Go и, глядя на свою программу, задавались вопросом: «А где же сейчас живет моя структура? На быстром и уютном стеке или в бескрайних просторах кучи под надзором сборщика мусора?». Этот вопрос мучает не только новичков. На Stack Overflow тема «Stack vs heap allocation of structs in Go, and how they relate to garbage collection» собрала тысячи просмотров. И это не случайно.

Кажущаяся простота Go скрывает мощный механизм — escape analysis. Это компиляторный детектив, который решает судьбу каждого объекта. От его решения зависит производительность вашего приложения и нагрузка на GC (Garbage Collector). В этом пошаговом туториале мы наденем шляпу детектива и разберемся:

  • Почему одна структура живет на стеке, а другая — в куче?
  • Как указатели и замыкания ломают идиллию стека.
  • Как это знание поможет вам писать быстрый и эффективный код на Go.

Стек vs Куча: Короткий ликбез

Прежде чем нырять в код, давайте освежим в памяти теорию. Не беспокойтесь, будет быстро и больно.

Стек (Stack)

  • Скорость: Молниеносный. Выделение памяти — это просто движение указателя вниз.
  • Жизненный цикл: Строго привязан к вызову функции. Живет, пока работает функция.
  • Управление: Автоматическое. «Сгорает» при выходе из функции.
  • Размер: Маленький и фиксированный на горутину (обычно 2-4 КБ).

Куча (Heap)

  • Скорость: Медленнее. Требуется поиск свободного блока памяти.
  • Жизненный цикл: Пока на объект есть ссылки из программы. Может пережить создавшую его функцию.
  • Управление: Сборщик мусора (GC). Он сканирует кучу, находит «мертвые» объекты и освобождает память. Это стоит ресурсов CPU.
  • Размер: Большой (ограничен только памятью ОС).

Золотое правило Go: «Держи данные на стеке как можно дольше». Это снижает нагрузку на GC и ускоряет программу.

Escape Analysis: Главный детектив Go

В Go вы не пишете new или malloc чтобы явно указать, где разместить объект. Компилятор сам решает это за вас. Этот процесс называется escape analysis (анализ ускользания).

Он смотрит на ваш код и задает один вопрос: «Может ли ссылка на этот объект покинуть текущую функцию?».

  • Если НЕТ (объект не ускользает) → Объект размещается на стеке.
  • Если ДА (объект ускользает) → Объект размещается в куче.

Давайте посмотрим на это в действии.

Пример 1: Безопасная структура на стеке

package main

import "fmt"

type Point struct {
    X, Y int
}

// createPoint создает Point и возвращает его по значению.
func createPoint(x, y int) Point {
    p := Point{X: x, Y: y}
    return p // p копируется при возврате
}

func main() {
    p1 := createPoint(10, 20)
    fmt.Println(p1)
}

В этом примере p — это локальная переменная. Она создается внутри createPoint и возвращается по значению (копируется). Сама структура p не может быть использована за пределами функции (её копия используется в main). Escape analysis видит это и радостно размещает p на стеке. GC отдыхает.

Пример 2: Ускользание через указатель

А теперь изменим всего одну строчку — вернем указатель.

package main

import "fmt"

type Point struct {
    X, Y int
}

// createPointer создает Point и возвращает указатель на него.
func createPointer(x, y int) *Point {
    p := Point{X: x, Y: y}
    return &p // ВНИМАНИЕ! Возвращаем адрес локальной переменной!
}

func main() {
    p2 := createPointer(10, 20)
    fmt.Println(p2.X, p2.Y)
}

Вот тут и происходит магия. После выхода из createPointer стековая память, где жила p, считается освобожденной. Но мы всё еще держим на неё указатель (p2) в main! Если бы Go разрешил это, мы бы получили висячий указатель (dangling pointer) — классическая проблема C/C++.

Go поступает умнее: компилятор видит, что переменная p «ускользает» за пределы своей функции. Он размещает её в куче. Теперь её жизнью управляет GC.

Это абсолютно корректный и безопасный код. Но он создает нагрузку на GC.

Практикум: Как увидеть, где что лежит?

Go предоставляет мощный инструмент для наблюдения за escape analysis. Запустите компилятор с флагом -gcflags '-m'.

Для первого примера (возврат по значению):

$ go build -gcflags '-m' main.go
# command-line-arguments
./main.go:9:6: can inline createPoint
./main.go:14:17: inlining call to createPoint
./main.go:9:13: moved to heap: p  <-- Ага! Вот и ложка дегтя.

Стоп! Почему p переместилась в кучу в первом примере? Потому что компилятор решил заинлайнить функцию createPoint. Это оптимизация производительности. Правило «не ускользает» работает, но только если функция не встроена. В реальности, даже для маленьких структур, компилятор может принять другое решение. Не стоит полагаться на 100% гарантию «стековости» без проверки.

Для второго примера (возврат указателя):

$ go build -gcflags '-m' main.go
# command-line-arguments
./main.go:9:13: moved to heap: p  <-- То же самое, но причина другая.
./main.go:14:17: inlining call to createPointer

В обоих случаях мы видим moved to heap. В первом — из-за инлайнинга, во втором — из-за ускользания указателя (даже если инлайнинг не сработал бы, оно всё равно было бы в куче).

Другие триггеры для ускользания в кучу

Возврат указа

Блоги

Рекомендаций по книгам