Learning Platform
Глоссарий Troubleshooting
Урок 08.01 · 22 мин
Начальный
MemoryStackHeapAllocationLinux

Stack vs heap — где живут переменные и кто решает

Когда вы пишете x = 5 в Python или int x = 5; в C, переменная физически где-то лежит в RAM. Но где именно? В одной программе одновременно работают несколько областей памяти, и две из них — центральные: стек и куча. Любая переменная попадает в одну из них (или в третью область, про которую расскажем тоже), и от этого зависит её время жизни, скорость доступа и поведение при ошибках.

Зачем разбираться, если вы не пишете на C? Потому что под капотом любой Python, Go, Java и JavaScript-runtime использует те же механизмы. Когда вы видите RecursionError: maximum recursion depth exceeded — это stack overflow. Когда MemoryError или OOM-kill — это куча. Когда программа на Rust валится с segfault на ровном месте — это переполнение стека потоком. Понимание stack vs heap — ключ к диагностике этих проблем и к интуиции, почему рекурсия дороже цикла, а большие массивы обычно лежат на куче.

В этом уроке: что такое стек кадров, как растёт куча, почему stack pointer и heap pointer движутся навстречу друг другу, и что показывает /proc/[pid]/maps про вашу программу.


Анатомия памяти процесса

У каждого Linux-процесса есть виртуальное адресное пространство — огромный (на x86_64 — 128 ТБ user space) диапазон адресов, который kernel выделил под нужды процесса. Это пространство не плоское: оно разделено на несколько именованных регионов.

Карта памяти процесса -- сверху вниз по адресам
High addressesСамые высокие виртуальные адреса -- ближе к 0x7fffffffffff в userspace на x86_64. Здесь обычно kernel-зона, выше которой userspace не лезет
StackСтек растёт ВНИЗ (от больших адресов к меньшим). Хранит локальные переменные, return-адреса, аргументы функций. Каждый поток имеет свой стек, по умолчанию ~8 МБ в Linux
Memory mapРегион для mmap-областей: shared libraries (libc.so), большие malloc-аллокации, mapped-файлы. Растёт сверху вниз от середины адресного пространства
HeapКуча растёт ВВЕРХ (от меньших адресов к большим). Управляется через brk/sbrk. Здесь живут malloc-аллокации, объекты Python, GC-куча Go
BSS / DataГлобальные и статические переменные. BSS -- занулённые (int x = 0). Data -- инициализированные (int x = 42). Размер фиксирован при компиляции
TextИсполняемый код программы. Read-only. Сюда загружены машинные инструкции вашего бинарника
Low addressesНизкие адреса. Первая страница (0x0...0xFFF) умышленно недоступна -- любой доступ через NULL-указатель даёт segfault

Главное наблюдение: стек и куча растут навстречу друг другу. Стек идёт от высоких адресов вниз, куча — от низких вверх. Между ними — свободное виртуальное пространство, которое теоретически достаётся тому, кто первый закажет. На практике в современных системах их разделяет mmap-регион и виртуальное пространство такое огромное, что они никогда не сталкиваются. Но название «stack vs heap» исторически родилось именно из этой картинки: две памяти, растущие навстречу.

Посмотреть карту реальной программы:

# Запускаем sleep в фоне и смотрим его карту памяти:
sleep 1000 &
SLEEP_PID=$!
cat /proc/$SLEEP_PID/maps | head -20
kill $SLEEP_PID

# Типичный вывод:
# 555555554000-555555556000 r-xp 00000000 fe:01 1234567  /usr/bin/sleep
# 555555755000-555555756000 r--p 00001000 fe:01 1234567  /usr/bin/sleep
# 555555756000-555555757000 rw-p 00002000 fe:01 1234567  /usr/bin/sleep
# 555555757000-555555778000 rw-p 00000000 00:00 0        [heap]
# 7ffff7dc1000-7ffff7de7000 r--p 00000000 fe:01 9876543  /usr/lib/libc.so.6
# 7ffff7fb0000-7ffff7fb2000 rw-p 00000000 00:00 0
# 7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0        [stack]

