Массив попадает на стек, если его размер известен на этапе компиляции, он невелик и нужен только внутри одной функции. В кучу его переносят, когда размер вычисляется во время выполнения, измеряется сотнями килобайт и больше или когда данные должны пережить возврат из функции. Два условия, размер и время жизни, закрывают большинство практических случаев; остальные уточняются потокобезопасностью и ценой частых выделений.
Разница в скорости касается выделения и освобождения, а не чтения элементов. После того как память получена, обращение к arr[i] в обоих случаях сводится к арифметике адреса: базовый указатель плюс смещение. Стек выигрывает на цене выделения и предсказуемости расположения, куча — на гибкости размера и времени жизни.
Дальше: сравнение по ключевым параметрам, алгоритм выбора, механика переполнения стека, работа аллокаторов и арен, примеры на C, C++ и Go и инструменты диагностики.
Числа по умолчанию (размер стека, пороги аллокаторов) различаются между дистрибутивами, версиями libc и ядра. Проверяйте их в своём окружении командами ulimit -s, cat /proc/<pid>/limits и по версии компилятора. Ниже приведены типовые величины для 64-битных Linux-систем.
Стек и куча: ключевые различия для размещения массивов
Стек работает по принципу LIFO. Каждый вызов функции сдвигает указатель стека и резервирует кадр под локальные переменные, включая массивы фиксированного размера. Возврат из функции возвращает указатель на прежнее место, поиск свободных блоков не выполняется. Куча устроена иначе: память выдаёт аллокатор по запросу malloc, new или make, а освобождает её либо программист, либо сборщик мусора.
| Параметр | Стек потока | Куча процесса |
|---|---|---|
| Размер по умолчанию | Обычно 1–8 МБ на поток | Ограничен RAM и swap |
| Скорость выделения | Сдвиг указателя стека, единицы наносекунд | Поиск блока в структурах аллокатора, десятки и сотни наносекунд |
| Кто освобождает | Компилятор генерирует код, память возвращается при выходе из функции | free или delete вручную, в Go — сборщик мусора |
| Время жизни | До конца вызова функции | Пока память не освободят |
| Фрагментация | Не возникает | Возможна при чередовании блоков разного размера |
| Доступ из других потоков | Только через переданный указатель, и это опасно | Да, но требуется синхронизация |
Массивы на стеке почти всегда имеют размер, известный при компиляции. Исключение — массивы переменной длины (VLA) из C99, где размер задаётся в момент вызова. В куче размер может быть любым и меняться во время работы программы.
Скорость доступа и выделения памяти
Выделение места на стеке компилятор превращает в одну машинную инструкцию, например sub rsp, N. Это единицы наносекунд вне зависимости от того, сто элементов в массиве или тысяча. Выделение в куче проходит через аллокатор: он ищет подходящий свободный блок в своих списках и битовых картах, а при нехватке места запрашивает у ядра новые страницы через brk или mmap.
Практическая оценка: массив из 1000 int (4 КБ) на стеке выделяется практически мгновенно, а в куче та же операция стоит десятки и сотни наносекунд. В одиночном вызове разница незаметна, но в цикле на миллион итераций она превращается в разницу между десятками миллисекунд и секундами.
Доступ к элементам после выделения одинаков: base + i * sizeof(T). Отличается кэш-локальность. Стек горячий, его страницы постоянно используются, поэтому локальный массив с высокой вероятностью уже лежит в кэше. Блок из кучи может оказаться в холодной странице, и первый обход массива потратит время на промахи кэша.
Частое выделение и освобождение блоков разного размера дробит адресное пространство кучи. Свободные участки между занятыми блоками не удаётся переиспользовать под крупный массив, хотя суммарно памяти хватает. Аллокатор вынужден расширять кучу, а программа получает рост RSS и замедление.
Доступный объём и ограничения стека
Стек потока ограничен жёстко. В Linux размер стека главного потока обычно равен 8 МБ и задаётся лимитом RLIMIT_STACK, который виден через ulimit -s. Потоки, созданные pthread_create, в glibc по умолчанию получают стек того же порядка; явно задать его размер позволяет pthread_attr_setstacksize. В Windows значение по умолчанию для потока — 1 МБ, оно зашито в заголовок PE и меняется ключом /STACK компоновщика.
Как только массив не помещается в стек, поведение перестаёт быть управляемым. Адрес выходит за границу отображённой области, процесс получает SIGSEGV и завершается аварийно. Массив double[1000000] занимает 8 МБ и при стандартном лимите почти наверняка переполнит стек главного потока, а в рабочем потоке с меньшим стеком — гарантированно.
Лимит можно поднять: ulimit -s 16384 для текущей сессии или setrlimit из кода. В контейнерах и CI это работает не всегда, потому что лимит наследуется от процесса-родителя и может быть зафиксирован в манифесте. Проверяйте доступный запас до того, как полагаться на увеличение стека.
Время жизни данных и область видимости
Локальный массив живёт до выхода из функции. Память под ним исчезает вместе с кадром стека, и указатель на неё становится висячим. Классическая ошибка в C выглядит так:
char *read_buf(void) {
char buf[1024];
/* заполняем buf */
return buf; /* ошибка: буфер уничтожен при выходе из функции */
}
Использование такого указателя даёт неопределённое поведение: данные может перезаписать следующий вызов функции. Исправление одно: выделять буфер в куче через malloc и возвращать владение вызывающей стороне.
Массив в куче живёт, пока его не освободят. В C и C++ это делает программист, в Go — сборщик мусора, когда на срез больше нет ссылок. Go дополнительно решает задачу автоматически: escape analysis определяет, «убегает» ли значение из функции, и при необходимости переносит буфер в кучу. Подробнее про рост и копирование таких структур — в материале о динамических массивах и цене реаллокации.
Критерии выбора: когда массив на стеке, а когда в куче
Порядок принятия решения укладывается в пять шагов:
- Оцените размер. Меньше 1–2 КБ, размер известен при компиляции — стек.
- Сравните размер с лимитом стека. Больше 10% доступного стека — куча.
- Проверьте время жизни. Массив должен пережить функцию или быть возвращён — куча.
- Проверьте потоки. Данные читают или пишут несколько потоков — куча плюс синхронизация.
- Посчитайте частоту. Выделение в горячем цикле десятки тысяч раз в секунду — арена или пул.
Примеры по этой логике: буфер под чтение файла (куча, размер заранее неизвестен), временный массив под сортировку 50 элементов (стек), кэш разобранных записей (куча), аккумулятор внутри одной функции (стек).
Размер массива и риск переполнения стека
Считайте в байтах, а не в элементах. При стеке 8 МБ и типе int (4 байта) массив int[100000] занимает 400 КБ, это 5% лимита и безопасно. Массив int[2000000] занимает 8 МБ и выбирает весь стек целиком, включая кадры вызывающих функций. Порог в 10% от лимита оставляет запас на вызовы, локальные переменные и обработчики сигналов.
Рекурсия умножает проблему на глубину. Если на каждом уровне создаётся локальный массив на 64 КБ, при лимите 8 МБ стек закончится примерно на 120-м уровне. Обход глубокого дерева с таким массивом в кадре падает задолго до исчерпания данных. Решение: выделять буфер один раз в куче и передавать его в рекурсивную функцию параметром либо переписать обход итеративно.
Время жизни и необходимость возврата из функции
Если массив возвращается наружу или сохраняется после завершения функции, выбор один — куча. В C это malloc и явная передача ответственности за free. В C++ достаточно вернуть std::vector по значению: буфер лежит в куче, а перемещение делает возврат дешёвым. В Go возвращается срез, и буфер автоматически окажется в куче, потому что значение убегает из функции.
Статические и глобальные массивы живут всю программу и формально решают проблему времени жизни. У них две цены: фиксированный размер на этапе компиляции и общий доступ из всех потоков. Статический буфер, который переиспользуют несколько потоков, приводит к гонкам и порче данных, поэтому в многопоточном коде он пригоден только под защитой мьютекса.
Потокобезопасность и конкурентный доступ
Стек принадлежит конкретному потоку, поэтому массив на стеке изолирован по определению. Куча общая, и любой указатель на массив в куче, переданный в другой поток, требует синхронизации. Массив-счётчик в куче, который инкрементируют четыре потока, без атомарных операций или мьютекса теряет часть инкрементов.
Аллокатор тоже становится точкой конкуренции. malloc в glibc потокобезопасен, но при интенсивном выделении из многих потоков возникают блокировки арен. Современные аллокаторы (tcmalloc, jemalloc) снижают это за счёт потоковых кэшей: разные реализации аллокаторов имеют разные характеристики производительности и лучше подходят для разных нагрузок. В Go у каждой горутины свой стек, но срез, разделяемый между горутинами, размещается в куче и требует синхронизации доступа к элементам.
Переполнение стека при больших массивах: причины и решения
Механизм прост. При входе в функцию указатель стека сдвигается на суммарный размер локальных переменных. Если новый указатель выходит за границу области, отведённой под стек, срабатывает защитная страница, ядро отправляет SIGSEGV, и процесс завершается. Пример, который падает при стандартном лимите:
int main(void) {
int arr[10000000]; /* 40 МБ на стеке при лимите 8 МБ */
arr[0] = 1;
return 0;
}
Компилятор не предупреждает о таком коде: размер массива корректен, а лимит стека на этапе сборки неизвестен.
Диагностика stack overflow
Признаки: падение с сообщением «Segmentation fault (core dumped)» сразу после входа в функцию с крупной локальной переменной, отсутствие роста потребления памяти перед падением, в dmesg запись о срабатывании защитной страницы стека.
ulimit -s cat /proc/$(pidof app)/limits | grep -i stack dmesg | grep -i "stack" gcc -fstack-usage app.c
Ключ -fstack-usage заставляет GCC для каждой функции создать файл .su с двумя числами: статический размер кадра и признак динамического выделения. Так находится функция-виновник без гадания. В отладчике gdb команда backtrace показывает глубокую рекурсию, а info frame — размер кадра текущего вызова. В Go похожие ситуации ловятся трассировкой и отчётом о превышении лимита горутинного стека, который runtime увеличивает автоматически до определённого предела.
Способы избежать переполнения
- Перенести массив в кучу: malloc, new, make. Самый предсказуемый вариант.
- Увеличить стек: ulimit -s 16384 для сессии или setrlimit и pthread_attr_setstacksize в коде.
- Использовать статическую или глобальную память, помня про общий доступ из потоков.
- В C++ применять std::vector или std::unique_ptr<int[]> вместо сырых массивов.
- В Go явно вызывать make([]int, N): большой буфер уйдёт в кучу, а не на стек горутины.
- Разбить обработку на блоки и держать в памяти только один фрагмент.
Увеличение стека помогает не всегда. При тысяче потоков по 16 МБ виртуальная память заканчивается раньше, чем физическая, а в контейнере лимит может быть зафиксирован. Рост стека часто маскирует архитектурную проблему: массив такого размера в кадре функции нужен редко.
Аллокаторы и арены: управление частыми выделениями массивов в куче
Когда программа создаёт и уничтожает массивы десятками тысяч раз в секунду, основное время уходит не на работу с данными, а на служебные операции аллокатора.
Когда стандартный malloc недостаточно эффективен
Типовой сценарий: сетевой сервис на несколько тысяч запросов в секунду, под каждый запрос выделяется буфер. Каждый вызов malloc и free — это поиск блока, обновление служебных структур и периодические блокировки. Выделение и освобождение миллиона массивов по 1 КБ через стандартный аллокатор занимает секунды, а через арену укладывается в миллисекунды, потому что арена лишь сдвигает указатель.
Вторая проблема — фрагментация. Если блоки разного размера чередуются, свободной памяти суммарно достаточно, но непрерывного участка под крупный массив нет. Аллокатор не может выполнить запрос, хотя RSS растёт.
Арены и пулы: примеры и сценарии использования
Арена — область памяти, из которой блоки выдаются последовательно и не освобождаются по отдельности. Память возвращается целиком, когда арена больше не нужна. В C++ для этого есть std::pmr::monotonic_buffer_resource:
std::pmr::monotonic_buffer_resource arena; std::pmr::vector<int> v(&arena); v.resize(1024); /* отдельные элементы не освобождаются: память вернётся вместе с arena */
В C распространён приём подмены аллокатора через LD_PRELOAD: tcmalloc или jemalloc включаются без правки кода и дают потоковые кэши и арены вместо одной общей кучи. tcmalloc изначально разработан Google для ускорения многопоточной аллокации и снижения конкуренции за блокировки за счёт thread-local и core-local кэшей. В Go для переиспользования буферов применяется sync.Pool:
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 4096) },
}
func handle() {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf[:0])
_ = buf
}
Арены выигрывают в парсерах, компиляторах, игровых движках и обработчиках запросов, где много мелких объектов с одинаковым временем жизни. Они плохо подходят для долгоживущих объектов: пока арена жива, вся её память занята, даже если освободилась большая часть блоков. Резервирование под арену стоит делать по фактическому пику потребления, иначе RSS вырастет без пользы.
Практические примеры на C, C++ и Go
Синтаксис отличается, логика выбора одна и та же: фиксированный небольшой размер — стек, изменяемый или крупный — куча.
C: ручное управление и VLA
void handle(void) {
int stack_buf[256]; /* 1 КБ на стеке */
int *heap_buf = malloc(1024 * sizeof(int)); /* 4 КБ в куче */
if (heap_buf == NULL) {
return; /* проверка обязательна */
}
/* работа с буферами */
free(heap_buf);
}
stack_buf освобождается автоматически, heap_buf требует free на всех путях выхода, включая обработку ошибок. Массив переменной длины размещается на стеке, и его размер известен лишь в момент вызова:
void process(int n) {
int vla[n]; /* память на стеке, размер проверяется вручную */
}
При n порядка сотен тысяч VLA переполняет стек, а компилятор об этом не предупредит. Функция alloca даёт тот же эффект и ту же опасность, но освобождает память при выходе из функции автоматически. В C++ ни VLA, ни alloca не входят в стандарт.
C++: std::array, std::vector и умные указатели
std::array<int, 100> on_stack{}; /* стек, размер в типе */
std::vector<int> on_heap(100); /* куча, размер меняется */
auto ptr = std::make_unique<int[]>(100); /* куча, освобождение автоматическое */
std::array хранит элементы внутри объекта, поэтому его размер входит в кадр стека: массив на несколько мегабайт внутри функции так же опасен, как сырой. Копирование std::array побайтовое, зато возврат по значению обычно оптимизируется и обходится без копии. std::vector держит буфер в куче, умеет расти и сокращаться, а при выходе из области видимости освобождает память. Для динамических массивов vector остаётся выбором по умолчанию, unique_ptr нужен там, где требуется именно сырой массив без накладных расходов на размер и ёмкость.
Go: escape analysis и срезы
func create() []int {
arr := make([]int, 1000)
return arr // срез убегает из функции, буфер окажется в куче
}
func use() int {
arr := make([]int, 1000) // без возврата буфер может остаться на стеке
return len(arr)
}
Компилятор Go сам решает, где размещать значение. Если ссылка не покидает функцию, буфер остаётся на стеке горутины; если массив возвращается, передаётся в горутину или сохраняется в интерфейсе, он уходит в кучу. Проверить решение можно сборкой с флагами анализа:
go build -gcflags=-m ./...
Вывод помечает строки как «escapes to heap» или «does not escape». Стеки горутин растут динамически, но это не отменяет планирования: буферы на сотни килобайт, которые создаются в горячем цикле и остаются в куче, нагружают сборщик мусора. Пулы из sync.Pool снимают значительную часть этой нагрузки.
Диагностика и инструменты профилирования
Инструменты делятся на две группы: одни показывают факты о выделениях, другие ловят ошибки доступа к памяти.
Инструменты для C и C++
valgrind --tool=memcheck ./app valgrind --tool=massif ./app heaptrack ./app gcc -fsanitize=address -g app.c -o app perf stat -e cache-misses ./app
memcheck находит утечки, чтение неинициализированной памяти и выход за границы буфера. Massif строит график потребления кучи и показывает пик. heaptrack разбирает трассировки выделений и указывает функции с наибольшим числом аллокаций. AddressSanitizer даёт ту же информацию о переполнениях буфера при меньших накладных расходах, чем valgrind. perf stat по счётчику cache-misses показывает, насколько удачно массив ложится в кэш.
Инструменты для Go
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap go tool pprof -http=:8080 http://localhost:6060/debug/pprof/allocs go tool trace ./trace.out GODEBUG=efence=1 ./app
Профиль heap показывает, какие объекты занимают память в момент снятия, allocs — сколько всего было выделено за время работы. Переменная GODEBUG управляет отладочными переменными рантайма Go и представляет собой список пар name=val, разделённых запятыми. Режим efence=1 переводит аллокатор в особый режим и дополнительно отключает консервативное сканирование стека, используемое для асинхронно вытесненных горутин. Цена режима — резкий рост потребления памяти, поэтому применять его стоит на тестовом стенде. Профилирование замедляет программу, и снимать метрики в промышленной эксплуатации без необходимости не стоит.
Типичные ошибки и антипаттерны
Большинство падений и утечек связано с четырьмя ситуациями, которые повторяются из проекта в проект.
Ошибки времени жизни и указателей
Возврат указателя на локальный массив — самая частая ошибка новичков в C и C++. Второе место занимает использование памяти после free: указатель формально указывает на корректный адрес, но блок уже возвращён аллокатору и может быть выдан под другие данные.
int *p = malloc(100 * sizeof(int)); free(p); p[0] = 5; /* использование после освобождения */
Лечится привычкой обнулять указатель сразу после free и проверять его перед использованием. В C++ переход на unique_ptr и shared_ptr убирает целый класс таких дефектов, потому что владение описывается типом.
Ошибки управления памятью в куче
Утечка возникает, когда free пропущен на одном из путей выхода, например в ветке обработки ошибки. Двойное освобождение ломает структуры аллокатора и приводит к падению в точке, не связанной с ошибкой. Обе проблемы ловятся valgrind и AddressSanitizer до выпуска в эксплуатацию.
Отдельный антипаттерн — VLA с размером, пришедшим из внешнего ввода. Пользовательский параметр в сотни мегабайт переполнит стек, а сервис упадёт от одного запроса. Входные размеры всегда проверяются и ограничиваются константой перед выделением памяти.
Влияние на производительность и кэш-локальность
Размещение массива определяет не скорость арифметики адреса, а то, насколько часто процессор ждёт данные из памяти.
Кэш-локальность и предзагрузка
Кэш-линия на x86 и ARM занимает 64 байта, то есть шестнадцать значений типа int. Последовательный обход массива укладывается в аппаратную предзагрузку: процессор замечает линейный шаг и подтягивает следующие линии заранее. Результат зависит от того, где лежат данные и что находится рядом.
Локальный массив на стеке располагается в горячей странице, к которой процессор обращался только что, поэтому первые обращения редко промахиваются. Блок из кучи может быть выдан из только что запрошенной у ядра страницы, и тогда первые проходы по массиву стоят промахов кэша. Разница ощутима на массивах размером в десятки и сотни килобайт, которые не влезают в кэш второго уровня. На массивах в сотни мегабайт она исчезает: данные всё равно не помещаются в кэш, и всё решает порядок обхода. Как порядок индексов влияет на промахи, разобрано в статье про row-major и column-major обход многомерных массивов.
Раскладка структур добавляет свой эффект: поля с общим доступом стоит держать рядом, чтобы лишние байты не занимали кэш-линию. Практические приёмы собраны в материале о выравнивании и padding в массивах структур.
NUMA и многопроцессорные системы
На двухсокетных серверах память разделена на узлы: обращение к чужому узлу идёт по межпроцессорной шине и стоит заметно дороже локального. Поток, которому аллокатор выдал память на дальнем узле, будет обходить массив медленнее. Смягчают это привязкой потока к узлу (numactl, cpuset) и использованием аллокаторов с поддержкой NUMA, а также первым касанием страниц тем потоком, который будет с ними работать.
Для больших массивов разница между стеком и кучей по NUMA-эффекту невелика: многостраничный буфер всё равно распределяется по узлам. Здесь важнее выравнивание по границе кэш-линии, чтобы избежать ложного разделения (false sharing), когда два потока пишут в разные переменные одной кэш-линии и вынуждены постоянно синхронизировать кэш.
Что сделать перед следующим изменением кода
Проверьте лимит стека в своём окружении: ulimit -s и cat /proc/<pid>/limits покажут фактический запас, а gcc -fstack-usage даст размеры кадров функций. Соберите список локальных массивов крупнее нескольких килобайт и перенесите их в кучу. Для Go выполните go build -gcflags=-m и убедитесь, что большие буферы в горячих путях не уходят в кучу без необходимости, а повторяющиеся выделения закрыты пулом. Эти три проверки занимают меньше часа и снимают основную часть рисков, связанных с размещением массивов.