ОС · Виртуализация памяти · 15 мин
API памяти: стек vs куча, утечки и escape
Откуда вообще берётся память
В прошлой главе (адресное пространство) мы выяснили: каждой программе ОС рисует иллюзию приватной непрерывной памяти. Внутри этой иллюзии есть две принципиально разные области, откуда берётся память под ваши данные: стек и куча. Это не два разных «банка памяти» — это две разные дисциплины управления одной и той же памятью. Разберём по-простому.
Аналогия. Представьте склад. Стек — это стопка коробок у входа: новую коробку кладёшь сверху, забираешь тоже только сверху, строго в обратном порядке. Быстро, дисциплинированно, но негибко. Куча — это большой зал, где ты просишь у кладовщика кусок места под свой груз: он находит свободную полку, выдаёт адрес, и груз лежит там, пока ты явно не скажешь «забирай обратно». Гибко, но за порядком надо следить.
Адресное пространство процесса (упрощённо)
┌───────────────────────────┐ высокие адреса
│ СТЕК │ растёт ВНИЗ ↓
│ (кадры вызовов функций) │
├───────────────────────────┤
│ ↓ │
│ свободно │
│ ↑ │
├───────────────────────────┤
│ КУЧА │ растёт ВВЕРХ ↑
│ (malloc / объекты, GC) │
├───────────────────────────┤
│ данные, код программы │
└───────────────────────────┘ низкие адресаЧто нарисовано: стек и куча растут навстречу друг другу с разных концов адресного пространства, а между ними — большая «свободная» зона. Так сделано специально: пока есть зазор, обе области могут расти, не мешая друг другу.
Стек: автоматическая память кадров
Каждый раз, когда вызывается функция, на вершину стека кладётся кадр (stack frame) — это блок памяти под локальные переменные этой функции, аргументы и адрес возврата (куда вернуться, когда функция закончится). Когда функция завершается — кадр просто снимается с вершины. Никакого ручного освобождения: память «освобождается» сама фактом возврата.
Вызовы: main() → handle() → parse()
шаг 1: main шаг 2: + handle шаг 3: + parse
┌─────────┐ ┌─────────┐ ┌─────────┐
│ main │ │ main │ │ main │
└─────────┘ ├─────────┤ ├─────────┤
│ handle │ │ handle │
└─────────┘ ├─────────┤
│ parse │ ← вершина (SP)
└─────────┘
parse() вернулась → её кадр МГНОВЕННО снят:
┌─────────┐
│ main │
├─────────┤
│ handle │ ← вершина снова здесь
└─────────┘Что нарисовано: стек растёт «вглубь» по мере вложенных вызовов (push кадра) и схлопывается при возврате (pop кадра). Указатель вершины (SP, stack pointer) — это просто число, которое двигается вверх-вниз. Поэтому выделение на стеке почти бесплатное: «выделить» = сдвинуть указатель на N байт.
Плюсы стека: бешено быстро, освобождение автоматическое, отличная локальность (данные рядом, кэш процессора счастлив). Минус: данные кадра живут только пока функция не вернулась. Вернёшь из функции указатель на локальную переменную — и в C получишь висячий указатель (dangling pointer): память кадра уже снята, указатель смотрит в мусор. Классическая ошибка.
Рекурсия и переполнение стека
Стек не бесконечен (типично единицы мегабайт на поток в C; в Go горутина стартует с маленького стека ~2 КБ и растёт по мере надобности). Глубокая рекурсия без выхода кладёт кадр за кадром, пока место не кончится — это stack overflow.
Рекурсия без базового случая: f() вызывает f()
┌──────┐
│ f #1 │
├──────┤
│ f #2 │
├──────┤
│ f #3 │
├──────┤
│ ... │ кадры всё ниже и ниже...
├──────┤
│ f #N │ ← упёрлись в границу стека → CRASH
└──────┘ (в Go: "fatal error: stack overflow"
или паника при росте сверх лимита)Куча: память, которую просишь явно
Когда данные должны пережить функцию, в которой родились (например, объект, который мы вернули наружу и им пользуются дальше), их место — на куче. Куча живёт по другому правилу: память выделяется, когда попросили, и живёт, пока нужна, независимо от вызовов функций.
В языке C это делается руками через пару функций — это и есть классический API памяти:
void *p = malloc(100); // попросить 100 байт; вернётся адрес куска
// ... пользуемся p ...
free(p); // вернуть кусок системе вручнуюmalloc («memory allocate») находит на куче свободный кусок нужного размера и
отдаёт его адрес. free возвращает кусок обратно, чтобы его можно было выдать
снова. Как именно аллокатор ищет свободные куски, борется с дырами и
фрагментацией — отдельная большая тема, см. главу
управление свободной памятью.
В C на программисте лежит вся ответственность, и отсюда — целый зоопарк багов:
| Ошибка в C | Что случилось | Последствие |
|---|---|---|
забыл free |
память не вернули | утечка — расход растёт |
free дважды |
вернули один кусок дважды | порча кучи, краш |
| use-after-free | пользуемся после free |
чтение/запись мусора |
| dangling pointer | указатель на снятый кадр | UB, краш |
Go: компилятор и GC берут это на себя
Здесь Go отличается от C радикально, и это главное, что нужно унести из главы.
В Go нет malloc/free в коде. Вы просто создаёте значения (x := T{},
make, new, литералы), а две вещи делают остальное:
- Escape analysis (анализ убегания) — на этапе компиляции компилятор решает за вас, где разместить значение: на стеке или на куче.
- Garbage Collector (GC, сборщик мусора) — в рантайме автоматически
освобождает то, на что больше никто не ссылается. Никакого ручного
free.
Что такое escape analysis
Правило простое: если компилятор может доказать, что значение не «переживёт» функцию, оно остаётся на стеке. Если значение «убегает» наружу — оно уезжает на кучу. «Убегает» — значит на него где-то останется ссылка после возврата функции: вернули указатель, положили в глобальную переменную, отправили в канал, захватили в замыкании, сохранили в долгоживущей структуре.
func stays() int {
x := 42 // x никуда не убегает
return x // вернули КОПИЮ значения
} // x жил на СТЕКЕ, исчез с кадром
func escapes() *int {
y := 42 // берём адрес и возвращаем его
return &y // на y останется ссылка снаружи!
} // компилятор кладёт y на КУЧУ До возврата escapes(): После возврата:
СТЕК КУЧА СТЕК КУЧА
┌────────┐ ┌────────┐ ┌──────┐
│ escapes│ ┌──────┐ │ caller │ │ y=42 │←┐
│ &y ───┼─────▶│ y=42 │ │ p ────┼──┼──────┘ │
└────────┘ └──────┘ └────────┘ └────────┘
кадр escapes снят,
но y жив на куче —
висячего указателя НЕТЧто нарисовано: в C возврат &y дал бы висячий указатель (кадр снят — адрес
протух). В Go компилятор это предвидит: раз адрес y убегает, он размещает y
на куче, и после возврата объект жив. Поэтому в Go вернуть &y из функции —
абсолютно нормально и безопасно.
Проверить решения компилятора можно флагом:
go build -gcflags='-m' ./...
# ./main.go:12:9: &y escapes to heap
# ./main.go:4:2: x does not escapeПочему это важно для перфоманса: куча дороже стека. Аллокация на куче — это поиск места + позже работа GC. Аллокация на стеке — сдвиг указателя и бесплатное освобождение. Поэтому одна из частых оптимизаций горячего кода в Go — «убрать лишние escape», чтобы значения остались на стеке и не нагружали GC. Подробнее про то, как Go обращается со значениями и указателями, — в главе модель памяти Go.
| Стек | Куча | |
|---|---|---|
| Кто выделяет | компилятор автоматически | в C — malloc; в Go — рантайм по решению escape analysis |
| Кто освобождает | сам, при возврате функции | в C — free вручную; в Go — GC |
| Скорость выделения | мгновенно (сдвиг SP) | дороже (поиск места) |
| Время жизни | до конца функции | пока есть ссылки |
| Размер | ограничен (Mб), но в Go растёт | большой, ограничен RAM/лимитом |
| Типичная ошибка | overflow, dangling (в C) | утечка |
| Локальность/кэш | отличная | хуже (разбросано) |
Утечки памяти: GC не панацея
Раз GC сам всё чистит, утечек в Go быть не может? Может. GC освобождает только то, на что никто не ссылается. Если вы (часто случайно) держите ссылку на данные, которые уже не нужны, — GC обязан их хранить. Память не освобождается, расход растёт. Это и есть утечка памяти: не «потеряли указатель», а наоборот — «держим ненужную ссылку».
Объект жив для GC, пока есть ХОТЯ БЫ ОДНА ссылка на него:
корни (стек, глобалы)
│
▼
[ map cache ]──▶[ big blob #1 ] ← нужен? нет. но ссылка есть → НЕ удалят
│
└─────────▶[ big blob #2 ] ← тоже висит в map → утечка
Никто не ссылается:
[ blob #3 ] ← недостижим → GC заберётЧто нарисовано: GC идёт от «корней» (стек, глобальные переменные) по ссылкам и
помечает всё достижимое как живое. blob #3 недостижим — будет собран. А #1 и
#2 достижимы через map, значит для GC они «нужны», даже если логически они вам
уже не нужны.
Типичные источники утечек в Go:
- Растущая
map/слайс-кэш без вычистки. Кладём ключи и никогда не удаляем — карта пухнет вечно. - Подслайс большого массива.
small := big[:10]держит ссылку на весь массивbig, и весь backing-массив не освобождается, хоть нужно 10 элементов. - Утечка горутин. Горутина, заблокированная навсегда (ждёт из канала, в который никто не пишет), живёт вечно — а вместе с ней живёт вся память, на которую она ссылается.
Утечка горутины:
go func() {
v := <-ch // ch никто не закроет и не запишет
use(v) // эта строка не выполнится НИКОГДА
}() // горутина висит → её стек и захваченные
// объекты не освобождаются. И так на КАЖДЫЙ
// забытый запрос → память течёт под нагрузкойПро каналы, горутины и корректную их остановку (через context, закрытие
каналов) — в примитивах синхронизации. Главная мысль:
в Go нет висячих указателей и double-free, но утечки через долгоживущие ссылки
и подвисшие горутины — реальны и встречаются на проде постоянно.
Как ловят утечки на проде
В Go для этого есть встроенный профайлер pprof: смотрят heap-профиль (что
занимает память) и число горутин (runtime.NumGoroutine() или
/debug/pprof/goroutine). Если число горутин монотонно растёт со временем —
почти наверняка где-то утечка горутин. Если растёт heap при стабильной нагрузке —
ищут забытую ссылку/кэш. Эта картина прямо связана с тем, что происходит в проде
под памятью: OOM-killer, лимиты контейнера — см. главу
память в проде.
Где компилятор Go разместит значение, на которое останется ссылка после возврата функции?
Что происходит с кадром (stack frame) функции, когда она возвращает управление?
Почему в Go, в отличие от C, безопасно вернуть из функции указатель на локальную переменную?
Какие ситуации реально приводят к утечке памяти в Go?
Сколько байт примерно занимает стартовый стек новой горутины в Go?
Какой команды/флага достаточно, чтобы увидеть решения escape analysis компилятора Go?
Что спрашивают на собесе
- В чём разница между стеком и кучей? Ожидают: дисциплина управления (LIFO, кадры, авто-освобождение vs явный/GC), скорость, время жизни, типичные ошибки.
- Что такое escape analysis в Go и зачем он нужен? Компилятор решает, где
разместить значение; если убегает — на кучу. Цель — меньше нагружать GC,
держать данные на стеке. Бонус: как посмотреть (
-gcflags='-m') и что считается «убеганием». - Безопасно ли в Go вернуть указатель на локальную переменную? Да — в отличие от C. Объясните почему (escape analysis перенесёт на кучу, GC сохранит).
- Бывают ли утечки памяти в Go, если есть GC? Да: GC хранит всё достижимое; утечка = удерживаемая ненужная ссылка (растущий кэш, подслайс большого массива, подвисшая горутина).
- Что такое висячий указатель и double-free, и почему их нет в Go? Покажите,
что это проблемы ручного управления памятью (C), которых GC и отсутствие
freeв Go устраняют. - Как искать утечку памяти/горутин в проде?
pprof(heap, goroutine профили), мониторингNumGoroutine, рост RSS контейнера, OOM.