GraphLMS

ОС
Начать

ОС · Виртуализация памяти · 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, литералы), а две вещи делают остальное:

  1. Escape analysis (анализ убегания) — на этапе компиляции компилятор решает за вас, где разместить значение: на стеке или на куче.
  2. 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, лимиты контейнера — см. главу память в проде.

Проверь себя· API памяти: стек vs куча

Где компилятор 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.
Адресное пространство: иллюзия приватной памятиАппаратная трансляция адресов: base-and-bounds