Видите [heap] около низких адресов (0x555…) и [stack] у самой вершины (0x7fff…)? Это и есть наши герои.


Стек — LIFO с автоматическим управлением

Стек — структура данных last-in-first-out, и в памяти процесса он работает точно так же. У CPU есть специальный регистр rsp (stack pointer на x86_64), который указывает на текущую вершину стека. Каждый вызов функции:

  1. Сдвигает rsp вниз на размер кадра функции (place для локальных переменных + сохранённые регистры + return-адрес).
  2. Кладёт return-адрес — куда вернуться после ret.
  3. Сохраняет регистры, которые функция собирается изменить.
  4. Выделяет место для локальных переменных простым вычитанием из rsp.

При выходе из функции — всё в обратном порядке: восстанавливаются регистры, rsp двигается обратно вверх, выполняется ret. Никакого malloc/free. Аллокация на стеке — это буквально вычитание из регистра. Одна инструкция CPU. Поэтому стек быстрый.

Стек кадров -- одна функция на кадр
main()Кадр функции main. Содержит локальные переменные main, return-адрес в _start (точка входа libc), сохранённые регистры
foo() called from mainКадр foo. Когда main вызывает foo, выделяется новый кадр на стеке. После выхода из foo -- кадр исчезает (rsp возвращается обратно)
bar() called from fooКадр bar. На вершине стека. Когда bar завершится -- rsp вернётся на foo. Если bar вызовет ещё функцию -- появится новый кадр выше
Free stack spaceСвободное пространство стека. Когда rsp сдвигается ниже -- эта область становится частью нового кадра. Виртуально зарезервировано, физически выделяется по мере доступа (lazy page allocation)

Что хранится в кадре функции:

  • Локальные переменные — те, что объявлены внутри функции. int x = 5; в C, let x = 5; в Rust.
  • Аргументы функции — частично в регистрах (первые 6 на x86_64 System V ABI: rdi, rsi, rdx, rcx, r8, r9), частично на стеке.
  • Сохранённые регистры — callee-saved, которые функция должна вернуть в исходное состояние.
  • Return-адрес — адрес инструкции после call в вызывающей функции.

Размер стека ограничен. В Linux по умолчанию 8 МБ на главный поток, для дополнительных потоков обычно 2 МБ (зависит от libc и опций pthread). Проверить:

ulimit -s
# Типичный вывод: 8192  (в килобайтах = 8 МБ)

# Изменить для текущей сессии shell:
ulimit -s 16384  # 16 МБ

# Для всех новых процессов (требует /etc/security/limits.conf или systemd):
# в unit-файле: LimitSTACK=16M

Stack overflow — когда стек кончился

Что произойдёт, если функция выделяет слишком большой массив на стеке или вы зашли в бесконечную рекурсию? Стек растёт вниз — и в какой-то момент он упрётся в guard page: специально размеченную одну страницу (4 КБ), которая помечена как недоступная. Любой доступ к ней триггерит segfault, и ядро отправляет процессу SIGSEGV. Программа умирает с сообщением «segmentation fault» или «stack overflow».

# Минимальный stack overflow на C:
cat > overflow.c << 'CEOF'
#include <stdio.h>

void recurse(int n) {
    char buf[1024];   // 1 КБ на каждый кадр
    buf[0] = (char)n;
    printf("Depth %d, stack at %p\n", n, (void*)buf);
    recurse(n + 1);
}

int main(void) {
    recurse(0);
    return 0;
}
CEOF

gcc -O0 overflow.c -o overflow
./overflow 2>&1 | tail -5

# Типичный вывод:
# Depth 7800, stack at 0x7fffff7ff230
# Depth 7801, stack at 0x7fffff7fee30
# Depth 7802, stack at 0x7fffff7fea30
# Segmentation fault (core dumped)

8192 КБ / 1 КБ на кадр ~= 8000 кадров. Сходится: разнеслось на 7800+ вызовах. Адрес buf уменьшается с каждым вызовом (видно, как идём вниз).

В Python такая же история, только обёртка:

python3 -c "
import sys
sys.setrecursionlimit(10**6)
def f(n): return f(n+1)
f(0)
"
# Segmentation fault

