C memory manager

Управление памятью в C++

C memory manager

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

В статье я рассмотрю парочку таких техник.

Примеры в статье отличаются (например, от этого) тем, что используется перегрузка операторов new и delete и за счёт этого синтаксические конструкции будут минималистичными, а переделка программы — простой.

Также описаны подводные камни, найденные в процессе (конечно, гуру, читавшие стандарт от корки до корки, не удивятся).

0. А нужна ли нам ручная работа с памятью?

В первую очередь проверим, насколько умный аллокатор может ускорить работу с памятью. Напишем простые тесты для C++ и C# (C# известен прекрасным менеджером памяти, который делит объекты по поколениям, использует разные пулы для объектов разных размеров и т.п.). class Node {public: Node* next;};// …

for (int i = 0; i < 10000000; i++) { Node* v = new Node();} class Node{ public Node next;}// ...for (int l = 0; l < 10000000; l++){ var v = new Node();} Несмотря на всю «сферично-вакуумность» примера, разница по времени получилась в 10 раз (62 ms против 650 ms).

Кроме того, c#-пример закончен, а по правилам хорошего тона в c++ выделенные объекты надо удалить, что ещё больше увеличит отрыв (до 2580 ms).

1. Пул объектов

Очевидное решение — забрать у ОС большой блок памяти и разбить его на равные блоки размера sizeof(Node), при выделении памяти брать блок из пула, при освобождении — возвращать в пул. Пул проще всего организовать с помощью односвязного списка (стека).

Поскольку стоит задача минимального вмешательства в программу, всё что можно будет сделать, это добавить примесь BlockAlloc к классу Node:class Node : public BlockAlloc Прежде всего нам понадобится пул больших блоков (страниц), которые забираем у ОС или C-runtime.

Его можно организовать поверх функций malloc и free, но для большей эффективности (чтобы пропустить лишний уровень абстракции), используем VirtualAlloc/VirtualFree. Эти функции выделяют память блоками, кратными 4K, а также резервируют адресное пространство процесса блоками, кратными 64K.

Одновременно указывая опции commit и reserve, мы перескакиваем ещё один уровень абстракции, резервируя адресное пространство и выделяя страницы памяти одним вызовом.

Класс PagePoolinline size_t align(size_t x, size_t a) { return ((x-1) | (a-1)) + 1; }//#define align(x, a) ((((x)-1) | ((a)-1)) + 1) templateclass PagePool{public: void* GetPage() { void* page = VirtualAlloc(NULL, PageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); pages.push_back(page); return page; } ~PagePool() { for (vector::iterator i = pages.

begin(); i != pages.

