DevGang
Авторизоваться

Почему плохо распределяется динамическая память (для встроенной)

Если вам нужна гибкость и вы можете себе это позволить, используйте динамическую память. Если вы не можете себе этого позволить, используйте static.

Я продолжаю объяснять, почему динамическое распределение во встроенных системах - неприятная идея, поэтому решил опубликовать ее в посте. Это запутанная тема для многих начинающих разработчиков, которых учили использовать new и delete на ранних курсах C++. В программировании настольных/веб-приложений динамическое распределение присутствует повсюду. [1] Не так во встроенном.

Чтобы уточнить, динамическое выделение памяти (во встраиваемых системах) не всегда плохо, так же как и goto не всегда плохо. И динамическое размещение, и goto имеют подходящее применение, но часто используются неправильно. Как инженеры, мы обязаны понимать, в каких ситуациях требуются эти функции, и делать правильный выбор.

Невежественные инженеры-программисты думают: «Чем более продвинута концепция, тем лучше». Не будь невежественным.
Невежественные инженеры-программисты думают: «Чем более продвинута концепция, тем лучше». Не будь невежественным.

Вопрос не только в распределении. Виртуальные классы, исключения, информация о типах во время выполнения (RTTI) — все это запрещено для некоторых компаний, занимающихся встраиваемыми системами. Их всех избегают по одним и тем же причинам: снижение производительности и раздувание кода. По сути, не хватило времени и не хватило места. С динамической памятью есть еще одна причина.

Так почему же динамическая память плохая?

Из-за фрагментации памяти. Это происходит, когда мы продолжаем выделять и освобождать память разных размеров. Это может привести к напрасной трате памяти, что приведет к более медленным выделениям (из-за необходимости перераспределения и сжатия памяти) или нашему худшему кошмару: исключению нехватки памяти.

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

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

Это серьезный вопрос. При разработке встроенных приложений следует учитывать сохраняемость, резервное копирование и сбросы, но следует избегать ошибочных сбросов — например, из-за сбоев OOM. Возможно, это не проблема, если вы работаете с ракетами.

Какие есть альтернативы?

В C динамическое размещение в значительной степени необязательно. [2]

В С++ это больше хлопот. Динамическое выделение памяти является ядром многих стандартных контейнеров библиотек: строк, векторов, карт. Arduino String также использует динамическую память. Без сомнения, эти библиотеки чрезвычайно полезны для организации данных и управления ими.

Альтернативой является использование статического распределения, предоставляя максимальную привязку к размерам нашего массива. Иногда это так же просто, как изменить объявления вашего массива.

// --- Dynamic ---

size_t size = 10;
int* array1 = new int[size];

// Use the array...
for (int i = 0; i < size; i++)
    array1[i] = i;

delete[] array1;


// --- Static ---

// Define a maximum capacity...
#define MAX_SIZE 100                // ...with a macro,
// constexpr size_t MAX_SIZE = 100; // ...or use C++'s type-safe constexpr.

int array2[MAX_SIZE];

// Use the array... (be careful to use `size` instead of `MAX_SIZE`).
for (int i = 0; i < size; i++)
    array2[i] = i;

Со сложными структурами данных требуется дополнительная работа, чтобы исключить динамическое выделение. Это то, чего достигают контейнеры ETL, в отличие от контейнеров STL.

ETL (встроенная библиотека шаблонов) является альтернативой стандартной библиотеке C++ и содержит множество стандартных функций, а также библиотеки, полезные для программирования встраиваемых систем (например, кольцевые буферы).
// STL
// Allocate a dynamic vector (on the heap). Capacity grows on-demand.
std::vector<int> vec1 = {1,2,3};

// ETL
// Allocate a static vector (on the stack) with fixed capacity.
etl::vector<int, 10> vec2 = {1,2,3};

Одним из преимуществ статического распределения является скорость. При динамическом распределении необходимо выяснить ограничения по размеру и перераспределить. Если распределитель исправен, он может сэкономить время за счет повторного использования ранее освобожденной корзины; но это все равно требует времени. При статике память либо выделяется заранее (в случае глобальных переменных), либо выделяется одной инструкцией (вычитанием указателя стека) [3]. Следовательно, лучшая производительность за счет гибкости.

Как насчет полиморфизма? Мы можем добиться статического полиморфизма через CRTP; но мы теряем возможность полиморфного контейнера (например, vector<Animal*>, vector<Shape*>). Мы также могли бы использовать типы суммы (например, std::variant); но это увеличивает объем памяти. Иногда виртуальные классы являются необходимой злой абстракцией.

Таким образом, крайне важно учитывать требования к дизайну разрабатываемого программного обеспечения. Какой длины должны быть строки? Могут ли они быть ограничены максимальной длиной? Сколько элементов будет содержать максимум наш вектор? Является ли более удобным в обслуживании использование виртуальных классов здесь?

Всегда ли динамическая память плоха?

Динамическая память неплоха во всех случаях, если ее правильно использовать. Некоторые подходящие ситуации:

  1. Вы выделяете только во время инициализации. Например, мы хотим выделить память на основе параметра из файла конфигурации, и этот параметр не меняется во время выполнения.
  2. Трудно определиться с максимальной границей. Процитирую комментарий Reddit:
Поскольку размеры данных в сетях, подобных той, в которой используется [ESP32] IDF, чрезвычайно изменчивы, заранее предсказать использование памяти невозможно. Кроме того, сетевое взаимодействие чрезвычайно легко совместимо в режиме реального времени, особенно беспроводное. Все это в совокупности ослабляет ограничения и позволяет использовать malloc.

Существуют различные способы реализации динамического выделения памяти. «Лучший» метод зависит от вашего конкретного сценария. Пулы памяти — одна из таких реализаций, простая и легкая. FreeRTOS документирует другие реализации кучи, которые стремятся быть потокобезопасными. Куча 4 представляет особый интерес, так как снижает фрагментацию.

Вывод

В недавней дискуссии между дядей Бобом и Кейси Муратори о чистоте кода и производительности Боб резюмирует:

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

Это относится и к встроенной системной памяти. Трудно позволить себе динамичные вещи при ограниченных ресурсах. С более мощными микроконтроллерами легче обосновывать динамические вещи, будь то куча, полиморфизм и тому подобное. В конце концов, мы должны принять во внимание доступные ресурсы, требования к дизайну и варианты использования. Позвольте мне еще раз подвести итог. Если вам нужна гибкость и вы можете себе это позволить, используйте динамическую память. Если вы не можете себе этого позволить (как это часто бывает), используйте static. [4]

Примечание

[1] Даже если вы не используете его напрямую, он все равно там. Большинство языков со сборкой мусора (например, Java, JS, Python) размещают примитивы в стеке, а все остальные объекты — в куче.

[2] Под этим я подразумеваю, что стандартная библиотека C редко зависит от него; кроме, может быть, IO. Хотя на встроенном устройстве мы, вероятно, не будем много заниматься вводом-выводом.

[3] И если ваша функция использует несколько статически распределенных переменных, выделение будет объединено в одно гигантское вычитание стека. Вы можете поблагодарить свой компилятор за этот статический бонус.

[4] Разница между желанием и потребностью невелика, но IMO важна при программировании встроенных систем. Иногда мы можем захотеть использовать кучу, но это не нужно. Очень редко нам может понадобиться гибкость кучи, поскольку преимущества перевешивают затраты.

Источник:

Комментарии
Чтобы оставить комментарий, необходимо авторизоваться