Бесплатный урок из курса «С++ без аллокаций памяти» на Stepik.
Использование кучи сильно упрощает жизнь программиста. Например, когда нужно создать строку, размер которой заранее неизвестен, куча позволяет выделять ровно столько памяти, сколько нужно в момент выполнения. Это удобно, потому что не нужно гадать, какой размер строки выбрать заранее.
Если же ограничиваться только стеком, приходится заранее резервировать память под строку фиксированного размера во время компиляции. Это неудобно и опасно: если на самом деле строка окажется длиннее, чем выделенный буфер, произойдёт переполнение буфера, что может привести к краху программы или уязвимости безопасности.
С другой стороны, использование стека имеет свои преимущества и компромиссы. Память в стеке выделяется очень быстро, почти без затрат процессора, и автоматически освобождается при выходе из функции. Но при этом невозможно гибко менять размер данных во время выполнения, и программист должен заранее знать, сколько памяти потребуется для локальных переменных. То есть стек эффективен и быстр, но менее гибок по сравнению с кучей, а также предлагает определенные свойства которых нет у кучи:
- Детерминированность стека
- Фрагментация
- Увеличение размера бинарника
- Обьем используемой памяти
Детерминированность стека
Это один из больших плюсов использования стека по сравнению с кучей. Память в стеке выделяется только внутри функций и автоматически освобождается, когда функция заканчивает работу. Это значит, что текущее использование памяти полностью зависит от того, какие функции вызваны в данный момент и какие кадры стека у них заняты.
void bar() {}
void foo() { bar(); }
void foobar() { foo(); }
каждая функция создаёт свой собственный кадр стека — небольшой участок памяти, где хранятся локальные переменные, параметры и адрес возврата. Если мы знаем размеры всех этих кадров стека, мы можем точно посчитать, сколько памяти будет использоваться при вызове foobar. При этом, если не использовать рекурсию, программа никогда не превысит этот максимальный объём памяти — он детерминирован.
Рассчитать это может быть немного сложно, особенно если используются указатели на функции или сложные вызовы через другие функции, но главное отличие от кучи в том, что при работе со стеком не нужно проверять, хватит ли памяти. Вы просто используете кадр стека, и он гарантированно будет доступен. Стек работает быстро и предсказуемо, а ошибки вроде утечек памяти встречаются очень редко, потому что освобождение памяти происходит автоматически.
Фрагментация памяти — это проблема, которая возникает при работе с динамической памятью (кучей). Представьте себе книжную полку: если вы постоянно ставите и убираете книги разного размера, со временем между оставшимися книгами появляются небольшие промежутки. Эти промежутки слишком малы для больших книг, хотя общий объем свободного места может быть достаточным.
То же самое происходит и с памятью компьютера. Когда программа многократно выделяет и освобождает блоки памяти разного размера, между занятыми участками остаются небольшие "дырки". Эти дырки нельзя объединить для размещения большого объекта, даже если их общий размер был бы достаточен.
Допустим, у нас есть 4 байта доступной памяти в куче – мы ожидаем, что следующий код будет работать.
char* x = new char; // 3 байта свободно
char* y = new char; // 2 байта свободно
delete x; // 3 байта свободно
char* z = new char[3]; /// упс!
Причина, по которой это не работает, заключается в том, что память фрагментирована. Доступно 3 байта, но только два из них находятся рядом друг с другом. Наша куча "фрагментирована", поэтому мы не можем полностью использовать доступную память.
Мы можем наглядно увидеть, почему Z не может быть выделена, если изобразим использование памяти для каждой строки кода:
/** память используется: | 0x0 | 0x1 | 0x2 | 0x3 | */
// | | | | |
char* x = new char; // | x | | | |
char* y = new char; // | x | y | | |
delete x; // | | y | | |
char* z = new char[3]; // | | y | z | z | z ????
В отличие от кучи, стек не может фрагментироваться: он всегда увеличивается и уменьшается непрерывными блоками в одном направлении.
Другой проблемой детерминизма является то, что выделение памяти в куче не имеет детерминированной верхней границы времени выполнения в большинстве реализаций. Это проблематично в игр, приложений на мобильных платформах и консолях.
Проблемы с кучей
// Эта операция может занять разное время
char* buffer = new char[1024];
Время выделения зависит от:
- Степени фрагментации памяти
- Размера запрашиваемого блока
- Алгоритма менеджера памяти
Увеличение размера программы
Использование кучи требует программной логики для управления предыдущими выделениями памяти, поиска свободной памяти и т.д. Если вы используете кучу, вы заметите значительное увеличение размера программы.
Мы можем это быстро проверить с помощью простой программы:
int main()
{
#if defined(USE_HEAP)
int* dummy = new int{42};
#endif
return 0;
}
g++ .\example.cpp -o no_heap.elf --specs=rdimon.specs
g++ .\example.cpp -o heap.elf --specs=rdimon.specs -DUSE_HEAP=1
size heap.elf
text data bss dec hex filename
114144 2796 336 117276 1ca1c heap.elf
size no_heap.elf
text data bss dec hex filename
7460 2420 288 10168 27b8 no_heap.elf
Как видите, разница составляет почти 100 КБ при добавлении кучи. Обратите внимание, что это можно оптимизировать, но использование кучи остается серьезным фактором влияния на объем памяти. Поскольку куча полностью управляется во время выполнения, она требует библиотеки времени выполнения, которая сама нуждается в памяти для отслеживания состояния кучи. Добавьте к проблеме фрагментации требования к памяти для управления кучей, и вы обнаружите, что использование памяти кучи связано с большими накладными расходами.
Проблемы с кучей в разработке игр
Эти проблемы становятся особенно явными в игровой разработке, где требуется стабильная производительность и эффективное использование ограниченной памяти консолей и мобильных устройств. Может быть выгодно минимизировать или полностью исключить стандартную кучу по следующим причинам:
- Устранить непредсказуемые задержки при выделении памяти во время критичных игровых моментов (например, во время взрывов или смены уровней)
- Исключить возможность фрагментации памяти, которая может привести к нехватке памяти для больших ресурсов (текстуры, модели, звуки)
- Уменьшить размер игрового движка за счет исключения сложных процедур выделения памяти
- Снизить потребление RAM во время игры за счет исключения служебных структур менеджера памяти
- Обеспечить предсказуемое время отклика для всего приложения
Однако отказ от стандартной кучи не гарантирует решения всех проблем с производительностью. Игровым разработчикам по-прежнему нужно писать высококачественный код с эффективным управлением ресурсами. Вот где пригодятся специализированные техники управления памятью в играх.
Практические решения для игровой (и не только) разработки
Вместо теоретического изучения алгоритмов управления памятью, которое может оказаться слишком абстрактным для практической разработки игр, мы в этом курсе сосредоточимся на конкретных игровых сценариях и проверенных решениях, которые позволят быстро применить эффективные паттерны управления памятью в ваших проектах. Вы сможете видеть влияние каждой оптимизации на производительность через профилировщики и бенчмарки.
← Все статьи