end(); ++i) { VirtualFree(*i, 0, MEM_RELEASE); } }private: vector pages;}; Затем организуем пул блоков заданного размера Класс BlockPooltemplateclass BlockPool : PagePool{public: BlockPool() : head(NULL) { BlockSize = align(sizeof(T), Alignment); count = PageSize / BlockSize; } void* AllocBlock() { // todo: lock(this) if (!head) FormatNewPage(); void* tmp = head; head = *(void**)head; return tmp; } void FreeBlock(void* tmp) { // todo: lock(this) *(void**)tmp = head; head = tmp; }private: void* head; size_t BlockSize; size_t count; void FormatNewPage() { void* tmp = GetPage(); head = tmp; for(size_t i = 0; i < count-1; i++) { void* next = (char*)tmp + BlockSize; *(void**)tmp = next; tmp = next; } *(void**)tmp = NULL; }}; [/attention]Комментарием // todo: lock(this) помечены места, которые требуют межпоточной синхронизации (например, используйте EnterCriticalSection или boost::mutex).
Объясню, почему при «форматировании» страницы не ипользуется абстракция FreeBlock для добавления блока в пул. Если бы было написано что-то вроде for (size_t i = 0; i < PageSize; i += BlockSize) FreeBlock((char*)tmp+i); То страница по принципу FIFO оказалась бы размеченной «наоборот»: Несколько блоков, затребованных из пула подряд, имели бы убывающие адреса. А процессор не любит ходить назад, от этого у него ломается Prefetch (UPD: Не актуально для современных процессоров). Если же делать разметку в цикле
for (size_t i = PageSize-(BlockSize-(PageSize%BlockSize)); i != 0; i -= BlockSize) FreeBlock… то цикл разметки ходил бы по адресам назад. Теперь, когда приготовления сделаны, можно описать класс-примесь.templateclass BlockAlloc{public: static void* operator new(size_t s) { if (s != sizeof(T)) { return ::operator new(s); } return pool.AllocBlock(); } static void operator delete(void* m, size_t s) { if (s != sizeof(T)) { ::operator delete(m); } else if (m != NULL) { pool.FreeBlock(m); } } // todo: implement nothrow_t overloads, according to borisko' comment // http://habrahabr.ru/post/148657/#comment_5020297 // Avoid hiding placement new that's needed by the stl containers… static void* operator new(size_t, void* m) { return m; } // …and the warning about missing placement delete… static void operator delete(void*, void*) { } private: static BlockPool pool;};template BlockPool BlockAlloc::pool;
Объясню, зачем нужны проверки if (s != sizeof(T))
Когда они срабатывают? Тогда, когда создаётся/удаляется класс, отнаследованный от базового T. Наследники будут пользоваться обычными new/delete, но к ним также можно примешать BlockAlloc. Таким образом, мы легко и безопасно определяем, какие классы должны пользоваться пулами, не боясь сломать что-то в программе. Множественное наследование также прекрасно работает с этой примесью. Готово. Наследуем Node от BlockAlloc и заново проводим тест. Время теста теперь — 120 ms. В 5 раз быстрее. Но в c# аллокатор всё же лучше. Наверное, там не просто связный список. (Если же сразу после new сразу вызывать delete, и тем самым не тратить много памяти, умещая данные в кеш, получим 62 ms. Странно. В точности, как у .NET CLR, как будто он возвращает освободившиеся локальные переменные сразу в соответствующий пул, не дожидаясь GC)

2. Контейнер и его пёстрое содержимое

Часто ли попадаются классы, которые хранят в себе массу различных дочерних объектов, таких, что время жизни последних не дольше времени жизни родителя? Например, это может быть класс XmlDocument, наполненный классами Node и Attribute, а также c-строками (char*), взятыми из текста внутри нод.
Или список файлов и каталогов в файловом менеджере, загружаемых один раз при перечитывании каталога и больше не меняющихся. Как было показано во введении, delete обходится дороже, чем new. Идея второй части статьи в том, чтобы память под дочерние объекты выделять в большом блоке, связанном с Parent-объектом.
При удалении parent-объекта у дочерних будут, как обычно, вызваны деструкторы, но память возвращать не потребуется — она освободиться одним большим блоком. Создадим класс PointerBumpAllocator, который умеет откусывать от большого блока куски разных размеров и выделять новый большой блок, когда старый будет исчерпан.
Класс PointerBumpAllocatortemplateclass PointerBumpAllocator{public: PointerBumpAllocator() : free(0) { } void* AllocBlock(size_t block) { // todo: lock(this) block = align(block, Alignment); if (block > free) { free = align(block, PageSize); head = GetPage(free); } void* tmp = head; head = (char*)head + block; free -= block; return tmp; } ~PointerBumpAllocator() { for (vector::iterator i = pages.begin(); i != pages.
end(); ++i) { VirtualFree(*i, 0, MEM_RELEASE); } } private: void* GetPage(size_t size) { void* page = VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); pages.push_back(page); return page; } vector pages; void* head; size_t free;};typedef PointerBumpAllocator DefaultAllocator; Наконец, опишем примесь ChildObject с перегруженными new и delete, обращающимися к заданному аллокатору: templatestruct ChildObject{ static void* operator new(size_t s, A& allocator) { return allocator.AllocBlock(s); } static void* operator new(size_t s, A* allocator) { return allocator->AllocBlock(s); } static void operator delete(void*, size_t) { } // *1 static void operator delete(void*, A*) { } static void operator delete(void*, A&) { }private: static void* operator new(size_t s);}; В этом случае кроме добавления примеси в child-класс необходимо будет также исправить все вызовы new (или воспользоваться паттерном «фабрика»). Синтаксис оператора new будет следующим: new (… параметры для оператора… ) ChildObject (… параметры конструктора… ) Для удобства я задал два оператора new, принимающих A& или A*. Если аллокатор добавлен в parent-класс как член, удобнее первый вариант:node = new(allocator) XmlNode(nodename); Если аллокатор добавлен как предок (примесь), удобнее второй:node = new(this) XmlNode(nodename); Понятно, что указатель и ссылка взаимно конвертируются, разделение этих случаев — избавления от лишних значков. Для вызова delete не предусмотрен специальный синтаксис, компилятор вызовет стандартный delete (отмеченный *1), независимо от того, какой из операторов new был использован для создания объекта. То есть, синтаксис delete обычный:delete node; Если же в конструкторе ChildObject (или его наследника) происходит исключение, вызывается delete с сигнатурой, соответствующей сигнатуре оператора new, использованном при создании этого объекта (первый параметр size_t будет заменён на void*). Размешение оператора new в секции private защищает от вызова new без указания аллокатора. Приведу законченный пример использования пары Allocator-ChildObject:Примерclass XmlDocument : public DefaultAllocator{public: ~XmlDocument() { for (vector::iterator i = nodes.begin(); i != nodes.end(); ++i) { delete (*i); } } void AddNode(char* content, char* name) { char* c = (char*)AllocBlock(strlen(content)+1); strcpy(c, content); char* n = (char*)AllocBlock(strlen(name)+1); strcpy(n, content); nodes.push_back(new(this) XmlNode(c, n)); } class XmlNode : public ChildObject { public: XmlNode(char* _content, char* _name) : content(_content), name(_name) { } private: char* content; char* name; }; private: vector nodes;};
Заключение. Статья была написана 1.5 года назад для песочницы, но увы, не понравилась модератору.
Хабы:
  • Программирование
  • C++
  • Системное программирование
Источник: https://habr.com/ru/post/148657/

Почему мы используем менеджеры памяти?

C memory manager
Я видел, что многие кодовые базы, особенно серверные коды, имеют базовые (иногда продвинутые) менеджеры памяти. Реальная цель диспетчера памяти состоит в том, чтобы уменьшить количество вызовов malloc или, главным образом, с целью анализа памяти, проверки повреждения или может быть другими целями, ориентированными на приложения.
Является ли аргумент сохранения вызовов malloc достаточно разумным, поскольку malloc сам по себе является диспетчером памяти. Единственный выигрыш в производительности, который я могу объяснить, это когда мы знаем, что система всегда запрашивает память одинакового размера.
Или причина наличия диспетчера памяти в том, что free не возвращает память в ОС, а сохраняет в списке. Таким образом, в течение жизненного цикла процесса использование кучи процесса может увеличиться, если мы продолжим выполнять malloc / free из-за фрагментации.
7
mallocявляется распределителем общего назначения — «не медленный» важнее, чем «всегда быстро».
Рассмотрим функцию, которая во многих распространенных случаях будет улучшена на 10%, но в некоторых редких случаях может привести к значительному снижению производительности. Распределитель для конкретного приложения может избежать редкого случая и извлечь выгоду. Распределитель общего назначения не должен.

Кроме того количество звонков в malloc, Есть и другие соответствующие атрибуты:
месторасположение распределения
На современном оборудовании это легко самый важный фактор для производительности. Приложение лучше знает шаблоны доступа и может соответствующим образом оптимизировать распределение.
многопоточность
Распределитель общего назначения должен разрешать вызовы к malloc и быть свободным от разных потоков. Это обычно требует блокировки или аналогичной обработки параллелизма. Если куча очень занята, это приводит к массовым раздорам.
Приложение, которое знает, что некоторые высокочастотные выделения / освобождения поступают только из одного потока, может использовать свою собственную специфичную для потока кучу, которая не только избегает конфликтов для этих распределений, но также увеличивает их локальность и снимает нагрузку с распределителя по умолчанию.
фрагментация
Это по-прежнему проблема для долго работающих приложений в системах с ограниченной физической памятью или адресным пространством. Фрагментация может потребовать все больше памяти или адресного пространства от ОС, даже без фактического увеличения рабочего набора. Это серьезная проблема для приложений, которые должны работать бесперебойно.
В прошлый раз, когда я глубже изучал распределители (что, вероятно, прошло полвека), был достигнут консенсус о том, что наивные попытки уменьшить фрагментацию часто конфликтуют с никогда не медленный править.
Опять же, приложение, которое знает (некоторые из его) шаблоны распределения, может получить большую нагрузку от распределителя по умолчанию.
Одним из наиболее распространенных вариантов использования является построение синтаксического дерева или чего-то подобного: существуют миллиарды небольших выделений, которые никогда не освобождаются по отдельности, только в целом. Такой шаблон может быть эффективно обслужен с очень тривиальным распределителем.
устойчивость и диагностика
Не в последнюю очередь диагностические возможности и возможности самозащиты распределителя по умолчанию могут быть недостаточными для многих приложений.
5
Почему у нас есть собственные менеджеры памяти, а не встроенные?
Причина номер один, вероятно, заключается в том, что кодовая база была изначально написана 20-30 лет назад, когда предоставленная не была хорошей, и никто не осмелился ее изменить.
Но в противном случае, как вы говорите, поскольку приложению необходимо управлять фрагментацией, захватывайте память при запуске, чтобы гарантировать, что память всегда будет доступна, из соображений безопасности или по ряду других причин — большинство из которых можно было бы получить при правильном использовании встроенного менеджер.
4
C и C ++ предназначены для разборки. Они не делают много, чего явно не требуется, поэтому, когда программа запрашивает память, она получает минимально возможные усилия, необходимые для доставки этой памяти.
Другими словами, если вам это не нужно, вы не платите за это.
Если требуется более детальный контроль над памятью, это область программиста.
[attention type=red]
Если программист желает обменять «голую железную» скорость на систему, которая обеспечит более высокую производительность на целевом оборудовании в сочетании с часто уникальными целями программы, улучшенной поддержкой отладки, или просто любит внешний вид и теплые размышления, которые приходят от использования менеджера , это до них. Программист либо пишет что-то умнее, либо находит стороннюю библиотеку, которая делает то, что хочет.

2

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

Является ли реальная цель диспетчера памяти уменьшить количество вызовов malloc или, главным образом, для анализа памяти, проверки повреждения или других целей, ориентированных на приложения?

Это большой вопрос. Диспетчер памяти в любом приложении может быть универсальным (например, malloc) или более конкретным. Чем более специализированным становится менеджер памяти, тем более вероятно, что он будет более эффективным при выполнении конкретной задачи, которую он должен выполнять.

Возьмите этот чрезмерно упрощенный пример:

#define MAX_OBJECTS 1000 Foo globalObjects[MAX_OBJECTS]; int main(int argc, char ** argv){void * mallocObjects[MAX_OBJECTS] = {0};void * customObjects[MAX_OBJECTS] = {0}; for(int i = 0; i < 1000; ++i){mallocObjects[i] = malloc(sizeof(Foo));customObjects[i] = &globalObjects[i];}}

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

Когда вы размещаете с помощью malloc, нет никакой гарантии, что оно находится рядом с предыдущим размещением. Malloc является распределителем общего назначения и хорошо справляется с этой задачей, но не обязательно делает наиболее эффективный выбор для каждого приложения.

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

Существует также разница между абстракцией памяти и пользовательскими распределителями памяти. Распределители STL, возможно, являются моделью абстракции, а не пользовательским распределителем памяти.

Посмотрите эту ссылку для получения дополнительной информации о пользовательских распределителях и почему они полезны: ссылка на gamedev.net

2

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

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

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

1

Менеджеры памяти используются в основном для управления продуктивно резервирование вашей памяти.

Обычно процессы имеют доступ к ограниченному объему памяти (4 ГБ в 32-битных системах), из этого вы должны вычесть пространство виртуальной памяти, зарезервированное для ядра (1 ГБ или 2 ГБ в зависимости от конфигурации вашей ОС).

Таким образом, фактически у процесса есть доступ, скажем, к 3 ГБ памяти, которая будет использоваться для хранения всех его сегментов (код, данные, bss, куча и стек).

Менеджеры памяти (например, malloc) пытаются выполнить различные запросы резервирования памяти, выданные процессом, запрашивая новые страницы памяти в ОС (используя системные вызовы sbrk или mmap).

Каждый раз, когда это происходит, это требует дополнительных затрат на выполнение программы, поскольку ОС должна искать подходящую страницу памяти, которая будет назначена процессу (физическая память ограничена, и все запущенные процессы хотят ее использовать), обновлять таблицы процессов. (ТМП и т. Д.).

Эти операции занимают много времени и влияют на выполнение и производительность процесса. Таким образом, диспетчер памяти обычно пытается запрашивать необходимые страницы для умелого выполнения резервирования процесса. Например, он может запросить еще несколько страниц, чтобы не вызывать больше вызовов mmap в ближайшем будущем.

Кроме того, он пытается справиться с такими проблемами, как фрагментация, выравнивание памяти и т. Д. Это в основном освобождает процесс от этой ответственности, в противном случае каждый, кто пишет какую-то программу, которая нуждается в динамическом выделении памяти, должен выполнить это вручную!

На самом деле, есть случаи, когда кто-то может быть заинтересован в управлении памятью вручную. Это касается встраиваемых систем или систем высокой доступности, которые должны работать в течение 24/365.

В этих случаях, даже если фрагментация памяти мала, это может стать проблемой после очень длительного периода работы (например, 1 год). Таким образом, одним из решений, которые используются в этом случае, является использование пула памяти для выделения перед рукой памяти для объектов приложения.

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

1

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

После долгой серии mallocs / new и free / delete в выгружаемой памяти могут появиться пробелы на страницах, которые занимают пустое пространство и могут в конечном итоге исчерпать виртуальное адресное пространство. Microsoft решает эту проблему с помощью своей платформы .

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

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

0

Источник: https://web-answers.ru/c/pochemu-my-ispolzuem-menedzhery-pamjati.html

Поделиться:
Нет комментариев

    Добавить комментарий

    Ваш e-mail не будет опубликован. Все поля обязательны для заполнения.