Skip to content

Latest commit

 

History

History
109 lines (80 loc) · 7.77 KB

vla.md

File metadata and controls

109 lines (80 loc) · 7.77 KB

Varialbe Length Arrays

Память в C/C++ под наши объекты, как известно, можно выделять на стеке, а можно в куче. На стеке она обычнно выделяется автоматически и нам об этом сильно беспокоиться не надо.

int32_t foo(int32_t x, int32_t y) {
    int32_t z = x + y;
    return z;
}

При вызове функции foo на стеке, после аргументов (хотя не факт что аргументы будут переданы через стек), будет выделено (просто вершина стека будет сдвинута) еще 4 байта (а может быть и больше, кто ж знает, что там настроено у компилятора!) под переменную z. А может быть и не будет выделено (например, если компилятор оптимизирует переменную и сложит результат сразу в регистр rax). Дикая природа удивительна, неправда ли?

Освобождается память со стека тоже автоматически. Причем уже не обычно, а всегда. Если только, конечно, вы случайно не сломали стек, не сделали чудовищную ассемблерную вставку и теперь адрес возврата не ведет куда-то не туда или не используете attribute ( ( naked ) ). Но, мне кажется, в этих случаях у вас куда более серьезные проблемы... Во всех остальных случаях память со стека освобожается автоматически. Потому, как известно, вот такой код порождает висячий указатель

int32_t* foo(int32_t x, int32_t y) {
    int32_t z = x + y;
    return &z;
}

Обычно для выделения чего-то на стеке размер этого чего-то должен быть заранее известен на этапе компиляции. Обычно, но не всегда...


Однажды один большой и сложный HTTP сервер внезапно упал. Упал он, как ни странно, с моим любимым сообщением segmentation fault (core dumped). К этому все впрочем уже привыкли, ведь HTTP сервер был написан на чистом и прекрасном C. Так что падение -- это что-то само собой разумеюшееся.

Содержимое core файла было загадочным: строчка, на которую указывал dump, не делала ничего страшного. Она не разменовывала указатель, не писала в массив, не читала из массива, не освобождала память, не выделяля память... Ничего. Она просто пыталась вызвать функцию и передать в нее параметры. Но что-то пошло не так.

У нее закончился стэк.

Но как же так?! В core дампе было всего от силы 40 стэк фреймов! Как он мог закончится? Там же 10 мегабайт под Linux!

Путешествуя по этому стэк трейсу, я поднялся на пять стэк фреймов выше. Все они были довольно небольшого размера. 40 байт, 180, килобайт... А вот шестой фрейм оказался невероятно большим! 8 мегабайт!

Открывши соответствующий исходник, я обнаружил:

int encoded_len = request->content_len * 4 / 3 + 1;
char encoded_buffer[encoded_len];
encoded_len = encode_base64(request->content, content_len, encoded_buffer, encoded_len);
process(encoded_buffer, enconded_len);

Знакомьтесь, variable length array (VLA)! Прекрасная фича языка C. Существует в языке C++ как нестандартное расширение (MSVC не поддердживает, в GCC и Clang компилируется).

Это массив переменной длины на стеке. Причина падения была ясна.


VLA -- концептуально, фича довольно полезная. Но крайне небезопасная. Вам нужен буффер, но его длину вы узнаете только в runtime? Пожалуйста, VLA! Не нужен никакой malloc/new -- просто объяви массив и укажи длину! К тому же это в среднем намного быстрее чем malloc и не утечет, автоматически освободится! Ну и конечно же, что может быть лучше чем получить segfault вместо out-of-memory?

Лучше VLA может быть только прямое использование функции alloca(). Ведь в отличие от VLA, у нее намного больше вариативности по отрыванию ног

void fill(char* ptr, int n) {
    for (int i =0;i<n;++i) {
        ptr[i] = i * i;
    }
}

int use_alloca(int n) {
    char* ptr = (char*)alloca(n);
    fill(ptr, n);
    return ptr[n-1];
}

int main() {
    int n = 0;
    for (int i = 1; i < 10000; ++i) {
        n += use_alloca(i);
    }
    return n ? 0 : 1;
}

Тут каждый вызов alloca не приводит к переполнению стека сам по себе. Но если use_alloca будет заинлайнена компилятором по какой-либо причине, мы получим SIGSEGV

Использование alloca и VLA крайне не рекомендуется. man упоминает случай, когда их использование может быть оправдано: ваш код полагается на setjmp/longjump и нормальный менеджмент динамически выделенной памяти можеь быть осложнен, а стек все равно будет очищен даже при longjmp. Не буду спрашивать, зачем оно вам...

alloca и vla действительно в среднем быстрее чем динамическая аллокация. Но если уж нужно действительно быстро, то вариант с преаллоцированным массивом или массивом фиксированной длины получше будет

А В C++ (без расширений) VLA нет. Там есть шаблоны, а они не дружат с VLA.

#include <iostream>

template <size_t N>
void test_array(int (&arr)[N]) {
    std::cout << sizeof(arr) << "\n";
}

int main(int argc, char* argv[]) {
    int fixed[15];
    int vla[argc];
    test_array(fixed);
    test_array(vla); // compilation error
}

Полезные ссылки

  1. https://lwn.net/Articles/749064/
  2. https://man7.org/linux/man-pages/man3/alloca.3.html
  3. https://nullprogram.com/blog/2019/10/27/
  4. https://en.cppreference.com/w/c/language/array