Почему плохо распределяется динамическая память (для встроенной)
Если вам нужна гибкость и вы можете себе это позволить, используйте динамическую память. Если вы не можете себе этого позволить, используйте 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); но это увеличивает объем памяти. Иногда виртуальные классы являются необходимой злой абстракцией.
Таким образом, крайне важно учитывать требования к дизайну разрабатываемого программного обеспечения. Какой длины должны быть строки? Могут ли они быть ограничены максимальной длиной? Сколько элементов будет содержать максимум наш вектор? Является ли более удобным в обслуживании использование виртуальных классов здесь?
Всегда ли динамическая память плоха?
Динамическая память неплоха во всех случаях, если ее правильно использовать. Некоторые подходящие ситуации:
- Вы выделяете только во время инициализации. Например, мы хотим выделить память на основе параметра из файла конфигурации, и этот параметр не меняется во время выполнения.
- Трудно определиться с максимальной границей. Процитирую комментарий Reddit:
Поскольку размеры данных в сетях, подобных той, в которой используется [ESP32] IDF, чрезвычайно изменчивы, заранее предсказать использование памяти невозможно. Кроме того, сетевое взаимодействие чрезвычайно легко совместимо в режиме реального времени, особенно беспроводное. Все это в совокупности ослабляет ограничения и позволяет использовать malloc.
Существуют различные способы реализации динамического выделения памяти. «Лучший» метод зависит от вашего конкретного сценария. Пулы памяти — одна из таких реализаций, простая и легкая. FreeRTOS документирует другие реализации кучи, которые стремятся быть потокобезопасными. Куча 4 представляет особый интерес, так как снижает фрагментацию.
Вывод
В недавней дискуссии между дядей Бобом и Кейси Муратори о чистоте кода и производительности Боб резюмирует:
Операторы Switch имеют свое место. Динамический полиморфизм имеет свое место. Динамичные вещи более гибки, чем статичные, поэтому, когда вам нужна такая гибкость и вы можете себе это позволить, переходите к динамике. Если вы не можете себе этого позволить, оставайтесь на месте.
Это относится и к встроенной системной памяти. Трудно позволить себе динамичные вещи при ограниченных ресурсах. С более мощными микроконтроллерами легче обосновывать динамические вещи, будь то куча, полиморфизм и тому подобное. В конце концов, мы должны принять во внимание доступные ресурсы, требования к дизайну и варианты использования. Позвольте мне еще раз подвести итог. Если вам нужна гибкость и вы можете себе это позволить, используйте динамическую память. Если вы не можете себе этого позволить (как это часто бывает), используйте static. [4]
Примечание
[1] Даже если вы не используете его напрямую, он все равно там. Большинство языков со сборкой мусора (например, Java, JS, Python) размещают примитивы в стеке, а все остальные объекты — в куче.
[2] Под этим я подразумеваю, что стандартная библиотека C редко зависит от него; кроме, может быть, IO. Хотя на встроенном устройстве мы, вероятно, не будем много заниматься вводом-выводом.
[3] И если ваша функция использует несколько статически распределенных переменных, выделение будет объединено в одно гигантское вычитание стека. Вы можете поблагодарить свой компилятор за этот статический бонус.
[4] Разница между желанием и потребностью невелика, но IMO важна при программировании встроенных систем. Иногда мы можем захотеть использовать кучу, но это не нужно. Очень редко нам может понадобиться гибкость кучи, поскольку преимущества перевешивают затраты.