Синтаксис си/с++

Стек и куча: где живут переменные, разница в скорости и времени жизни

2 просмотров

Введение

Ранее мы упоминали «автоматическое» время жизни локальных переменных и динамическую память — в этой статье соберём эти концепции в единую картину, явно разобрав два основных региона памяти, используемых программой: стек (stack) и кучу (heap), их различия по скорости работы и управлению временем жизни.

Концепция

Стек — область памяти, организованная как структура данных того же названия (алгоритмического цикла) — каждый вызов функции добавляет новый «кадр» в стек, содержащий локальные переменные этой функции , и этот кадр автоматически удаляется при выходе из функции, что и обеспечивает автоматическое время жизни локальных переменных без необходимости явного освобождения. Куча — отдельная область памяти, используемая для динамического выделения — в отличие от стека, память в куче не освобождается автоматически при выходе из функции, в которой был вызван malloc/new, оставаясь выделенной до явного free/delete (или, для умных указателей, до автоматического срабатывания соответствующей логики освобождения). Выделение/осввобождение памяти на стеке существенно быстрее работы с кучей, поскольку стек — это просто сдвиг единственного указателя текущей вершины стека (без необходимости поиска свободного блока подходящего размера), тогда как куча требует более сложного управления, отслеживающего множество отдельных, не обязательно соседних выделенных и свободных блоков переменного размера.

Пример кода

#include <iostream>

void stackExample()
{
    int localVariable = 42; // создаётся на СТЕКЕ — автоматически удаляется при выходе из функции
    std::cout << "Адрес localVariable: " << &localVariable << std::endl;
} // localVariable больше не существует здесь — память автоматически "освобождена" (сдвигом стека)

void heapExample()
{
    int *heapVariable = new int(42); // создаётся в КУЧЕ — НЕ удаляется автоматически при выходе из функции!
    std::cout << "Адрес heapVariable: " << heapVariable << std::endl;
    delete heapVariable; // ОБЯЗАТЕЛЬНОЕ явное освобождение — иначе утечка памяти 
}

int main()
{
    stackExample();
    heapExample();
    return 0;
}
// Демонстрация разницы в скорости — выделение в стеке практически бесплатно
#include <chrono>
#include <iostream>

void measureStackAllocation()
{
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; ++i) {
        int stackArray[10]; // выделение НА СТЕКЕ — просто сдвиг указателя стека, очень быстро
        stackArray[0] = i;
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::cout << "Стек: " << std::chrono::duration<double, std::milli>(end - start).count() << " мс" << std::endl;
}

void measureHeapAllocation()
{
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1000000; ++i) {
        int *heapArray = new int[10]; // выделение в КУЧЕ — заметно медленнее из-за более сложного управления
        heapArray[0] = i;
        delete[] heapArray;
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::cout << "Куча: " << std::chrono::duration<double, std::milli>(end - start).count() << " мс" << std::endl;
    // Куча обычно ЗАМЕТНО медленнее стека для такого количества мелких, повторяющихся выделений
}
// Опасность висячей ссылки на возвращённый адрес стековой переменной 
int *dangerousFunction()
{
    int localValue = 100;
    return &localValue; // ОШИБКА — возвращаем адрес переменной, которая ПЕРЕСТАНЕТ существовать!
} // localValue уничтожена ЗДЕСЬ, при выходе из функции — возвращённый указатель теперь "висячий"

int main()
{
    int *danglingPointer = dangerousFunction();
    // *danglingPointer — неопределённое поведение, обращение к уже несуществующей памяти стека
    return 0;
}

Пояснения к коду

Сравнение stackExample()/heapExample() явно показывает разницу в управлении временем жизни — localVariable автоматически перестаёт существовать при выходе из функции без какого-либо явного кода для этого, тогда как heapVariable, указывающий на объект в куче, требует явного delete, иначе память останется выделенной (утечка,) даже после выхода из функции и потери самого указателя heapVariable. Замеры производительности (measureStackAllocation/measureHeapAllocation) количественно демонстрируют различие в скорости — стек выделяет память практически мгновенно (простой сдвиг указателя вершины стека), тогда как куча для каждого вызова new/delete выполняет значительно более сложную работу (поиск подходящего свободного блока, обновление внутренних структур учёта), что суммарно даёт заметно худшую производительность для большого количества мелких, частых выделений в куче по сравнению с эквивалентным использованием стека. dangerousFunction() явно демонстрирует классическую, серьёзную ошибку — возврат адреса локальной (стековой) переменной, время жизни которой заканчивается ровно в момент выхода из функции, что делает возвращённый указатель «висячим» немедленно после возврата из функции, до какого-либо реального использования этого указателя вызывающим кодом.

Подводные камни

  • Размещение слишком больших объектов на стеке (большие массивы или структуры, объявленные как обычные локальные переменные) — размер стека ограничен (обычно несколько мегабайт по умолчанию, хотя точный размер зависит от платформы и настроек) и существенно меньше, чем размер доступной кучи, и попытка выделить на стеке объект, превышающий доступное пространство стека (особенно в сочетании с глубокой рекурсией, алгоритмического цикла, каждый уровень которой добавляет собственный кадр стека), приводит к переполнению стека (stack overflow) — обычно немедленному и трудно диагностируемому без специальных инструментов краху программы.
  • Возврат указателя или ссылки на локальную (стековую) переменную из функции — как явно показано в примере dangerousFunction(), это одна из самых классических ошибок, связанных с пониманием различия стека и кучи, приводящая к неопределённому поведению при последующем использовании возвращённого «висячего» указателя/ссылки, и компиляторы часто (хотя не гарантированно во всех случаях) выдают предупреждение для подобных, легко обнаруживаемых статическим анализом случаев.
  • Чрезмерное, неоправданное использование динамического выделения (куча) для объектов с коротким, предсказуемым временем жизни, которые могли бы быть выделены на стеке — для каждого случая, где объект реально нужен только в пределах текущей функции/блока без необходимости «переживать» выход из неё, стековое выделение (через обычную локальную переменную) обычно предпочтительнее по производительности (статья выше про сравнение скорости) и автоматически избегает риска утечки памяти, и динамическое выделение в куче стоит применять осознанно, именно когда объект должен существовать дольше текущей функции, должен иметь размер, неизвестный на этапе компиляции, или должен совместно использоваться разными частями кода через общий указатель/умный указатель .
  • Игнорирование того, что объекты, выделенные на стеке (а не в куче), уничтожаются в строго обратном порядке относительно их создания (последний созданный — первый уничтоженный, LIFO-порядок, отражающий саму природу стека как структуры данных) — это поведение становится особенно важным при работе с RAII, где порядок уничтожения объектов (и, соответственно, порядок вызова их деструкторов,) может иметь значение для корректного, предсказуемого освобождения взаимозависимых ресурсов, и непонимание этого строгого, гарантированного порядка может привести к неожиданным последствиям для кода, неявно зависящего от конкретного порядка освобождения нескольких ресурсов.