В Python есть soft-limit (sys.setrecursionlimit, по умолчанию 1000), который ловит рекурсию до того, как реально кончится C-стек. Но если поднять лимит выше реальной ёмкости — получите segfault.

WARNING

В многопоточных программах stack overflow одного потока валит весь процесс. Сложность: разные потоки могут иметь разные размеры стека. Для thread pool на async-сервере stack overflow в worker-е = смерть всего сервера. Думайте о глубине рекурсии в worker-функциях.


Куча — ручное и динамическое

Куча (heap) — противоположность стеку по всему. Стек растёт вниз — куча вверх. Стек авто-управляемый — куча требует явных malloc/free. Стек локальный для функции — куча общая на весь процесс. Стек быстрый — куча медленнее.

Когда нужна куча:

  • Размер неизвестен заранее. На стеке нельзя выделить «массив из N элементов, где N — ввод пользователя» (на C99 можно через VLA, но это редкость и опасно). На куче — легко.
  • Время жизни переходит границу функции. Объект, возвращаемый из функции, не может жить на её стеке (кадр исчезнет). Если хотите вернуть массив — malloc.
  • Большой объект. Стек — 8 МБ. Если массив на 100 МБ — только куча.
  • Объекты с управляемой ссылочной структурой. Linked list, tree, graph — узлы связаны указателями, могут быть в любом порядке создания/удаления. Это про кучу.

Базовый цикл работы с кучей в C:

cat > heap.c << 'CEOF'
#include <stdio.h>
#include <stdlib.h>

int* make_array(int n) {
    int* arr = malloc(n * sizeof(int));  // выделили на куче
    for (int i = 0; i < n; i++) arr[i] = i * i;
    return arr;  // адрес кучи возвращается -- объект жив за пределами функции
}

int main(void) {
    int* a = make_array(5);
    printf("a[3] = %d, at %p\n", a[3], (void*)a);
    free(a);  // вернули куче -- иначе утечка
    return 0;
}
CEOF

gcc heap.c -o heap_demo
./heap_demo
# Типичный вывод:
# a[3] = 9, at 0x55a3b6e2a2a0

Адрес 0x55a3b... — это область heap (около 0x555… в типичной x86_64 layout). Сравните с адресами стека 0x7fff... из прошлого примера — разные диапазоны.

Call stack в Python: что лежит в stack frame в CPython

Стек vs куча — сравнительная таблица

Stack vs heap -- ключевые различия
StackСтек: автоуправление через rsp, маленький (МБ), быстрый (1 инструкция CPU), локальный для функции, ограничен размером (overflow при рекурсии или больших массивах), последовательное использование (LIFO)
HeapКуча: ручное (malloc/free) или GC, большая (доступна вся свободная RAM + swap), медленнее (вызов аллокатора), общая на процесс, не ограничена (кроме общего лимита), произвольный порядок выделения
Growth: downСтек растёт от высоких адресов к низким. rsp вычитается на размер нового кадра
Growth: upКуча растёт от низких адресов к высоким через системный вызов brk. Для больших аллокаций -- mmap, которая берёт страницы из mmap-региона
Speed: O(1) instructionАллокация на стеке -- одна инструкция вычитания. Деаллокация -- одна сложения. Почти бесплатно
Speed: free-list / treeАллокатор кучи (ptmalloc, jemalloc) ищет блок подходящего размера в свободных списках. ~50-500 нс, иногда блокировка mutex-а
Lifetime: function scopeЛокальная переменная живёт до конца функции. После return её адрес содержит мусор -- никогда не возвращайте указатель на стек
Lifetime: until freeОбъект на куче живёт, пока его не освободят явно (C/C++) или GC не соберёт (Python, Java, Go). Без free -- утечка

Высокоуровневые языки: где Python хранит что?

В Python x = 5 создаёт объект int где? На куче! Все Python-объекты (int, str, list, dict, ваш класс) живут на куче CPython. На стеке — только указатели на эти объекты + кадры (frame objects). Поэтому sys.getsizeof(5) возвращает 28 байт — это объект int с заголовком, refcount, type pointer и значением, лежащий на куче.

