Go. Структуры данных
Указатели: как работает с памятью
Введение в работу с памятью
Представьте, что у вас есть два типа хранилищ: шкаф для одежды (стек) и склад для больших вещей (куча). В шкафу всё аккуратно сложено стопками — что положили последним, то первым и достаём. На складе же вещи лежат где попало, и нужна специальная система учёта, чтобы найти нужное и понять, когда можно выбросить ненужное.
Именно так работает память в Go. Когда вы создаёте переменные и используете указатели, Go автоматически решает, где разместить данные — на стеке или в куче. Понимание этого механизма поможет вам писать более эффективный код.
Давайте разберёмся, как Go управляет памятью и как указатели влияют на это управление.
Стек и куча: две области памяти
Что такое стек?
Стек (stack) — это область памяти, которая работает по принципу LIFO (Last In, First Out — «последним пришёл, первым ушёл»). Это как стопка тарелок: последнюю положенную тарелку снимаем первой.
package main

import "fmt"

func main() {
    // Эти переменные размещаются на стеке
    x := 10
    y := 20
    z := x + y
    
    fmt.Println(z) // 30
    
    // Когда функция завершится, все переменные автоматически удалятся со стека
}
Характеристики стека:
  • Очень быстрое выделение и освобождение памяти
  • Автоматическое управление (не требует сборщика мусора)
  • Последовательное размещение данных
  • Ограниченный размер
  • Данные живут только внутри функции
Что такое куча?
Куча (heap) — это область памяти для данных, которые должны жить дольше одного вызова функции или слишком велики для стека.
package main

import "fmt"

func createUser() *User {
    // Эта структура размещается в куче
    user := &User{
        Name: "Alice",
        Age:  30,
    }
    
    // Возвращаем указатель - данные останутся в памяти
    return user
}

type User struct {
    Name string
    Age  int
}

func main() {
    user := createUser()
    fmt.Println(user.Name) // Alice
    
    // Данные живут в куче, пока на них есть ссылки
}
Характеристики кучи:
  • Неограниченный размер (в пределах доступной памяти)
  • Данные живут независимо от вызовов функций
  • Можно разделять данные между функциями
  • Медленнее выделение и освобождение памяти
  • Требует сборщика мусора для очистки
Как Go решает, куда размещать данные
Escape Analysis
Go автоматически решает, где разместить данные — на стеке или в куче. Этот процесс называется «escape analysis».
Важно

Если данные «убегают» за пределы функции (escape), они размещаются в куче.
package main

import "fmt"

// Данные НЕ "убегают" - размещаются на стеке
func stackExample() {
    x := 42 // остаётся внутри функции
    fmt.Println(x)
}

// Данные "убегают" - размещаются в куче
func heapExample() *int {
    x := 42
    return &x // ← возвращаем указатель - данные "убегают"!
}

func main() {
    stackExample()
    
    ptr := heapExample()
    fmt.Println(*ptr)
}
Что здесь происходит?
  • В stackExample переменная x используется только внутри функции — размещается на стеке.
  • В heapExample мы возвращаем указатель на x — данные «убегают» и размещаются в куче.
  • Go автоматически определяет это во время компиляции.
Проверяем решения компилятора
Вы можете увидеть, какие переменные «убегают» в кучу, используя флаг -gcflags="-m":
go build -gcflags="-m" main.go
package main

func stackOnly() {
    x := 10
    y := 20
    _ = x + y
}

func escapeToHeap() *int {
    x := 42
    return &x
}

func main() {
    stackOnly()
    escapeToHeap()
}
Вывод компилятора:
./main.go:8:2: moved to heap: x
Компилятор сообщает, что переменная x в функции escapeToHeap перемещена в кучу.
Когда данные размещаются в куче
Большие данные
Когда размер данных превышает определённый порог, Go автоматически размещает их в куче, даже если нет явного возврата указателя.
package main

func smallData() {
    // Маленькие данные - размещаются на стеке
    arr := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    _ = arr
}

