- разница не аппаратная (одна планка RAM), а программная: абстракции ОС
- стек быстрее: простой алгоритм, кэш-дружелюбность, нет фрагментации, нет GC, нет syscall'ов
- компилятор кладёт на стек всё что может, на кучу — только если обязан (escape analysis)
| Стек | Куча | |
|---|---|---|
| скорость аллокации | быстро (сдвиг указателя) | медленно (поиск блока) |
| освобождение | автоматически при return | GC |
| время жизни | пока функция выполняется | пока есть ссылки |
| размер | 2KB -> до ~1GB | ограничен RAM |
| доступ | только своя горутина | любая горутина |
| синхронизация | не нужна | нужна (локи в аллокаторе) |
Когда что:
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 исчез, память свободна.
stack frame (создается при каждом вызове функции)
+------------------------+
| local variables | локальные переменные (sum, temp, ...)
+------------------------+
| arguments | аргументы функции (a, b, ...)
+------------------------+
| return address | куда вернуться после return
+------------------------+
Что попадает на стек:
- локальные переменные с известным размером
- аргументы функции
- адрес возврата
Почему быстро:
Аллокация = сдвинуть указатель. Освобождение = сдвинуть указатель обратно. Не нужен GC.
Каждая горутина имеет свой стек, начинается с 2KB и растет при необходимости.
- куча = область памяти для данных, которые не могут жить на стеке
- общая для всех горутин (в отличие от стека — у каждой горутины свой)
- медленнее стека: сложный поиск блока + локи + GC для освобождения
Куча — область памяти для данных, которые не могут жить на стеке.
Куча общая для всех горутин, в отличие от стека — у каждой горутины свой.
Почему медленнее стека:
Сложный поиск свободного блока (не просто сдвиг указателя).
Нужна синхронизация: куча общая → локи.
Нужен GC для освобождения: стек освобождается при return, куча — нет.
- escape analysis: компилятор строит взвешенный граф и решает — стек или куча, на этапе компиляции
- правила одинаковы для ВСЕХ типов: интерфейсы, структуры, примитивы,
new()— без исключений - два условия хипа:
- ссылка переживает scope,
- объект слишком большой
maxStackVarSize— для обычных переменных (~10MB).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. Компилятор не может доказать, что на объект не ссылаются после выхода из функции:
func f() *int {
x := 42
return &x // moved to heap: ссылка уходит наверх
}
2. Объект слишком большой для стека:
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).
Правила одинаковы для ВСЕХ типов
// Интерфейсы НЕ всегда хип
var x interface{} = 42
println(x) // ✅ стек: println не использует рефлексию
fmt.Println(x) // ❌ хип: fmt использует рефлексию,
// компилятор не может доказать безопасность
// new() НЕ всегда хип
func f() {
p := new(int) // ✅ стек: указатель не уходит из функции
*p = 42
}
func g() *int {
p := new(int) // ❌ хип: указатель возвращается наверх
return p
}
Неважно: new, make, литерал, интерфейс — правила escape-анализа идентичны.
Факты о структурах и массивах
Если одно поле структуры → хип, вся структура → хип. Если один элемент массива/среза → хип, весь массив/срез → хип. Не может часть объекта жить на стеке, а часть в куче.
Как посмотреть решения компилятора
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 — она уходит на кучу.
// Указатель уходит из функции
func f() *int {
x := 42
return &x // escape: указатель переживёт стек функции
}
// Return указателя на локальную переменную → escape: указатель
// переживёт стек функции.
// Closure захватывает переменную
func f() {
x := 42
go func() {
fmt.Println(x) // escape: горутина может жить дольше f()
}()
}
//Closure (особенно в горутине) захватывает переменную → escape:
//горутина может жить дольше f().
// Interface boxing
var i interface{} = 42 // escape: компилятор не знает размер за интерфейсом
fmt.Println(x) // то же самое: аргументы упаковываются в interface{}
// любой вызов fmt.Print/Println/Sprintf → куча
Interface boxing → escape: компилятор не знает тип/размер за интерфейсом. Любой вызов fmt.Print/Println/Sprintf → куча.
// Слайс vs массив
s := []int{1, 2, 3} // escape: слайс содержит указатель на underlying array
a := [3]int{1, 2, 3} // НЕ escape: массив — value type, копируется целиком
Слайс → escape (содержит указатель на underlying array). Массив фиксированного размера → НЕ escape (value type, копируется).
// Слишком большой объект
var a [10_000_000]int // escape: не влезает в стек, даже без return &a
Слишком большой объект → escape: не влезает в стек даже без return.
// Передача в канал
ch <- &x // escape: компилятор не знает кто и когда прочитает
Передача указателя в канал → escape: компилятор не знает кто и когда прочитает.
// make с неизвестным размером
s := make([]int, n) // escape: n известен только в runtime
s := make([]int, 3) // НЕ escape: размер известен компилятору
make([]int, n) с runtime-размером → escape. make([]int, 3) с константой → НЕ escape.
// Запись в глобальную переменную
var global *int
func f() {
x := 1
global = &x // escape: переживает scope функции
}
Запись указателя в глобальную переменную → escape: переживает scope функции.
// Запись указателя в 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.
Класс Размер 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 тысячу раз.
Цепочка:
Go запрашивает у ОС арену (64MB на Linux)
→ арена нарезается на страницы (8KB каждая)
→ страницы объединяются в спаны (mspan)
→ каждый спан обслуживает один size class
Спан (mspan) — ключевое понятие:
Спан для класса 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 (пустые байты) для выравнивания полей
- порядок полей влияет на размер структуры: сортируй от большего к меньшему
- размер структуры должен быть кратен выравниванию самого большого поля (для корректной работы массивов)
тип размер выравнивание (адрес должен быть кратен)
------ ------ -----------------------------------------
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 -- одно чтение. Если на "кривом" адресе -- два чтения и склейка, или вообще ошибка.
Память (каждая ячейка = 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):
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
Влияние порядка полей на размер структуры
Порядок полей влияет на размер:
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, меньше памяти.
Кратность размера структуры выравниванию
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)
Зачем размер структуры должен быть кратен выравниванию самого большого поля — для корректной работы массивов:
Зачем: массив [2]A
С padding (размер 16):
элемент 0: адреса 0-15
элемент 1: адреса 16-31 → int64 на адресе 16, кратен 8 → ок
Без padding (размер 12):
элемент 0: адреса 0-11
элемент 1: адреса 12-23 → int64 на адресе 12, не кратен 8 → сломано
Как проверить
Как проверить:
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 и растет автоматически при нехватке места.
Как работает:
1. Компилятор вставляет проверку в начало каждой функции (prologue)
func foo() {
// [скрытый код] if SP < stackguard { runtime.morestack() }
...
}
2. Если места мало -- runtime.morestack():
- аллоцирует новый стек (2x размер)
- копирует данные со старого стека
- обновляет все указатели на стек
- очищает старый стек
- продолжает выполнение
Компилятор вставляет проверку в prologue каждой функции: if SP < stackguard { runtime.morestack() }.
При вызове runtime.morestack(): аллоцируется новый стек размером 2x, копируются данные, обновляются все указатели, выполнение продолжается.
До роста: После роста:
+--------+ 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 каждый раз
Указатели фиксятся автоматически:
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 указателя
// ❌ указатель → хип
func NewUser() *User {
return &User{Name: "Alice"} // moved to heap
}
// ✅ значение → стек (копия)
func NewUser() User {
return User{Name: "Alice"} // стек, копируется при return
}
Бенчмарк: разница существенная (аллокация + нагрузка на GC).
Не значит что всегда нужно возвращать значение. Но если создаёте огромное количество объектов — стоит задуматься.
Сигнатура Read
// ❌ возвращаем срез → 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) // буфер мог быть на стеке вызывающего
}
Переменная в цикле
// ❌ каждую итерацию: сдвиг 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 точка
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 (арена) и раздаёт сам.