---
title: "API памяти: стек vs куча, утечки и escape"
description: "В прошлой главе мы увидели адресное пространство процесса: код, данные, куча, стек. Теперь вопрос практический: когда программа создаёт значение, где оно оказывается и кто потом…"
canonical: https://graphlms.ru/os/book/memory-api
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# API памяти: стек vs куча, утечки и escape

> Источник: https://graphlms.ru/os/book/memory-api — курс «Операционные системы», GraphLMS.

## Две дисциплины одной памяти

В [прошлой главе](/os/book/address-space) мы увидели адресное пространство
процесса: код, данные, куча, стек. Теперь вопрос практический: когда программа
создаёт значение, где оно оказывается и кто потом освобождает память?

Есть два ответа, и это не два разных вида памяти, а два разных правила управления.

- **Стек**: память выделяется при входе в функцию и автоматически освобождается при
  выходе из неё. Строгий порядок: последним выделили, первым освободили.
- **Куча**: память выделяется по запросу и живёт, пока её явно не освободят (в C)
  или пока на неё есть ссылки (в Go со сборщиком мусора).

Для Go-разработчика эта глава объясняет три вещи, которые постоянно всплывают в
профилировании: почему одни функции аллоцируют, а другие нет, откуда берутся утечки
памяти при наличии сборщика мусора и как это всё связано с ОС.

## Стек: кадры вызовов

При каждом вызове функции на вершину стека кладётся **кадр** (stack frame): место
под её локальные переменные, аргументы и адрес возврата. При возврате кадр
снимается.

```text
  Вызовы: main() → handle() → parse()

  шаг 1          шаг 2           шаг 3
  ┌────────┐     ┌────────┐      ┌────────┐
  │ main   │     │ main   │      │ main   │
  └────────┘     ├────────┤      ├────────┤
                 │ handle │      │ handle │
                 └────────┘      ├────────┤
                                 │ parse  │ ← вершина стека (SP)
                                 └────────┘

  parse() вернулась → её кадра больше нет:
  ┌────────┐
  │ main   │
  ├────────┤
  │ handle │ ← вершина снова здесь
  └────────┘
```

Вершину стека отмечает регистр SP (stack pointer). «Выделить кадр» — значит
сдвинуть SP на нужное число байт, «освободить» — сдвинуть обратно. Это одна
инструкция, поэтому стек почти бесплатен. Данные кадра лежат рядом, и кэш
процессора с ними работает хорошо.

Ограничение одно: данные кадра живут, только пока функция не вернулась. В C можно
вернуть из функции указатель на локальную переменную, и он будет указывать на
снятый кадр, где скоро окажутся чужие данные. Это **висячий указатель** (dangling
pointer), классическая ошибка C.

### Переполнение стека

Стек ограничен. У потока ОС в Linux стек главного потока по умолчанию 8 МБ
(`ulimit -s`). В Go у каждой горутины свой стек: он начинается с 2 КБ и растёт по
мере надобности, но не больше 1 ГБ на 64-битных системах. Рекурсия без выхода
упирается в этот лимит:

```go
package main

func f(n int) int { return f(n+1) + 1 }

func main() { f(0) }
```

```text
runtime: goroutine stack exceeds 1000000000-byte limit
runtime: sp=0x140200e03a0 stack=[0x140200e0000, 0x140400e0000]
fatal error: stack overflow
```

Это не паника, а фатальная ошибка: `recover` её не поймает, процесс завершается.

## Куча в C: malloc и free

Когда данные должны пережить функцию, их кладут в кучу. В C это делается вручную:

```c
char *buf = malloc(100);  // попросить 100 байт; вернётся адрес
/* ... пользуемся buf ... */
free(buf);                // вернуть память, иначе утечка
```

`malloc` находит в куче свободный кусок нужного размера, `free` возвращает его для
повторного использования. Как именно аллокатор ищет куски и борется с
фрагментацией, разберём в главе [управление свободной памятью](/os/book/free-space).

Вся ответственность на программисте, и отсюда набор классических ошибок:

