• разница не аппаратная (одна планка RAM), а программная: абстракции ОС
  • стек быстрее: простой алгоритм, кэш-дружелюбность, нет фрагментации, нет GC, нет syscall'ов
  • компилятор кладёт на стек всё что может, на кучу — только если обязан (escape analysis)
СтекКуча
скорость аллокациибыстро (сдвиг указателя)медленно (поиск блока)
освобождениеавтоматически при returnGC
время жизнипока функция выполняетсяпока есть ссылки
размер2KB -> до ~1GBограничен RAM
доступтолько своя горутиналюбая горутина
синхронизацияне нужнанужна (локи в аллокаторе)

Когда что:

go
func stack() { x := 42 // стек: локальная, не escape arr := [100]int{} // стек: фиксированный размер, не escape } func heap() *int { x := 42 return &x // куча: указатель уходит наверх } func alsoHeap() { s := make([]int, n) // куча: размер известен только в runtime var i interface{} = 42 // куча: boxing в interface go func() { fmt.Println(x) // куча: x захвачен closure }() }

Правило: компилятор кладет на стек всё что может. На кучу -- только если обязан (escape analysis).


  • область памяти для переменных, пока функция выполняется: вызов = создание frame, return = frame исчез
  • аллокация = сдвиг stack pointer, освобождение = сдвиг обратно, GC не нужен
  • каждая горутина имеет свой стек (начинается с 2KB, растёт автоматически)
  • что попадает на стек: локальные переменные с известным размером, аргументы функции, адрес возврата

Стек -- область памяти где живут переменные пока функция выполняется. Вызвал функцию -- создался frame с её переменными. Функция вернулась -- frame исчез, память свободна.

text
stack frame (создается при каждом вызове функции) +------------------------+ | local variables | локальные переменные (sum, temp, ...) +------------------------+ | arguments | аргументы функции (a, b, ...) +------------------------+ | return address | куда вернуться после return +------------------------+

Что попадает на стек:

  • локальные переменные с известным размером
  • аргументы функции
  • адрес возврата

Почему быстро:

Аллокация = сдвинуть указатель. Освобождение = сдвинуть указатель обратно. Не нужен GC.

Каждая горутина имеет свой стек, начинается с 2KB и растет при необходимости.


  • куча = область памяти для данных, которые не могут жить на стеке
  • общая для всех горутин (в отличие от стека — у каждой горутины свой)
  • медленнее стека: сложный поиск блока + локи + GC для освобождения

Куча — область памяти для данных, которые не могут жить на стеке.

Куча общая для всех горутин, в отличие от стека — у каждой горутины свой.

Почему медленнее стека:

Сложный поиск свободного блока (не просто сдвиг указателя).

Нужна синхронизация: куча общая → локи.

Нужен GC для освобождения: стек освобождается при return, куча — нет.


  • escape analysis: компилятор строит взвешенный граф и решает — стек или куча, на этапе компиляции
  • правила одинаковы для ВСЕХ типов: интерфейсы, структуры, примитивы, new() — без исключений
  • два условия хипа:
    1. ссылка переживает scope,
    2. объект слишком большой
      1. maxStackVarSize — для обычных переменных (~10MB).
      2. maxImplicitStackVarSize — для reference types, создаваемых через указатель/make (64KB).
  • go build -gcflags="-m" main.go - посмотреть решение компилятора, есть флаги -m (подробно) и -l (без inlinging)

Анализ компилятора на этапе компиляции: переменная живёт на стеке или на куче.

Меньше escapes → меньше аллокаций на куче → реже запускается GC → быстрее.

Что было бы без escape analysis

Как в C: return &x из функции → x жила на стеке → стек разрушился → dangling pointer → segfault. В Go компилятор видит что &x уходит наверх и сам переносит x на кучу. Поэтому return &x в Go безопасно.

Два условия escape на кучу

1. Компилятор не может доказать, что на объект не ссылаются после выхода из функции:

go
func f() *int { x := 42 return &x // moved to heap: ссылка уходит наверх }

2. Объект слишком большой для стека:

go
var a [10_000_000]int // moved to heap: > maxStackVarSize (~10MB) s := make([]int, 70000) // moved to heap: > maxImplicitStackVarSize (64KB)

maxStackVarSize — для обычных переменных (~10MB). maxImplicitStackVarSize — для reference types, создаваемых через указатель/make (64KB).

Правила одинаковы для ВСЕХ типов

go
// Интерфейсы НЕ всегда хип var x interface{} = 42 println(x) // ✅ стек: println не использует рефлексию fmt.Println(x) // ❌ хип: fmt использует рефлексию, // компилятор не может доказать безопасность
go
// new() НЕ всегда хип func f() { p := new(int) // ✅ стек: указатель не уходит из функции *p = 42 } func g() *int { p := new(int) // ❌ хип: указатель возвращается наверх return p }

Неважно: new, make, литерал, интерфейс — правила escape-анализа идентичны.

Факты о структурах и массивах

Если одно поле структуры → хип, вся структура → хип. Если один элемент массива/среза → хип, весь массив/срез → хип. Не может часть объекта жить на стеке, а часть в куче.

Как посмотреть решения компилятора

bash
go build -gcflags="-m" main.go # базовый вывод go build -gcflags="-m -m" main.go # подробный (почему решил) go build -gcflags="-m -l" main.go # -l отключает inlining (чище вывод) ./main.go:5:2: moved to heap: x # ушла на кучу ./main.go:9:6: x does not escape # осталась на стеке

  • принцип: компилятор не может доказать, что переменная не переживёт scope → куча
  • основные триггеры: return указателя, closure, interface boxing, канал, глобальная переменная, слайс, слишком большой объект, запись указателя в map/slice
  • make([]int, n) с runtime-размером → куча; make([]int, 3) с константой → стек

Принцип: если компилятор не может доказать что переменная не переживёт свой scope — она уходит на кучу.

go
// Указатель уходит из функции func f() *int { x := 42 return &x // escape: указатель переживёт стек функции } // Return указателя на локальную переменную → escape: указатель // переживёт стек функции.
go
// Closure захватывает переменную func f() { x := 42 go func() { fmt.Println(x) // escape: горутина может жить дольше f() }() } //Closure (особенно в горутине) захватывает переменную → escape: //горутина может жить дольше f().
go
// Interface boxing var i interface{} = 42 // escape: компилятор не знает размер за интерфейсом fmt.Println(x) // то же самое: аргументы упаковываются в interface{} // любой вызов fmt.Print/Println/Sprintf → куча

Interface boxing → escape: компилятор не знает тип/размер за интерфейсом. Любой вызов fmt.Print/Println/Sprintf → куча.

go
// Слайс vs массив s := []int{1, 2, 3} // escape: слайс содержит указатель на underlying array a := [3]int{1, 2, 3} // НЕ escape: массив — value type, копируется целиком

Слайс → escape (содержит указатель на underlying array). Массив фиксированного размера → НЕ escape (value type, копируется).

go
// Слишком большой объект var a [10_000_000]int // escape: не влезает в стек, даже без return &a

Слишком большой объект → escape: не влезает в стек даже без return.

go
// Передача в канал ch <- &x // escape: компилятор не знает кто и когда прочитает

Передача указателя в канал → escape: компилятор не знает кто и когда прочитает.

go
// make с неизвестным размером s := make([]int, n) // escape: n известен только в runtime s := make([]int, 3) // НЕ escape: размер известен компилятору

make([]int, n) с runtime-размером → escape. make([]int, 3) с константой → НЕ escape.

go
// Запись в глобальную переменную var global *int func f() { x := 1 global = &x // escape: переживает scope функции }

Запись указателя в глобальную переменную → escape: переживает scope функции.

go
// Запись указателя в map/slice m[key] = &x // escape: компилятор не может отследить lifetime slice = append(slice, &x) // escape: то же самое

Запись указателя в map или slice → escape: компилятор не может отследить lifetime.


  • mcache — per-P, без локов. Большинство аллокаций здесь
  • mcentral — по одному на каждый size class (67 штук), с локами
  • mheap — один глобальный, раздаёт страницы из арен

Зачем три уровня:

Если все горутины лезут за памятью в одно место → contention (все ждут один лок). Решение — иерархия: сначала быстро, потом медленнее.

Почему mcache на P, а не на M (поток):

TCMalloc делал кэш per-thread. Go улучшил: привязал к P. Когда M блокируется на syscall, P с mcache переходит к другому M → память не простаивает.


Проблема без классов: Выделяешь 5, 20, 3 байта вперемешку. Удалил 20 — дыра. Кто-то просит 25 — не влезает. Фрагментация.

Решение: 67 фиксированных размеров блоков: 8B, 16B, 24B, 32B, 48B... до 32KB.

Объект попадает в ближайший подходящий класс — просишь 5B, получаешь блок 8B.

Блоки одного размера лежат вместе. Удалил блок 8B → на его место встанет любой другой 8B. Нет фрагментации.

Потери памяти: Минус — тратишь лишнее. Просил 5B, получил 8B → 3B пустуют.

Чем меньше объект относительно класса, тем больше потеря: 1B в классе 8B = 87%, 9B в классе 16B = 43%.

Чем крупнее класс — тем меньше потеря: 32KB = всего 6%.

Объекты > 32KB: Не помещаются ни в один класс. Аллоцируются напрямую из mheap страницами по 8KB.

Даже +1 байт сверх 32KB = дополнительная страница 8KB.

text
Класс Размер Max waste Пример 1 8B 87% 1B объект → 7B пустует 2 16B 43% 9B объект → 7B пустует 3 24B 29% 5 48B 31% 33B объект → 15B пустует 7 80B 19% 66B объект → 14B пустует 67 32KB 6%

  • Go просит у ОС память большими кусками — арены по 64MB
  • арена нарезается на страницы по 8KB
  • страницы объединяются в спаны (mspan)
  • каждый спан обслуживает один класс размеров

Почему арены, а не маленькие куски:

Каждый запрос к ОС = syscall = дорого. Лучше попросить 64MB один раз, чем 1KB тысячу раз.

Цепочка:

text
Go запрашивает у ОС арену (64MB на Linux) → арена нарезается на страницы (8KB каждая) → страницы объединяются в спаны (mspan) → каждый спан обслуживает один size class

Спан (mspan) — ключевое понятие:

text
Спан для класса 8B (1 страница = 8KB): [8B][8B][8B][8B][8B]...[8B] — 1024 блока по 8 байт Спан для класса 24B (1 страница = 8KB): [24B][24B][24B]...[24B] — 341 блок по 24 байт Спан для класса 32KB (4 страницы = 32KB): [32KB] — 1 блок

Спан = группа страниц, нарезанная на одинаковые блоки. Размер блока = класс размеров.


  • CPU читает блоками по 4/8 байт: данные на «кривом» адресе = два чтения или ошибка
  • компилятор вставляет padding (пустые байты) для выравнивания полей
  • порядок полей влияет на размер структуры: сортируй от большего к меньшему
  • размер структуры должен быть кратен выравниванию самого большого поля (для корректной работы массивов)
text
тип размер выравнивание (адрес должен быть кратен) ------ ------ ----------------------------------------- bool 1 1 (любой адрес ок) int8 1 1 int16 2 2 (адреса 0, 2, 4, 6, 8...) int32 4 4 (адреса 0, 4, 8, 12...) int64 8 8 (адреса 0, 8, 16, 24...) pointer 8 8

Почему нужно выравнивание

CPU не читает память по одному байту -- он читает блоками по 4 или 8 байт. Если int64 лежит на адресе кратном 8 -- одно чтение. Если на "кривом" адресе -- два чтения и склейка, или вообще ошибка.

text
Память (каждая ячейка = 1 байт): адрес: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [--------блок 1--------][--------блок 2-------] int64 на адресе 0: читается за 1 раз (адрес кратен 8) int64 на адресе 3: частично в блоке 1, частично в блоке 2 -- плохо

Padding

Поэтому компилятор вставляет пустые байты (padding):

go
type Bad struct { a bool // 1 байт, адрес 0 b int64 // 8 байт, но адрес 1 не кратен 8! } // Компилятор делает так: // a [1 байт] адрес 0 // pad [7 байт] адреса 1-7 (пустота) // b [8 байт] адрес 8 (кратен 8, ок) // итого: 16 байт вместо 9

Влияние порядка полей на размер структуры

Порядок полей влияет на размер:

go
type Bad struct { // 24 байта a bool // 0: [a][ ][ ][ ][ ][ ][ ][ ] b int64 // 8: [b][b][b][b][b][b][b][b] c bool // 16:[c][ ][ ][ ][ ][ ][ ][ ] } type Good struct { // 16 байт b int64 // 0: [b][b][b][b][b][b][b][b] a bool // 8: [a] c bool // 9: [c][ ][ ][ ][ ][ ][ ] }

Правило: сортируй поля от большего к меньшему -- меньше padding, меньше памяти.

Кратность размера структуры выравниванию

go
type A struct { a int64 // 0-7 b int32 // 8-11 } // Данные = 12 байт. Самое большое поле int64 → кратно 8 // unsafe.Sizeof(A{}) = 16 (12 → 16) type B struct { a int32 // 0-3 b int16 // 4-5 } // Данные = 6 байт. Самое большое поле int32 → кратно 4 // unsafe.Sizeof(B{}) = 8 (6 → 8)

Зачем размер структуры должен быть кратен выравниванию самого большого поля — для корректной работы массивов:

text
Зачем: массив [2]A С padding (размер 16): элемент 0: адреса 0-15 элемент 1: адреса 16-31 → int64 на адресе 16, кратен 8 → ок Без padding (размер 12): элемент 0: адреса 0-11 элемент 1: адреса 12-23 → int64 на адресе 12, не кратен 8 → сломано

Как проверить

Как проверить:

go
unsafe.Sizeof(Bad{}) // 24 unsafe.Sizeof(Good{}) // 16 unsafe.Alignof(x) // требование выравнивания типа unsafe.Offsetof(s.field) // смещение поля от начала структуры

  • стек горутины: 2KB → растёт x2 при нехватке, сужается x2 при использовании <25%
  • contiguous stacks (Go 1.4+): копирование в новый блок + обновление указателей (stack maps), нет hot split
  • uintptr не обновляется при переносе (семантически не указатель)
  • макс: ~1GB (64-bit), ~250MB (32-bit). Стек аллоцируется в хипе → может расти

Стек горутины начинается с 2KB и растет автоматически при нехватке места.

Как работает:

text
1. Компилятор вставляет проверку в начало каждой функции (prologue) func foo() { // [скрытый код] if SP < stackguard { runtime.morestack() } ... } 2. Если места мало -- runtime.morestack(): - аллоцирует новый стек (2x размер) - копирует данные со старого стека - обновляет все указатели на стек - очищает старый стек - продолжает выполнение

Компилятор вставляет проверку в prologue каждой функции: if SP < stackguard { runtime.morestack() }.

При вызове runtime.morestack(): аллоцируется новый стек размером 2x, копируются данные, обновляются все указатели, выполнение продолжается.

text
До роста: После роста: +--------+ 2KB +--------+ 4KB | frame1 | | frame1 | | frame2 | | frame2 | | frame3 | | frame3 | +--------+ <- мало | free | | free | +--------+ <- есть место

Почему копирование, а не сегменты:

Go 1.3 и раньше использовал segmented stacks (связный список сегментов).

Проблема segmented stacks — «hot split»: функция на границе стека постоянно аллоцирует/освобождает сегмент.

Go 1.4+: contiguous stacks (копирование) — дороже разово, но нет hot split.

Лимиты:

  • начальный размер: 2KB
  • максимум: ~1GB (64-bit), ~250MB (32-bit)
  • рост: x2 каждый раз

Указатели фиксятся автоматически:

go
func foo() { x := 42 bar(&x) // если стек вырастет во время bar(), адрес &x обновится }

Runtime знает где на стеке лежат указатели (stack maps от компилятора) и корректирует их при копировании.

uintptr не обновляется при переносе стека — это число, а не указатель.

Сужение стека:

Если горутина использует менее 25% стека, runtime сужает его в 2 раза.

Например: стек 8KB, используется <2KB → сузится до 4KB.


  • возврат значения из конструктора вместо указателя = стек вместо хипа
  • сигнатура Read(buf []byte) вместо Read() []byte = вызывающий контролирует аллокацию
  • вынос переменной из цикла: даже на стеке — сдвиг pointer + инициализация каждую итерацию
  • нельзя явно указать «аллоцируй на стеке», но можно подсказывать escape-анализу

Возврат значения vs указателя

go
// ❌ указатель → хип func NewUser() *User { return &User{Name: "Alice"} // moved to heap } // ✅ значение → стек (копия) func NewUser() User { return User{Name: "Alice"} // стек, копируется при return }

Бенчмарк: разница существенная (аллокация + нагрузка на GC).

Не значит что всегда нужно возвращать значение. Но если создаёте огромное количество объектов — стоит задуматься.

Сигнатура Read

go
// ❌ возвращаем срез → underlying array на хипе func Read(n int) []byte { buf := make([]byte, n) // ... fill buf ... return buf // срез содержит указатель → хип } // ✅ принимаем срез → вызывающий контролирует аллокацию func Read(buf []byte) int { // ... fill buf ... return len(buf) // буфер мог быть на стеке вызывающего }

Переменная в цикле

go
// ❌ каждую итерацию: сдвиг SP + инициализация for i := 0; i < 1000000; i++ { data := SomeStruct{} init(&data) process(&data) } // ✅ одна аллокация, реинициализация var data SomeStruct for i := 0; i < 1000000; i++ { init(&data) process(&data) }

Даже на стеке: каждая «аллокация» = сдвинуть stack pointer + занулить память. В горячем цикле это заметно.

Особенность escape-анализа: литерал vs точка

go
type S struct { Pointer *int } // ✅ стек — всё инициализируется в литерале number := 42 s := S{Pointer: &number} // ❌ хип — number escapes (особенность реализации анализа) number := 42 s := S{} s.Pointer = &number // moved to heap: number

Идентичный код, разный результат. Escape-анализ строит граф по-разному.

Правила могут меняться между версиями Go.

Главное правило

Не оптимизируй без необходимости. Читаемый код > оптимизированный. Эти приёмы — только когда профилирование показало проблему с аллокациями.


  • TCMalloc = Thread-Cache Malloc — аллокатор от Google
  • решает две проблемы обычного malloc: contention и фрагментация
  • Go взял обе идеи и улучшил: кэш на P вместо потока

Проблема 1: Contention

malloc: все потоки конкурируют за один глобальный лок.

→ TCMalloc: у каждого потока свой кэш. Большинство аллокаций без лока.

→ Go: кэш на P (не на поток), чтобы не тратить память на заблокированных потоках.

Проблема 2: Фрагментация

malloc: выделяет блоки произвольного размера → дыры разного размера → фрагментация.

→ TCMalloc: блоки фиксированных размеров (классы). Одинаковые рядом → нет фрагментации.

Почему Go не вызывает malloc напрямую:

Каждый вызов malloc = syscall = дорого. Go запрашивает у ОС сразу 64MB (арена) и раздаёт сам.