Как стать автором
Обновить
0
Content AI
Решения для интеллектуальной обработки информации

Стековые переменные — быстрые и иногда мертвые

Время на прочтение4 мин
Количество просмотров25K
FAILПрограммы на C++ используют под локальные и временные переменные так называемую автоматическую память (automatic storage). Обычно автоматическая память реализована поверх стека программы, поэтому ее называют стековой. Ее большой плюс – выделение и освобождение памяти выполняется крайне быстро (обычно одна инструкция процессора). Ее большой минус – относительно небольшой объем, попытка выделить память сверх этого объема приводит к так называемому переполнению стека и тогда программа аварийно останавливается.

Из этого вытекает ограничение – нельзя пытаться выделить на стеке слишком много памяти. Слишком много? Сколько это? Ответ не так очевиден, как можно подумать на первый взгляд.



Слишком много – всего лишь больше, чем есть в наличии. Утверждение безукоризненно точное, но не очень полезное.

Сколько в наличии – зависит от разных факторов. Например, на Windows для главной нити программы объем стековой памяти задается в настройках компоновщика, а для остальных нитей – при их создании. Может быть, код будет выполняться в другой программе (модуль для веб-сервера), тогда объем стека будет задан веб-сервером (IIS ограничивает стек нитей для пользовательского кода до 256 килобайт, хотя размер по умолчанию в Windows – 1 мегабайт). На других системах могут быть ограничения уровня всей системы. В любом случае объем стека не очень велик и не всегда зависит от кода, который использует его под стековые переменные.

Какой объем стека используется – также не всегда зависит от разработчика.

Во-первых, код никогда не запускается сам по себе – его запускает кто-то. Есть некоторая точка входа – это пользовательская функция, в которую передается управление. До точки входа могла быть пачка функций, которые по цепочке вызвали друг друга и в итоге одна из них вызвала точку входа. Они тоже могут внести свой вклад в исчерпание стека, и от предельно разрешенного объема останется меньше, чем ожидает разработчик.

Во-вторых, даже в пользовательском коде не так очевидно, сколько стековой памяти используется. Читатель будет возмущен – как не очевидно? Взять и посчитать объем памяти под все локальные переменные – довольно просто. Например, тут…

//sample 0
void trivial()
{
   char buffer[4 * 1000 * 1000] = {};
   MessageBoxA( 0, buffer, buffer, 0 );
}


…очевидно, используется 4 мегабайта памяти, и на Windows (при объеме стека по умолчанию) это точно не полетит.

Хорошо, тогда пример сложнее. Идем на ideone.com. Оговорка: на момент написания поста в режиме C++ на ideone.com использовался gcc-4.3.4, в других версиях gcc поведение может отличаться. Пробуем такой код:

//sample 1
#include <stdio.h>
int main()
{
    char buffer[7 * 1000 * 1000] = {};
    printf( "%s", buffer );
}


Результат – success. Хорошо, теперь пробуем такой:

//sample 2
#include <stdio.h>
#include <stdlib.h>

int main()
{
    if( rand() ) {
       char buffer[7 * 1000 * 1000] = {};
       printf( "%s", buffer );
    } else {
       char buffer[6 * 1000 * 1000] = {};
       printf( "%s", buffer );
    }
}

КРАЙНЕ НЕОЖИДАННО

Результат – runtime error. Что произошло? Вызов rand() привел к исчерпанию стека?

Очень просто – в большинстве реализаций C++ в самом начале работы функции выделяется сразу весь нужный этой функции объем стековой памяти – компилятор просто вставляет код «выделить вооот столько» в начало функции (в Visual C++ для этого используется функция _chkstk(). Сколько выделить, решает компилятор, исходя из того, какие переменные он решит разместить в памяти (часть переменных отображается на регистры, а часть может оказаться в мертвом коде и быть просто удалена) и в каком порядке.

Во втором примере компилятор решил, что нужно выделить память под оба массива, проигнорировав, что эти массивы выделяются во взаимоисключающих ветвях оператора ветвления. Стандарт допускает такое поведение (раздел 3.7 ISO/IEC 14882:2003(E) говорит о «минимальном времени жизни»).

Потребление стековой памяти конкретной функцией, среди прочего, зависит от того, может ли компилятор переиспользовать память, занимаемую переменными в этой функции. Например, gcc-4.3.4 во втором примере не справился. Visual C++ 10 справляется со вторым примером, но зато не справляется в этом случае (предполагается, что объем стека 1 мегабайт):

//sample 3
class Temp {
public:
    Temp()
    {
        memset( buffer, 0, sizeof( buffer ) );
        printf( "%S", buffer );
    }
    void Process()
    {
        printf( "%S", buffer );
    }
private:
    WCHAR buffer[300 * 1024];
};

int _tmain(int argc, _TCHAR* argv[])
{
    switch( rand() ) {
    case 1:
        Temp().Process();
        break;
    case 2:
        Temp().Process();
        break;
    default:
        break;
    }
}


Сейчас читатель возразит, что «нечего выделять память под такие большие объекты на стеке», и это будет еще одно абсолютно верное и абсолютно бесполезное утверждение. В реальном мире исчерпание стека происходит по менее глупым причинам.

Например, был switch с 19 ветвями, в каждой из которых создавался временный объект как в примере выше. Код годами работал. Потом добавили 20-ю ветвь со своим временным объектом. Стек был, предположим, 256 килобайт, до вызова функции 56 уже были израсходованы. Для полного исчерпания стека тогда достаточно, чтобы каждый временный объект был в среднем чуть больше 20 килобайт, что уже не так вопиюще с точки зрения разработчика под Windows, привыкшего к мегабайтному стеку. 20 килобайт – все еще «хорошие разработчики не выделяют на стеке»? Хорошо, и 2-килобайтных объектов хватит, если стек уже почти исчерпан.

Что можно сделать? Вариантов три.

Вариант номер один – создавать «большие» объекты не на стеке. Очевидная альтернатива – динамическая память. С ней две проблемы. Во-первых, нужно будет самостоятельно позаботиться о своевременном правильном удалении объекта, но этот велосипед изобретать не нужно, есть умные указатели. Во-вторых, выделение и освобождение динамической памяти намного медленнее – одной инструкцией процессора там не обойтись, так что как минимум стоит оценить, не даст ли такой переход заметного замедления работы, а в случае подозрений профилировать этот код.

Вариант номер два – уменьшить объекты. Может быть, не обязательно использовать 256-килобайтный массив, а можно обойтись 4-килобайтным? Это не всегда возможно и это не исключает риск переполнения, но заметно снижает его во многих случаях.

Вариант номер три – делить функцию на несколько, чтобы память под объекты с непересекающимся временем жизни выделялась из разных функций.

Будьте готовы к тому, что компилятор не всегда выделяет память под стековые переменные так, как ожидает разработчик. Удостоиться премии Дарвина давно не было так просто.

Дмитрий Мещеряков,

департамент продуктов для разработчиков
Теги:
Хабы:
+37
Комментарии15

Публикации

Информация

Сайт
www.contentai.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия

Истории