| Ошибка в C | Что произошло | Последствие |
|---|---|---|
| забыли `free` | память не вернули | утечка, расход растёт |
| двойной `free` | вернули один кусок дважды | порча структур аллокатора, падение |
| use-after-free | пользуемся после `free` | читаем или портим чужие данные |
| висячий указатель | указатель на снятый кадр | неопределённое поведение |
| `malloc` без проверки | не проверили `NULL` | падение при нехватке памяти |

### Откуда сама куча берёт память

`malloc` и `free` — функции библиотеки, а не системные вызовы. Аллокатор раздаёт
память, которую заранее получил у ОС большими кусками. У ОС для этого два
системных вызова:

- **`brk`/`sbrk`** сдвигает верхнюю границу области `[heap]`, которую мы видели в
  `/proc/self/maps`. Так растёт классическая куча.
- **`mmap`** просит у ядра новую область адресов в любом свободном месте адресного
  пространства. glibc отдаёт через `mmap` крупные запросы (по умолчанию от 128 КБ),
  чтобы при `free` вернуть их ОС целиком.

Ни тот, ни другой вызов сразу не тратят физическую память. Ядро только запоминает,
что диапазон адресов теперь разрешён, а реальные страницы выдаёт при первом
обращении к ним. Механику разберём в главе
[за пределами физической памяти](/os/book/swapping).

Рантайм Go работает так же: резервирует у ОС через `mmap` большие куски (на
64-битном Linux по 64 МБ), сам нарезает их под объекты и стеки горутин, а
неиспользуемую память со временем возвращает ядру через `madvise`.

## Go: компилятор и сборщик мусора

В Go нет `malloc` и `free` в коде. Вы создаёте значения (`T{}`, `new`, `make`,
литералы), а две вещи делают остальное:

1. **Escape analysis** (анализ «убегания») при компиляции решает, где разместить
   значение: на стеке или в куче.
2. **Сборщик мусора** (garbage collector, GC) во время работы освобождает в куче то,
   до чего больше нельзя добраться по ссылкам.

### Escape analysis

Правило: если компилятор может доказать, что значение не нужно после выхода из
функции, оно живёт на стеке. Если ссылка на значение может пережить функцию, оно
«убегает» в кучу. Убегание происходит, когда вы возвращаете указатель, сохраняете
его в глобальную переменную или долгоживущую структуру, отправляете в канал,
захватываете в замыкании, которое живёт дольше функции.

Проверим, сколько аллокаций в куче делает каждый вариант:

```go
package main

import (
	"fmt"
	"testing"
)

var sink *int

func stays() int {
	x := 42
	return x // возвращаем копию значения
}

func escapes() *int {
	y := 42
	return &y // адрес y переживёт функцию
}

func main() {
	a := testing.AllocsPerRun(1000, func() { _ = stays() })
	b := testing.AllocsPerRun(1000, func() { sink = escapes() })
	fmt.Println("stays:", a, "аллокаций в куче")
	fmt.Println("escapes:", b, "аллокаций в куче")
}
```

```text
stays: 0 аллокаций в куче
escapes: 1 аллокаций в куче
```

`testing.AllocsPerRun` вызывает функцию много раз и считает среднее число
аллокаций в куче на вызов. `stays` не аллоцирует: `x` живёт в кадре. `escapes`
аллоцирует на каждый вызов: `y` должна существовать после возврата, поэтому
компилятор разместил её в куче.

Решения компилятора можно увидеть флагом `-m`:

```bash
go build -gcflags='-m' .
```

Среди прочих строк будут такие:

```text
./main.go:16:2: moved to heap: y
./main.go:22:57: moved to heap: y
```

Вторая строка появилась потому, что компилятор встроил (inline) `escapes` в
анонимную функцию в `main`, и у встроенной копии то же решение. Про `x` компилятор
ничего не пишет: он остался на стеке.

```text
   До возврата из escapes():       После возврата:
   СТЕК             КУЧА           СТЕК          КУЧА
   ┌─────────┐                     ┌─────────┐   ┌──────┐
   │ escapes │      ┌──────┐       │ main    │   │ y=42 │
   │  &y ────┼─────▶│ y=42 │       │ sink ───┼──▶│      │
   └─────────┘      └──────┘       └─────────┘   └──────┘
                                   кадр escapes снят, y жива в куче
```