func largeData() {
    // Большие данные - размещаются в куче
    arr := [1000000]int{} // ~8MB
    _ = arr
}

func main() {
    smallData()
    largeData()
}
Почему большие данные сразу идут в кучу?
Стек каждой горутины в Go начинается с небольшого размера (2KB в современных версиях) и может динамически расти по мере необходимости. Однако, если компилятор видит, что структура слишком велика, он автоматически размещает её в куче, чтобы избежать переполнения стека и его частых перевыделений.
Важно

Очень большие структуры автоматически размещаются в куче компилятором, даже если на них нет указателей. Конкретного порога нет — это решение компилятора на основе escape analysis и размера стека.
Работа памяти с замыканиями
Замыкание (closure) — это функция, которая захватывает переменные из внешней области видимости. Важно понимать, что замыкание — это отдельная функция, но она «помнит» переменные из места своего создания.
package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func main() {
    // Создаём структуру
    p := Person{Name: "Alice", Age: 30}
    
    // Создаём указатель на структуру
    ptr := &p
    
    // Два способа доступа к полям через указатель:
    fmt.Println((*ptr).Name) // Классический способ со скобками
    fmt.Println(ptr.Name)    // Go позволяет опускать * и скобки (синтаксический сахар)
    
    // Изменение полей через указатель
    ptr.Age = 31
    fmt.Println(p.Age) // 31 (оригинальная структура изменилась)
}
Что здесь происходит?
  • Функция makeCounter создаёт переменную count.
  • Внутренняя анонимная функция захватывает эту переменную.
  • Когда makeCounter возвращает эту внутреннюю функцию, count должна продолжать существовать.
  • Go видит, что count нужна за пределами makeCounter, поэтому размещает её в куче.
  • Память с count будет очищена только когда сборщик мусора увидит, что на функцию counter больше нет ссылок.
Почему это логично?
Замыкание создаёт ситуацию, где несколько функций делят (share) общую память. Переменная count должна быть доступна и из makeCounter, и из возвращённой функции. Такой шаринг памяти между функциями возможен только через кучу.
Сборщик мусора и указатели
Как работает сборщик мусора
Сборщик мусора (Garbage Collector, GC) в Go автоматически удаляет данные из кучи, когда на них больше нет ссылок.
package main

import (
    "fmt"
    "runtime"
)

type Data struct {
    Value [1000]int // ~8KB данных
}

func createGarbage() {
    // Создаём данные в куче
    data := &Data{}
    data.Value[0] = 42
    
    fmt.Println("Данные созданы")
    
    // Когда функция завершится, на data больше нет ссылок
    // Сборщик мусора сможет удалить эти данные
}

func main() {
    for i := 0; i < 10; i++ {
        createGarbage()
    }
    
    // Принудительный запуск сборщика мусора (обычно не нужен)
    runtime.GC()
    
    fmt.Println("Сборка мусора выполнена")
}
Трёхцветная разметка
Go использует алгоритм трёхцветной разметки (tri-color marking) для определения того, какие объекты ещё используются, а какие можно удалить.
Как это работает?
Все объекты в памяти условно раскрашиваются в три цвета:
  • Белый
    Объекты, которые, возможно, не используются (кандидаты на удаление)
  • Серый
    Объекты, которые используются, но их связи ещё не проверены
  • Чёрный
    Объекты, которые точно используются, и все их связи проверены
Корневые объекты (roots)
Корневые объекты — это точки входа, с которых начинается поиск живых объектов:
  • Глобальные переменные
    Объекты, доступные из любого места программы
  • Локальные переменные
    на стеке
    Все переменные во всех активных функциях всех горутин
  • Переменные, на которые
    указывают регистры
    Данные, с которыми процессор работает прямо сейчас
Проще говоря, корни — это всё, к чему ваша программа может обратиться напрямую в данный момент.
Процесс разметки:
Начальное состояние:
├─ Корневые объекты → серые
└─ Все остальные объекты → белые

Шаг 1: Берём серый объект
├─ Проверяем все указатели из этого объекта
├─ Белые объекты, на которые он ссылается, красим в серый
└─ Сам объект красим в чёрный