python3 -c "
import sys
x = 5
y = 'hello'
z = [1, 2, 3]
print(f'int 5:  {sys.getsizeof(x)} bytes at id 0x{id(x):x}')
print(f'str:    {sys.getsizeof(y)} bytes at id 0x{id(y):x}')
print(f'list:   {sys.getsizeof(z)} bytes at id 0x{id(z):x}')
"
# Типичный вывод:
# int 5:  28 bytes at id 0x7f1234567890
# str:    54 bytes at id 0x7f1234567a20
# list:   80 bytes at id 0x7f1234567b50

Адреса id() — это адреса в куче CPython (через PyObject_Malloc -> в итоге malloc-аллокатор). В Go всё хитрее: компилятор анализирует, может ли объект «убежать» из функции (escape analysis). Если не может — он лежит на стеке. Если может (возвращается, передаётся в горутину, etc.) — на куче. Поэтому Go-программа может создать миллионы маленьких объектов без давления на GC, если они короткоживущие и locally scoped.

В Rust картина похожа на C, но компилятор гарантирует безопасность: let x = vec![1, 2, 3] создаёт Vec, у которого heap-buffer, но сам Vec (3 указателя: ptr, len, capacity) лежит на стеке. Когда x уходит из scope — Drop автоматически освобождает heap-buffer.

TIP

Скорость аллокации часто недооценивают. Создание 1 миллиона объектов на стеке в C/Rust занимает несколько микросекунд. На куче — сотни миллисекунд. В Python (всё на куче) — ещё больше. Это одна из причин, почему Go и Rust быстрее Python даже без оптимизации.


Попробуй сам

Посмотрите карту памяти Python-процесса:

# Запускаем Python REPL в фоне:
python3 -c "
import time, ctypes
x = [i for i in range(10**6)]
print('PID:', __import__('os').getpid())
print('List address:', hex(id(x)))
time.sleep(60)
" &
PYPID=$!

# Через секунду смотрим карту:
sleep 1
echo "=== Memory map ==="
cat /proc/$PYPID/maps | grep -E '\[heap\]|\[stack\]'

echo "=== Size of regions ==="
cat /proc/$PYPID/status | grep -E 'Vm(Peak|Size|Stk|Data|RSS)'

kill $PYPID 2>/dev/null

Вы увидите:

  • VmStk — размер стека в КБ.
  • VmData — размер данных + кучи.
  • VmRSS — сколько физически в RAM сейчас.
  • В /proc/$PID/maps[heap] и [stack] регионы.

Поэкспериментируйте: создайте большой список (x = list(range(10**7))) и посмотрите, как растёт VmData. Это и есть рост кучи Python.


Проверка знанийKnowledge check
Программа на Python запустилась нормально, но при обработке большого вложенного JSON упала с 'Segmentation fault' -- НЕ привычным MemoryError или RecursionError. Что могло случиться и как отлаживать?
ОтветAnswer
Это, скорее всего, stack overflow в C-коде CPython, который рекурсивно обходит структуру (например, парсер json или сравнение dict). Python-уровень имеет soft-limit на recursion depth (`sys.getrecursionlimit()`, по умолчанию 1000), который ловит обычную рекурсию. Но если пользовательский код или C-extension не использует этот лимит и рекурсивно обходит вложенную структуру глубиной 100000 -- C-стек реально кончится, guard page трогается, ядро шлёт SIGSEGV и процесс умирает без Python-исключения. Отладка: 1) проверьте `ulimit -s` -- размер стека, увеличьте до 32-64 МБ через `ulimit -s unlimited` или systemd LimitSTACK. 2) Запустите под gdb: `gdb python -ex 'run script.py'`, после segfault команда `bt` покажет C-стек, и вы увидите рекурсивную функцию (типично PyObject_RichCompare, list_richcompare, dict_richcompare). 3) Переделайте код на итеративный обход (стек-явный, deque, или с очередью). 4) Если виноват сторонний парсер -- замените на потоковый (ijson вместо json для больших файлов).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Программа на C объявляет int x = 5 как локальную переменную в функции. Где физически живёт x во время выполнения функции?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4