Поэтому в Go вернуть `&y` из функции нормально и безопасно: висячего указателя не
будет. Цена — аллокация в куче и работа для сборщика мусора. Одна из частых
оптимизаций горячего кода в Go — убрать лишние убегания, чтобы значения
оставались на стеке.

| | Стек | Куча |
|---|---|---|
| Кто выделяет | компилятор, сдвигом SP | в C — `malloc`; в Go — рантайм по решению escape analysis |
| Кто освобождает | возврат из функции | в C — `free`; в Go — сборщик мусора |
| Цена выделения | почти ноль | поиск места, позже работа GC |
| Время жизни | до конца функции | пока нужно (C) или пока есть ссылки (Go) |
| Типичная ошибка | переполнение; в C — висячий указатель | утечка |

## Утечки памяти: GC не панацея

Сборщик мусора освобождает только то, до чего нельзя добраться по ссылкам от
«корней»: стеков горутин и глобальных переменных. Если вы держите ссылку на данные,
которые логически уже не нужны, GC обязан их хранить. Это и есть утечка в языке со
сборщиком мусора: не потерянный указатель, а **лишний удерживаемый**.

```text
   корни (стеки, глобальные переменные)
        │
        ▼
   [ map-кэш ]──▶[ большой объект 1 ]   никому не нужен, но ссылка есть → живёт
        │
        └──────▶[ большой объект 2 ]   то же самое

                 [ объект 3 ]            ссылок нет → GC его соберёт
```

Типичные источники утечек в Go:

- **Кэш без вытеснения.** `map`, в которую кладут и никогда не удаляют.
- **Маленький срез большого массива.** `small := big[:10]` ссылается на весь
  исходный массив `big`, и он не будет освобождён, пока жив `small`. Лечится копией:
  `small := append([]T(nil), big[:10]...)`.
- **Зависшие горутины.** Горутина, которая навсегда заблокирована на канале, живёт
  вечно вместе со своим стеком и всем, на что ссылается.

Последний случай — самый частый на проде. Его легко воспроизвести:

```go play
package main

import (
	"fmt"
	"runtime"
	"time"
)

func handle(id int) {
	ch := make(chan int)
	go func() {
		v := <-ch // никто не пишет в ch: горутина ждёт вечно
		fmt.Println(v)
	}()
	// handle вернулся, а горутина и её ch остались жить
}

func main() {
	fmt.Println("горутин в начале:", runtime.NumGoroutine())
	for i := 0; i < 1000; i++ {
		handle(i)
	}
	time.Sleep(100 * time.Millisecond)
	fmt.Println("горутин после 1000 запросов:", runtime.NumGoroutine())
}
```

```text
горутин в начале: 1
горутин после 1000 запросов: 1001
```

Каждый «запрос» оставил после себя горутину. На сервисе под нагрузкой это тысячи
горутин в минуту и медленно растущая память. Правильная остановка горутин через
`context` и закрытие каналов разобраны в курсе Go:
[примитивы синхронизации](/go/book/sync-primitives).

### Как ищут утечки на проде

В Go для этого есть профайлер `pprof`. Heap-профиль (`/debug/pprof/heap`)
показывает, какие места кода держат больше всего памяти. Профиль горутин
(`/debug/pprof/goroutine`) показывает, где они стоят. Признаки:

- число горутин (`runtime.NumGoroutine()` или метрика) растёт и не возвращается —
  утечка горутин;
- куча растёт при постоянной нагрузке — забытая ссылка или кэш без вытеснения.

Если утечку не найти, история заканчивается так, как описано в главе
[память в проде](/os/book/memory-cloud): процесс упирается в лимит контейнера, и
ядро его убивает.

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/memory-api

## Частые ошибки

- Считать, что `new` и `&T{}` в Go всегда означают кучу. Если указатель не убегает,
  компилятор разместит значение на стеке.
- Считать, что в Go не бывает утечек. Бывают, через удерживаемые ссылки и зависшие
  горутины.
- Думать, что `malloc` — системный вызов. Это функция библиотеки; в ядро она ходит
  редко, за большими кусками памяти.