Шаг 2: Повторяем, пока есть серые объекты

Результат:
├─ Чёрные объекты — оставляем в памяти
└─ Белые объекты — удаляем (на них нет ссылок)
Важные правила разметки:
  • В рамках одного цикла GC: каждый объект красится один раз (Белый → Серый → Чёрный).
  • Чёрные объекты не перекрашиваются в текущем цикле сборки.
  • Инвариант: чёрный объект никогда не указывает напрямую на белый объект.
Между циклами GC:
Цвета — это временные метки только для одного цикла сборки мусора. Когда начинается новый цикл:
  • Все цветовые метки сбрасываются.
  • Корневые объекты снова помечаются как серые.
  • Все остальные объекты снова становятся белыми.
  • Процесс разметки начинается заново.
  • Объект, бывший чёрным в прошлом цикле, может стать белым (если на него больше нет ссылок) или снова будет найден и перекрашен в чёрный.
Конкурентная сборка мусора:
Go использует конкурентный GC — он работает параллельно с программой. Это создаёт проблему: что если программа изменит граф объектов во время разметки?
Например, чёрный объект получает ссылку на белый объект. Чтобы это не привело к ошибочному удалению белого объекта, Go использует write barrier (барьер записи) — специальный механизм, который отслеживает такие изменения и помечает «проблемные» объекты как серые.
Stop The World (STW)
Во время некоторых фаз сборки мусора программа приостанавливается — это называется Stop The World (STW).
Зачем нужна остановка?
Представьте, что вы пытаетесь пересчитать книги в библиотеке, но люди постоянно берут и возвращают книги. Невозможно получить точный результат! Поэтому на короткое время нужно «заморозить» мир — остановить все горутины.
Когда происходит STW?
Go старается минимизировать паузы STW:
  • Короткая пауза в начале 
    Для инициализации процесса разметки
  • Короткая пауза в конце
    Для завершения разметки и очистки памяти
  • Основная работ
    Происходит конкурентно (concurrent), то есть параллельно с работой программы
Важно

В современных версиях Go паузы STW обычно составляют менее 1 миллисекунды, что подходит для большинства приложений.
Циклические ссылки не вызывают утечек памяти
Что такое циклические ссылки?
Циклическая ссылка возникает, когда два или более объекта ссылаются друг на друга, образуя цикл. Например, объект A указывает на B, а B указывает обратно на A.
Почему это может быть проблемой?
В некоторых языках программирования (например, Python, Swift) используется подсчёт ссылок (reference counting) для управления памятью. В этом подходе считается, сколько ссылок указывает на каждый объект. Когда счётчик становится равным нулю — объект удаляется.
Проблема

Если A и B ссылаются друг на друга, их счётчики никогда не станут нулём, даже если программа больше не может до них добраться. Это приводит к утечке памяти.
В Go это не проблема!
Трёхцветная разметка работает иначе — она проверяет достижимость объектов от корней, а не считает ссылки. Если на группу объектов с циклическими ссылками нет путей от корней — они все будут помечены белым цветом и удалены.
package main

type Node struct {
    Value int
    Next  *Node
    Prev  *Node
}

func createCycle() *Node {
    // Создаём двусвязный список
    first := &Node{Value: 1}
    second := &Node{Value: 2}
    
    // Циклические ссылки
    first.Next = second
    second.Prev = first
    
    return first // ← данные "убегают" в кучу
}

func main() {
    // Получаем указатель на список с циклическими ссылками
    head := createCycle()
    
    // Всё в порядке - сборщик мусора справится с циклами
    head = nil
    
    // Оба узла будут удалены, несмотря на циклические ссылки,
    // так как они недостижимы из корневых указателей
}
Производительность и оптимизация
Стоимость аллокаций в куче
package main

import (
    "testing"
)

var globalPtr *int // глобальная переменная для "убегания" данных

// Размещение на стеке - быстро
func BenchmarkStack(b *testing.B) {
    for i := 0; i < b.N; i++ {
        x := 42
        _ = x
    }
}

