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 выделил под нужды процесса. Это пространство не плоское: оно разделено на несколько именованных регионов.
Главное наблюдение: стек и куча растут навстречу друг другу. Стек идёт от высоких адресов вниз, куча — от низких вверх. Между ними — свободное виртуальное пространство, которое теоретически достаётся тому, кто первый закажет. На практике в современных системах их разделяет 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), который указывает на текущую вершину стека. Каждый вызов функции:
- Сдвигает rsp вниз на размер кадра функции (place для локальных переменных + сохранённые регистры + return-адрес).
- Кладёт return-адрес — куда вернуться после
ret. - Сохраняет регистры, которые функция собирается изменить.
- Выделяет место для локальных переменных простым вычитанием из rsp.
При выходе из функции — всё в обратном порядке: восстанавливаются регистры, rsp двигается обратно вверх, выполняется ret. Никакого malloc/free. Аллокация на стеке — это буквально вычитание из регистра. Одна инструкция CPU. Поэтому стек быстрый.
Что хранится в кадре функции:
- Локальные переменные — те, что объявлены внутри функции.
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.
В многопоточных программах 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... из прошлого примера — разные диапазоны.
Стек vs куча — сравнительная таблица
Высокоуровневые языки: где 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.
Скорость аллокации часто недооценивают. Создание 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.