## Проверьте себя

1. Функция возвращает структуру по значению (`return T{...}`). Будет ли аллокация в
   куче?
2. Функция кладёт `&x` в глобальную переменную. Где окажется `x`?
3. В профиле горутин 50 000 горутин стоят на `<-ch` в одной строке кода. Что это,
   скорее всего?
4. Почему `recover` не спасает от бесконечной рекурсии в Go?

Ответы. (1) Нет, если копия не убегает дальше; вернуть значение — значит
скопировать его в кадр вызывающего. (2) В куче: ссылка переживает функцию. (3)
Утечка горутин: никто не пишет в канал и не закрывает его. (4) Переполнение стека
в Go — фатальная ошибка рантайма, а не паника.

## Что спрашивают на собесе

- Разница между стеком и кучей: дисциплина управления, цена выделения, время жизни,
  типичные ошибки.
- Что такое escape analysis, что считается убеганием, как увидеть решения
  компилятора (`-gcflags='-m'`).
- Безопасно ли в Go вернуть указатель на локальную переменную и почему.
- Бывают ли утечки памяти в Go при наличии GC; примеры.
- Как `malloc` получает память у ОС (`brk`, `mmap`); как это делает рантайм Go.
- Как искать утечку памяти или горутин в проде (`pprof`, метрики).

## Вопросы для самопроверки

**Квиз «API памяти: стек vs куча»**

1. Где компилятор Go разместит значение, на которое останется ссылка после возврата функции?
   - В регистрах процессора
   - На куче — потому что оно «убегает» (верно)
   - В глобальной секции данных
   - На стеке — так быстрее
   - Пояснение: Escape analysis: если значение переживёт функцию (на него есть внешняя ссылка), оно размещается на куче, чтобы остаться живым после снятия кадра. GC освободит его, когда ссылок не останется.
2. Что происходит с кадром (stack frame) функции, когда она возвращает управление?
   - Освобождается вручную через free
   - Передаётся сборщику мусора
   - Копируется на кучу
   - Снимается со стека автоматически (сдвиг указателя вершины) (верно)
   - Пояснение: Стек работает по LIFO: возврат функции просто сдвигает указатель вершины назад, и кадр со всеми локальными переменными исчезает мгновенно и бесплатно. Никакого ручного освобождения.
3. Почему в Go, в отличие от C, безопасно вернуть из функции указатель на локальную переменную?
   - В Go нельзя брать адрес локальной переменной
   - Go копирует всю память при возврате
   - Escape analysis размещает такую переменную на куче, а GC хранит её, пока есть ссылки (верно)
   - Go запрещает рекурсию
   - Пояснение: Компилятор видит, что адрес убегает наружу, и кладёт переменную на кучу. Поэтому после снятия кадра объект жив — висячего указателя не возникает.
4. Какие ситуации реально приводят к утечке памяти в Go?
   - Растущая map-кэш, из которой ничего не удаляют (верно)
   - Горутина, навсегда заблокированная на чтении из канала (верно)
   - Возврат значения по копии из функции
   - Подслайс большого массива, удерживающий весь backing-массив (верно)
   - Пояснение: GC хранит всё достижимое по ссылкам. Растущий кэш, подслайс большого массива и подвисшая горутина удерживают ссылки на ненужные данные. Возврат копии, наоборот, ничего не удерживает.
5. Сколько байт примерно занимает стартовый стек новой горутины в Go?
   - Ответ: 2048
   - Пояснение: Горутина стартует с очень маленького стека (~2 КБ = 2048 байт) и растёт по мере надобности. Поэтому можно создавать миллионы горутин — в отличие от тяжёлых потоков ОС с мегабайтными стеками.
6. Какой команды/флага достаточно, чтобы увидеть решения escape analysis компилятора Go?
   - go build -gcflags='-m' ./... (верно)
   - go test -bench=.
   - go vet ./...
   - go run -race ./...
   - Пояснение: Флаг -m у компилятора (-gcflags='-m') печатает решения escape analysis: что осталось на стеке («does not escape») и что уехало на кучу («escapes to heap»).