// Размещение в куче - медленнее
func BenchmarkHeap(b *testing.B) {
    for i := 0; i < b.N; i++ {
        x := new(int)
        *x = 42
        globalPtr = x // ← данные "убегают" в глобальную переменную
    }
}
Результаты (примерные):
BenchmarkStack-8    1000000000    0.25 ns/op    0 B/op    0 allocs/op
BenchmarkHeap-8      50000000    30.00 ns/op    8 B/op    1 allocs/op
Аллокация в куче примерно в 100 раз медленнее!
Когда использовать указатели для производительности
Подводный камень: утечка памяти через слайсы
Один из неочевидных случаев, связанных с памятью — маленький слайс может удерживать большой базовый массив.
package main

import "fmt"

// ❌ Плохо: маленький слайс удерживает весь большой массив
func getHeaderBad(data []byte) []byte {
    // Читаем большой файл (10MB)
    // Но нам нужны только первые 100 байт (заголовок)
    header := data[:100]
    
    // Проблема: header хранит указатель на весь массив data!
    // Даже если data выйдет из области видимости, память не освободится,
    // пока существует header
    return header
}

func processBad() []byte {
    // Загружаем 10MB данных
    bigData := make([]byte, 10*1024*1024)
    // ... читаем из файла ...
    
    // Извлекаем заголовок
    header := getHeaderBad(bigData)
    
    // bigData выходит из области видимости, НО
    // header всё ещё ссылается на базовый массив 10MB!
    return header // проблема: 100 байт удерживают 10MB
}

// ✅ Хорошо: создаём копию нужных данных
func getHeaderGood(data []byte) []byte {
    // Создаём новый независимый слайс
    header := make([]byte, 100)
    copy(header, data[:100])
    
    // Теперь header не связан с data
    return header
}

func processGood() []byte {
    // Загружаем 10MB данных
    bigData := make([]byte, 10*1024*1024)
    // ... читаем из файла ...
    
    // Извлекаем заголовок с копированием
    header := getHeaderGood(bigData)
    
    // bigData выходит из области видимости и может быть удалён GC
    return header // ✅ возвращаем только 100 байт
}

func main() {
    header := processGood()
    fmt.Printf("Обработан заголовок: %d байт\n", len(header))
    
    // Теперь в памяти только 100 байт, а не 10MB
}
Что здесь происходит?
Слайс в Go хранит три значения: указатель на базовый массив, длину и ёмкость. Когда вы создаёте подслайс (sub-slice) с помощью data[:100], новый слайс ссылается на тот же базовый массив. Даже если вам нужно всего 100 байт, в памяти остаётся весь массив на 10MB!
Как избежать?
Если вам нужна только часть данных, создавайте копию с помощью copy () — это освободит память от большого массива.
Заключение
Теперь вы понимаете, как Go управляет памятью и как указатели влияют на размещение данных.
Ключевые моменты:
  • Стек
    Быстрая память для локальных данных функций, управляется автоматически
  • Куча
    Динамическая память для долгоживущих и больших данных, очищается сборщиком мусора
  • Escape analysis
    Go автоматически решает, куда размещать данные во время компиляции
  • Данные «убегают» в кучу
    Если возвращается указатель на них или они захватываются замыканием
  • Большие структуры
    Автоматически размещаются в куче компилятором
  • Трёхцветная разметка
    Алгоритм, который определяет, какие объекты можно удалить
  • Stop The World
    Короткие паузы GC (обычно <1мс) для инициализации и завершения сборки мусора
  • Циклические ссылки
    Не вызывают проблем — GC корректно их обрабатывает
Что дальше?
В следующих темах вы узнаете:
  • Указатели: unsafe/weak — работа с небезопасными указателями.
  • Массивы: объявление, инициализация, базовые операции.
  • Массивы: как работает с памятью.
  • Слайсы: внутреннее устройство.
Вы только что освоили фундаментальные принципы управления памятью в Go. Это знание поможет вам понимать, что происходит «под капотом», и писать более эффективный код!