C ручное управление памятью
При создании массива с фиксированными размерами под него выделяется определенная память. Например, пусть у нас будет массив с пятью элементами:
Для такого массива выделяется память 5 * 8 (размер типа double) = 40 байт. Таким образом, мы точно знаем, сколько в массиве элементов и сколько он занимает памяти. Однако это не всегда удобно. Иногда бывает необходимо, чтобы количество элементов и соответственно размер выделяемой памяти для массива определялись динамически в зависимости от некоторых условий. Например, пользователь сам может вводить размер массива. И в этом случае для создания массива мы можем использовать динамическое выделение памяти.
Для управления динамическим выделением памяти используется ряд функций, которые определены в заголовочном файле stdlib.h :
malloc() . Имеет прототип
Выделяет память длиной в s байт и возвращает указатель на начало выделенной памяти. В случае неудачного выполнения возвращает NULL
calloc() . Имеет прототип
Выделяет память для n элементов по m байт каждый и возвращает указатель на начало выделенной памяти. В случае неудачного выполнения возвращает NULL
realloc() . Имеет прототип
Изменяет размер ранее выделенного блока памяти, на начало которого указывает указатель bl, до размера в ns байт. Если указатель bl имеет значение NULL , то есть память не выделялась, то действие функции аналогично действию malloc
free() . Имеет прототип
Освобождает ранее выделенный блок памяти, на начало которого указывает указатель bl.
Если мы не используем эту функцию, то динамическая память все равно освободится автоматически при завершении работы программы. Однако все же хорошей практикой является вызов функции free() , который позволяет как можно раньше освободить память.
Рассмотрим применение функций на простой задаче. Длина массива неизвестна и вводится во время выполнения программы пользователем, и также значения всех элементов вводятся пользователем:
Консольный вывод программы:
Здесь для управления памятью для массива определен указатель block типа int . Количество элементов массива заранее неизвестно, оно представлено переменной n.
Вначале пользователь вводит количество элементов, которое попадает в переменную n. После этого необходимо выделить память для данного количества элементов. Для выделения памяти здесь мы могли бы воспользоваться любой из трех вышеописанных функций: malloc, calloc, realloc. Но конкретно в данной ситуации воспользуемся функцией malloc :
Прежде всего надо отметить, что все три выше упомянутые функции для универсальности возвращаемого значения в качестве результата возвращают указатель типа void * . Но в нашем случае создается массив типа int, для управления которым используется указатель типа int * , поэтому выполняется неявное приведение результата функции malloc к типу int * .
В саму функцию malloc передается количество байтов для выделяемого блока. Это количество подсчитать довольно просто: достаточно умножить количество элементов на размер одного элемента n * sizeof(int) .
После выполнения всех действий память освобождается с помощью функции free() :
Важно, что после выполнения этой функции мы уже не сможем использовать массив, например, вывести его значения на консоль:
И если мы попытаемся это сделать, то получим неопределенные значения.
Вместо функции malloc аналогичным образом мы могли бы использовать функцию calloc() , которая принимает количество элементов и размер одного элемента:
Либо также можно было бы использовать функцию realloc() :
При использовании realloc желательно (в некоторых средах, например, в Visual Studio, обязательно) инициализировать указатель хотя бы значением NULL.
Но в целом все три вызова в данном случае имели бы аналогичное действие:
Теперь рассмотрим более сложную задачу - динамическое выделение памяти для двухмерного массива:
Переменная table представляет указатель на массив указателей типа int* . Каждый указатель table[i] в этом массиве представляет указатель на подмассив элементов типа int , то есть отдельные строки таблицы. А переменная table фактически представляет указатель на массив указателей на строки таблицы.
Для хранения количества элементов в каждом подмассиве определяется указатель rows типа int . Фактически он хранит количество столбцов для каждой строки таблицы.
Сначала вводится количество строк в переменную rowscount . Количество строк - это количество указателей в массиве, на который указывает указатель table . И кроме того, количество строк - это количество элементов в динамическом массиве, на который указывает указатель rows . Поэтому вначале необходимо для всех этих массивов выделить память:
Далее в цикле осуществляется ввод количества столбцов для каждый строки. Введенное значение попадает в массив rows. И в соответствии с введенным значением для каждой строки выделяется необходимый размер памяти:
Затем производится ввод элементов для каждой строки.
В конце работы программы при выводе происходит освобождение памяти. В программе память выделяется для строк таблицы, поэтому эту память надо освободить:
И кроме того, освобождается память, выделенная для указателей table и rows:
Ручное управление памятью с С++ — одновременно один из самых больших плюсов и минусов в языке. Действительно, эта парадигма позволяет создавать очень производительные программы, однако она же рождает и кучу проблем. Существует несколько способов избавится от них. Я попробовал несколько и в итоге пришел к сборщику мусора. В этой статье я хочу изложить не столько реализацию сборщика мусора на С++, сколько историю идеи и его использования, каково им пользоваться и почему в итоге от него отказался.
Итак, как у большинства программистов у меня есть свой проект, а именно двумерный игровой движок. На нем и производились все «эксперименты».
Самая часто встречающаяся проблема при ручном управлении памятью — это утечки. Чтобы узнать о них нужно следить за памятью. Именно таков и был мой первоначальных подход. Следим за созданием и удалением объектов, проверяем после завершения программы что осталось не удаленным. Делается очень просто: перегружаем операторы new и delete, чтобы они могли принимать в параметрах имя файла исходного кода и строчку, откуда происходит аллокация, и храним все аллокации в одном месте. Для удобства объявляем макрос, который и передает имя файла и строку в оператор. Соответственно при удалении объекта удаляем запись о соответствующей аллокации.
- просто и понятно
- не затратно в плане ресурсов
- в ходе выполнения программы мы знаем количество задействованной памяти
- чтобы узнать об утечках, нужно завершать программу
- не очень информативно откуда именно происходят аллокации
- использование перегруженных операторов немного меняет синтаксис
И правда же, самое распространенное решение проблемы управления памятью — умные указатели. О них вас обязательно спросят на собеседовании. Идея проста: вместо обычных указателей мы используем шаблонные классы, которые работают как указатели, но имеют дополнительный функционал для автоматического освобождения объектов. Вместе с объектом храним счетчик ссылок, при присваивании значения указателю увеличиваем счетчик, при уничтожении указателя — уменьшаем. Если счетчик равен нулю — объект никому не нужен и его можно освободить. Однако, есть небольшая проблема — если два объекта ссылаются друг на друга, они никогда не будут освобождены, так как у обоих счетчик ссылок равен единице.
Слабые указатели решают эту проблему зацикленности. Один из указателей делается слабым, и теперь счетчики ссылок равны нулю и оба объекта могут быть освобождены.
Что ж, давайте придумаем ситуацию посложнее.
Такую ситуацию тоже можно разрешить с помощью слабых/сильных указателей, но будет ли это легче ручного управления? Если программист что-то сделает неправильно, результат будет такой же: утекший неосвобожденный объект. На самом деле, такие ситуации встречаются редко и в целом такой подход значительно упрощает работу с памятью.
- не требует много ресурсов
- гарантирует корректное освобождение объектов при правильной архитектуре
- просто в освоении
- усложняет синтаксис
- в некоторых ситуациях сложно правильно выстроить слабые/сильные указатели
- зацикленные ссылки приводят к утечкам
Опробовав умные указатели, я остался неудовлетворен. Просто хочется использовать один у тот же тип умного указателя везде и не задумываться о зацикленных ссылках. Пусть он сам о них задумывается. Но как это сделать? Представим ситуацию:
Нужно как-то узнать что два нижних объекта зациклены и их можно удалить, ведь на них никто не ссылается. По рисунку уже несложно догадаться: если ссылки от объекта не ведут к верхнеуровневым объектам, значит он может быть освобожден. Верхнеуровневые объекты — это, грубо говоря, те объекты, с которых начинается инициализация приложения. Для С++ это объекты на стеке и статические.
Думаю, внимательный читатель уже понял, что это и есть схема работы сборщика мусора, только наоборот. Самый простой сборщик работает примерно так: спускаясь по ссылкам от верхнеуровневых объектов помечаем всех как актуальные, после этого мы имеем право удалить те, что оказались не помеченными.
Для реализации нам потребуется такой же шаблонный класс указателя, как и в случае с умными указателями. Плюс нужен сам сборщик, который будет за всем следить и производить собственно сборку мусора.
Для каждого созданного объекта создается мета-информация ObjectInfo и хранится в менеджере памяти MemoryManager. Каждый такой объект создается перегруженным оператором new. ObjectInfo хранит в себе информацию о размере объекта, месте, откуда он был создан, список указателей на него и указатель на функцию для вызова деструктора этого объекта.
Для работы с объектами вместо привычных указателей используются шаблонные классы Ptr и RootPtr. RootPtr необходим для обозначения верхнеуровневых объектов, так как в ходе выполнения программы невозможно узнать что объект создан на стеке или статически (поправьте меня, если я не прав). При инициализации или копировании указателей происходит добавление и удаление указателей из соответствующих ObjectInfo.
При вызове сборки мусора меняется булевая переменная маркера на противоположную и начинается цикл отметки объектов. Цикл проходит по верхнеуровневым указателям, затем рекурсивно по их дочерним указателям. Дочерний указатель — это тот, что находится в теле объекта. После этого все объекты, у которых маркер не совпадает с текущим удаляются.
Внимательный читатель наверняка заметил еще одно — за удобство мы платим производительностью. Мы получаем все типичные минусы сборщиков мусора: излишний расход памяти и большое время работы сборки мусора. Именно на моем проекте игрового движка это фатальный минус, ведь на каждый кадр должно уходить несколько миллисекунд и просто нет времени чтобы все остановить и произвести сборку мусора. Однако, есть и хороший момент, этот сборщик мусора полностью наш и мы можем делать все что захотим!
Первое что приходит в голову — это то, что мы полностью управляем моментом сборки мусора. Совсем не обязательно делать это посреди геймплея. Можно сделать это как раз тогда, когда и происходит наибольшее количество инициализаций и уничтожений объектов, во время загрузки уровней. При разработке игр не принято делать много аллокаций/уничтожений во время активной игры. Если при каждом выстреле загружать и создавать объект пули, никакая оптимизация памяти вас не спасет. Поэтому делать долгую сборку мусора между уровнями кажется довольно живучей идеей.
Следующая идея — вовсе не вызывать сборку мусора или делать это крайне редко. Объекты по-прежнему удалять вручную или умными указателями, а сборщик мусора можно использовать в роли отладчика и подстраховки. В таком варианте мы получим производительность эквивалентную ручному управлению памятью и безопасность от утечек при сборке мусора.
Таким образом мы можем использовать сборщик по-разному. При быстром прототипировании нас не заботит производительность, но нужна стабильность, в этом случае используем автоматическую сборку. В обычной ситуации стараемся вручную управлять памятью, а сборщик нам подсказывает и подстраховывает. Ну и конечно же его можно просто выключить и перейти к полностью ручному управлению.
Помимо этого можно реализовать еще немного «вкусностей». Так как мы используем шаблонные классы-указатели, мы можем проверять их на корректность, то есть при удалении объекта вручную делать все ссылки на него невалидными. И далее в нужных местах их проверять. Еще одно из возможных улучшений — это дефрагментация памяти. На этапе сборки мусора можно перерасположить актуальные объекты в памяти для уменьшения кеш-промахов процессора, что несомненно даст прирост производительности.
- защищенность от утечек и использования некорректных указателей
- минимальные затраты при работе в полуавтоматическом режиме
- максимальное удобство при полностью автоматическом режиме
- усложнение синтаксиса
- необходимо понимание схемы работы и использования
- процесс сборки мусора все еще занимает значительное время
В конце концов на решение об отказе от сборщика повлияла специфика моего проекта. Проект планируется с открытым исходным кодом и он нацелен прежде всего на удобство использования. Усложнение и без того сложного синтаксиса С++ специфичными указателями и добавление сборщика мусора несомненно плохо повлияют на проект. Просто представьте знакомство разработчика с новой технологией: ему необходимо изучать новое API да еще и с мудреной моделью управления памятью, тем более, что большинство программистов С++ и так неплохо управляются с памятью вручную.
Окончательно я убедился в возврате к ручной модели когда принял решение использовать скрипты. Именно они нужны для простоты и удобства. Делаешь прототип или простую игру — используй скрипты, экономь время и ресурсы. Необходима гибкость и производительность — добро пожаловать в С++. Тем, кто будет использовать С++ на самом деле вряд ли понадобится сборщик мусора.
Вот так я и вернулся к началу. Надеюсь мой опыт будет полезен или хотя бы интересен другим велосипедостроителям.
P.S. Код из статьи не оптимизирован и приведен лишь для понимания работы.
При написании приложений, одной из важнейших вопросов являются потребление памяти и отзывчивость (скорость работы).
Считается, что сборщик мусора – черный ящик, работу которого нельзя предугадать.
А я скажу как бы ни так!
▌ Структура организации размещения объектов в памяти
Однажды я уже писал про определение размеров CLR-объектов. Чтобы не пересказывать статью, давайте лишь вспомним основные моменты.
Ссылка живет в стеке при выполнении любого метода, либо живет в поле какого-либо класса.
Вы не можете создать объект в вакууме без создания ссылки.
Чтобы не было спекуляций по поводу размеров объектов и проведения каких-либо тестов с помощью SOS (Son of Strike), измерения GC.TotalMemory и т.п. — просто посмотрим на исходники CLR, а точнее Shared Source Common Language Infrastructure 2.0, являющийся своего рода исследовательским проектом.
Каждый тип имеет свой MethodTable, и все экземпляры объектов одного и того же типа ссылаются на один и тот же MethodTable. Данная таблица хранит информацию о самом типе (интерфейс, абстрактный класс и т.д.).
Каждый объект содержит два дополнительных поля – заголовок объекта, в котором хранится адрес SyncTableEntry (запись syncblk), и Method Table Pointer (TypeHandle).
SyncTableEntry – структура, хранящая ссылку на CLR-объект и ссылку на сам SyncBlock.
SyncBlock – структура данных, в которой хранится хеш-код для любого объекта.
Говоря «для любого» значит, что CLR заранее инициализирует определенное количество SyncBlock’ов. Далее при вызове GetHashCode(), либо Monitor.Enter() среда просто вставляет в заголовок объекта указатель на уже готовый SyncBlock, попутно вычисляя хеш-код.
Делается это вызовом метода GetSyncBlock (смотрим файл %папка с архивом%\sscli20\clr\src\vm\syncblk.cpp) . В теле метода мы можем видеть следующий код:
Метод System.Object.GetHashCode полагается на структуру SyncBlock вызывая метод SyncBlock::GetHashCode.
Первоначальное значение syncblk равно 0 для CLR 2.0, но начиная с CLR 4.0 значение равняется -1.
При вызове Monitor.Exit() syncblk опять становится равной -1.
Хочется также заметить, что массив SyncBlock’ов хранится в отдельной памяти, недоступной GC.
Как же так? Спросите Вы.
Ответ прост – слабые ссылки. CLR создает слабую ссылку на запись в SyncBlock массиве. Когда CLR-объект умирает, SyncBlock обновляется.
Реализация метода Monitor.Enter() зависит от платформы и самого JIT. Так псевдонимом для данного метода в исходниках SSCLI является JIT_MonEnter.
Возвращаясь к теме размещения объектов в памяти и их размеров, хочется вспомнить, что любой экземпляр объекта (пустой класс) занимает минимум 12 байт в x86, а в x64 уже 24 байта.
Убедимся в этом без запуска SOS.
Переходим в файл %папка с архивом%\sscli20\clr\src\vm\object.h
В комментариях к той статье про размеры CLR-объектов меня упрекнули в неточности расчета размера System.String без каких-либо доказательств.
Однако я больше доверяю цифрам и … исходному коду!
Empty в расчет не берем, т.к. это пустая статичная строка.
m_stringLength указывает длину строки.
m_firstChar является указателем (. ) на начало хранения массива юникодных символов, а не первым символом в массиве.
Здесь не используется какая-либо магия – CLR просто находит оффсет.
Чтобы в этом убедиться снова открываем файл %папка с архивом%\sscli20\clr\src\vm\object.h
В самом начале файла видим комментарии к коду:
Вот это и есть внутренняя структура хранения строковых данных.
Далее находим класс StringObject и его метод GetBuffer().
Что ж, буфер (он же массив символов) просто вычисляется оффсетом.
А как же обстоит дело с самим System.String?
Открываем файл %папка с архивом%\sscli20\clr\src\bcl\system\string.cs
Видим следующие строки:
Однако System.String в своей работе полагается на COMString, реализующим сам конструктор, а также многие методы (PadLeft и т.п.).
Чтобы правильно сопоставить названия методов из фреймворка и внутренних C++ реализаций советую посмотреть файл %папка с архивом%\sscli20\clr\src\vm\ecall.cpp
Ну и чтобы окончательно убедиться в том, что m_firstChar является указателем, рассмотрим, например, часть кода метода Join:
Немного другой вариант с подсчетом (но с такими же результатами) приводит знаменитый Jon Skeet.
Перед тем как двигаться вперед, хотелось бы вспомнить и про стек.
Стек – контейнер, создаваемый средой при каждом вызове любого метода. В нем хранятся все данные, необходимые для завершения вызова (адреса локальных переменных, параметры и т.д.).
Таким образом, вызовы дерево вызовов представляют собой FIFO-контейнер, состоящий из стеков. При завершении вызова текущего метода, стек очищается и уничтожается, возвращая управление родительской ветке.
Как я уже писал выше, для переменных ссылочных типов, в стек помещается значение фиксированного размера (4 байта, например, для x86, тип DWORD), содержащее адрес экземпляра объекта, созданного в обычной куче.
В стеке по-умолчанию размещаются экземпляры примитивных типов, не участвующих в упаковке (boxing).
Однако при некоторых оптимизациях JIT- может сразу же расположить значения переменных в регистрах процессора, минуя ОЗУ.
Вспомним, что такое регистр процессора — блок ячеек памяти, образующий сверхбыструю оперативную память внутри процессора, использующаяся самим процессором и большой частью недоступен программисту.
Чем больше кэш CPU, тем более высокую производительность Вы можете получить, независимо от программной платформы.
▌ Устройство GC
Как известно, управлением памятью (созданием и уничтожением объектов) занимается сборщик мусора – он же Garbage Collector (GC).
Для работы приложения CLR сразу же инициализирует два сегмента виртуального адресного пространства – Small Object Heap и Large Object Heap.
Небольшая заметка: виртуальная память представляет собой логическое представление памяти, а не физическое. Физическая память выделяется только по мере необходимости. Каждому процессу в современной операционной системе выделяется виртуальное адресное пространство максимально адресуемого размера (4GB для 32-х битных ОС) с разделением на страницы (для платформ x86, IA-64, PowerPC-64 минимальный размер составляет 4KB, SPARC – 8 KB). Благодаря этому становится возможным изолирование адресного пространства одного процесса от другого, а также появляется возможность использования подкачки на диске.
Для выделения и возвращения обратно системе памяти GC использует Win32-функции VirtualAlloc и VirtualFree.
- стек
- статичные (глобальные) объекты
- финализируемые объекты
- неуправляемые Interop-объекты (CLR-объекты, принимающие участие в COM/unmanaged вызовах)
- регистры процессора
- другие CLR-объекты со ссылками
- Generation 0. Жизненный цикл объектов этого поколения самый короткий. Обычно к Gen0 относятся временные переменные, созданные в теле методов.
- Generation 1. Жизненный цикл объектов этого поколения также короткий. К нему относятся объекты с промежуточным временем жизни – объекты, переходящие из Gen0 в Gen2.
- Generation 2. Представляет собой наиболее долгоживущие объекты. Также объекты размером более 85 000 байт автоматически попадают в Large Object Heap и помечаются как Gen2.
В LOH попадают объекты не только размером более 85 000 байт, но также некоторые типы массивов.
Так массив из System.Double при размере 10 600 элементов (85000 / 8 байт) должен попасть в LOH. Однако это происходит уже при размере 1000+.
Объекты в управляемой куче располагаются один за другим, что в случае удаления большого количества объектов может приводить к фрагментации.
Однако для решения данной проблемы CLR – всегда (за исключением ручного управления памятью) дефрагментирует Small Object Heap.
Процесс выглядит следующим образом: текущие объекты копируются в свободную память (пробелы в куче, которые автоматически исчезают).
Таким образом, достигается минимальное потребление памяти, однако это требует и определенного процессорного времени. Однако это не должно волновать, т.к. для объектов Gen0, Gen1 задержка составляет всего лишь 1 мс.
Что же насчет Large Object Heap? Она никогда не дефрагментируется (почти никогда). Это потребовало бы большое количество времени, что может сказаться плохо на работе приложения. Однако это не значит, что CLR начинает потреблять все больше и больше памяти просто так. Во время Full-GC (Gen0, Gen1, Gen2) система все же возвращает ОС память, освобождаясь от уже мертвых объектами из LOH (или дефрагментацией SOH).
Также CLR располагает новые объекты в LOH не только один за другим, как в SOH, например, но и на местах уже свободной памяти, не дожидаясь Full-GC.
Запуск GC не детерминирован, за исключением вызова метода GC.Collect().
- При достижении поколения Gen0 размера в 256 KB
- При достижении поколения Gen1 размера в 2 MB
- При достижении поколения Gen2 размера в 10 MB
Еще одним моментом, при работе с памятью является использование неуправляемых ресурсов.
Т.к. неуправляемые ресурсы могут содержать любые объекты вне зависимости от длительности жизни и GC не детерминирован, то для этих целей существует финализатор.
При запуске приложения, CLR находит типы с финализаторами и исключает их из обычной сборки мусора (но это не означает, что объекты не привязаны к поколениям).
После окончания работы GC, в отдельном потоке обрабатываются финализируемые объекты (вызов метода Finalize).
Пример реализации паттерна Dispose:
Workstation mode – GC максимально оптимизирован для работы на клиентских машинах. Старается особо не загружать процессор, а также работает с минимальными задержками для приложений с UI. Доступен в двух режимах – параллельном и синхронном.
При параллельном режиме GC запускается в отдельном потоке (с приоритетом normal) для Gen2-поколения, блокируя при этом работу эфемерных поколений (аллокации новых объектов не возможны, все потоки приостановлены).
Если приложению доступно очень (. ) много свободной памяти, то SOH не становится компактным (GC жертвует память, ради отзывчивости приложения).
Таким образом, Workstation mode – идеально подходит для GUI-приложений.
Кроме того, если необходимо использовать именно серверный GC, то включить его можно так:
Для проверки можно использовать в коде свойство GCSettings.IsServerGC.
Для принудительного отключения Workstation Concurrent GC, используем следующие параметры:
По-умолчанию, параллельный режим включен для Workstation GC. Однако, если процессор – одноядерный, то GC автоматически переходит в синхронный режим.
Рассмотрим теперь Server GC.
Server GC разделяет управляемую кучу на сегменты, количество которых равно количеству логических процессоров, используя для обработки каждого из них по одному потоку.
Небольшая заметка: логический процессор необязательно соответствует физическому процессору. Системы с несколькяими физическими процессорами (т.е. несколькими сокетами) и многоядерные процессоры предоставляют ОС множество логических процессоров, причем ядро (. ) также может представлять собой более 1 логического процессора (например, при использовании технологии Hyper-threading от Intel).
По-умолчанию, LatencyMode для Workstation Concurrent GC установлен как Interactive, Server – Batch.
Существует еще и LowLatency, но его использование может привести к OutOfMemoryException, т.к. в данном режиме GC Полная сборка мусора происходит только в случае высокой нагрузки на память. Также его нельзя включить для Server GC.
В чем же разница между Batch и Interactive?
Т.к. Server GC делит управляемую кучу на несколько сегментов (каждый их которых обслуживает отдельный логический процессор), то в параллельной сборке мусора уже нет необходимости (если бы еще один поток запускался на др. логическом процессоре). Этот режим принудительно переопределяет параметр gcConcurrent. Если включен режим gcConcurrent, режим Batch будет препятствовать дальнейшей параллельной сборке мусора (. ). Batch эквивалентен непараллельной сборке мусора на рабочей станции. При использовании такого режима характерна обработка больших (. ) объемов данных.
Следует помнить, что изменение значения GCLatencyMode влияет на текущие запущенные потоки, что означает воздействие на саму среду исполнения и неуправляемый код.
А т.к. потоки могут выполняться на различных логических процессорах, то отсутствует гарантия мгновенного перевода режима GC.
А что если другой поток, захочет изменить данное значение. А если потоков 100?
Чувствуете, что назревает проблема для многопоточного приложения? И особенно для CLR – ведь может быть вызвано исключение в самой среде, а не в коде приложения.
Именно для таких случаев и существует constrained execution region (CER) – гарантия обработки всех исключений (как синхронных, таки асинхронных).
В блоке кода, помеченного как CER, среде исполнения запрещается бросать некоторые асинхронные исключения.
Например, при вызове Thread.Abort() поток, исполняемый под CER, не будет прерван до тех пор, пока не завершится исполнение CER-защищенного кода.
Также CLR при инициализации подготавливает CER, чтобы гарантировать работу даже при нехватке памяти.
Рекомендуется не использовать CER для больших участков кода, т.к. существует ряд ограничений для такого рода кода: boxing, вызов виртуальных методов, вызов методов через reflection, использование Monitor.Enter и т.д.
Но не будем углубляться в это дело и посмотрим, как безопасно переключить режим LatencyMode.
Точно нет? И даже ничего не смутило?
Хм…неужели вопрос о невозможности аллокации новых объектов в эфемерных поколениях никак не заинтересовал?
Целью его создания было уменьшение задержек при Full-GC, в частности Gen2.
Background GC – это тот же самый Concurrent GC, за одним исключением – при Full-GC эфемерные поколения не блокируется для аллокации новых объектов.
Согласитесь, что обработка Gen2 и LOH весьма затратное дело. А блокирование Gen0, Gen1 – т.е. обычной работы приложения может вызывать задержки (при определенных ситуациях).
Еще один вопрос, который адресует новый GC – откладывание аллокации новых объектов при достижении лимита размеров управляемой кучи (16 MB – desktop, 64 – server).
Теперь для предотвращения такой ситуации действует не только background thread для Gen2, но еще и foreground thread (да-да, у нас есть еще и Foreground GC), который помечает мертвые объекты из эфемерных поколений и объединяет текущие эфемерные поколения с Gen2 (т.к. объединение менее затратная операция, чем копирование) и передает их на обработку background thread, тем самым позволяя выделять память под новые объекты (напоминаю, что в Background GC Gen0, Gen1 не блокируются во время работы GC для Gen2).
Уменьшение количества задержек можно сравнить на нижеприведенном графике:
▌ Ручное управление памятью
Но также мы можем работать с неуправляемым кодом.
Для GCHandle CLR использует отдельную таблицу для каждого AppDomain.
GCHandle уничтожаются при вызове GCHandle.Free(), либо при выгрузке AppDomain.
- Normal
- Weak
- Weak Track Resurrection
- Pinned
Когда может понадобиться ручная работа с памятью, спросите Вы?
Например, для копирования массива байтов.
Помните, что GC дефрагментирует SOH? При некоторых ситуациях фиксация объектов в куче может привести к неэффективной дефрагментации SOH.
- новую CLR
- новый GC
- новую систему обработки системных исключений
- новый Thread Pool
- новая модель безопасности
Для использования старой модели безопасности:
Обработка системных SEH-исключений изменилась, но для обратной совместимости вносим следующие строчки в конфигурационный файл:
Если же Ваше приложение использует также и нативный код, то:
▌ Регистры процессора || FastCall
Первой оптимизацией, которую проводит JIT – оптимизация, присущая и компилятору VC++, а именно – FastCall.
Суть метода заключается в том, что в регистры ECX, EDX записываются первые 2 параметра метода.
При платформе x64 – используются регистры RCX, RDX, R8, R9.
Что же это дает?
Например, при реализации какого-либо алгоритма наиболее часто используемый параметр следует выставить первым (пусть это и микрооптимизация, но все же).
Так для вышеприведенного кода переменные startIndex и endIndex будут находиться именно в регистрах ECX, EDX, остальные (x, y) в стеке.
Проверим – для этого запустим дебаггер.
Для того чтобы посмотреть в текущие регистры наберем команду CTRL + D, R .
хранятся на стеке. Как следует из названия, стек работает с переменными по схеме FILO (first in last out). Управление стеком происходит автоматически. При выходе переменной из области видимости, соответствующая ей в стеке память освобождается. Этот механизм позволяет разработчику не следить за удалением автоматических переменных. Стек работает очень быстро, но имеет ограниченный размер, который обычно не превосходит нескольких мегабайт.
Второй тип памяти - куча - устроен иначе. В куче объекты можно хранить в произвольном месте, создавать и удалять их в произвольном порядке, а размер кучи обычно значительно превосходит размер стека. Платить за эти преимущества приходится скоростью: работа с кучей происходит значительно медленнее, чем со стеком. Кроме того, объекты из кучи не удаляются автоматически.
Кучу имеет смысл использовать в двух случаях:
- Необходимо хранить большой объект. Хранение больших объектов на стеке может привести к его переполнению (stack overflow).
- Автоматическое управление памятью в стеке не соответствует логике программы. Чаще всего такая ситуация возникает, когда созданный объект должен продолжать свое существование после выхода из блока, в котором он был создан. Ниже мы рассмотрим пример.
Динамическое выделение памяти означает работу с кучей и является предметом данного раздела.
Ручное управление памятью
Начнем с обзора низкоуровневых инструментов, которые обычно не используются при разработке на современном C++. Знание эти инструментов, однако, может пригодиться при чтении старого кода и при работе со старыми компиляторами.
Создать объект в куче можно с помощью оператора new :
Оператор new возвращает указатель на область памяти в куче, в которой был создан объект. Создав объект с помощью оператора new , разработчик становится ответственными за его удаление. Освободить выделенную память и удалить объект можно с помощью оператора delete :
Вернемся к примеру из раздела про наследование, в котором мы строили модель символов в графическом текстовом редакторе. Напомним, что мы создали абстрактный базовый класс Character и два его наследника Letter и Digit . Допустим, нам надо реализовать функцию, которая возвращает полиморфный список символов (текст документа). Без динамического выделения нам будет сложно решить эту задачу. Например:
Объекты l1 , l2 , d1 и d2 в функции create_document созданы на стеке. При выходе из функции create_document для каждого объекта будет вызван деструктор и освобождена память на стеке. В этом виде функция возвращает список указателей на освобожденную память, что приводит к неопределенному поведению. Следующее изменение сделает код корректным:
Теперь память для объектов выделяется в куче, объекты продолжают существовать после выхода из функции. Использовать функцию create_document необходимо с учетом особенностей работы с динамической памятью. Напишем функцию print_document , которая вызывает create_document и выводит символы в стандартный поток вывода:
Использование функции print_document приводит к утечке памяти: при каждом ее вызове в куче выделяется память, которая никогда не освобождается. Более аккуратная реализация выглядит так:
Эта логика применима в любой ситуации с динамическим выделением памяти. Например, динамическое выделение памяти может происходить в конструкторе класса, а ее освобождение - в деструкторе.
Существуют версии операторов new и delete для создания и удаления массивов объектов:
При освобождении памяти важно использовать правильную версию оператора delete , что дополнительно усложняет разработку программ с ручным управлением памятью. Хорошей новостью является то, что оператор delete[] сам определяет размер удаляемого массива.
Далее мы рассмотрим более удобные и безопасные инструменты для работы с динамической памятью, которые доступны в современном C++.
Владение ресурсами и идиома RAII
Динамическое выделение памяти тесно связано с концепцией владения ресурсами. Ресурсом может быть не только память, но и, например, файловый дескриптор или сокет-соединение. В хорошо спроектированной программе структура владения ресурсами устроена ясно: каждым ресурсом владеет определенный объект, который отвечает за освобождение ресурса. Владение ресурсом можно передавать другому объекту, который вместе с ресурсом берет на себя ответственность за его освобождение.
Ясно организовать владение ресурсами практически в любой программе можно, следую идиоме RAII (resource acquisition is initialization, получение ресурса есть инициализация), которая (в несколько упрощенном виде) состоит в следующем:
- Каждый ресурс следует инкапсулировать в класс, при этом
- Конструктор выполняет выделение ресурса
- Деструктор выполняет освобождение ресурса
Мы уже видели пример RAII-объекта в C++, когда говорили про работу с файлами. Объект fstream владеет ресурсом - файловым дескриптором - и отвечает за его освобождение, а вся работа с файлом происходит через этот объект.
Умные указатели
В рамках идиомы RAII в современном C++ решены сложности работы с динамическим выделением памяти. Логика работы с динамической памятью инкапсулирована в специальных классах std::unique_ptr и std::shared_ptr , которые называют умными указателями. При конструировании такого объекта происходит выделение памяти, а при вызове деструктора - освобождение. Например:
При выходе из функции main выделенная в куче память корректно будет освобождена. Объекты std::unique_ptr и std::shared_ptr различаются с точки зрения владения объектом. Уникальный указатель std::unique_ptr единолично владеет ресурсом. Это означает, что не может быть два разных объекта std::unique_ptr не могут быть связаны с одним и тем же ресурсом. Это, например, означает, что объект std::unique_ptr не имеет копирующего конструктора и копирующего оператора присваивания. Вместо этого возможно использование перемещающего конструктор и перемещающего оператора присваивания. Например:
Объекты std::shared_ptr можно копировать. При этом несколько объектов std::shared_ptr оказываются связанными с одним ресурсом (динамически выделенной памятью). Освобождение памяти происходит в момент, когда последний ссылающийся на эту память объект std::shared_ptr вышел из области видимости. Необходимость подсчета ссылок в объектах std::shared_ptr приводит к определенным накладным расходам. Например объекты std::shared_ptr занимают больше памяти, чем объекты std::unique_ptr . Объекты std::unique_ptr при этом не уступают в производительности простым указателям.
Важно, что умные указатели сохраняют свойство полиморфности. Это позволяет нам модифицировать функцию create_document следующим образом:
и не заботится больше о ручном освобождении ресурсов. Несмотря на некоторую громоздкость синтаксиса умные указатели значительно упрощают разработку на C++. Мы рекомендуем использовать умные указатели вместо низкоуровневых операторов new и delete для работы с динамической памятью.
Сложность обращения с длинными названиями типов в C++ вроде std::list> (и это не самый плохой случай) может быть преодолена с помощью псевдонимов. Например:
Виртуальный деструктор
В заключение этого раздела обсудим один тонкий момент, связанный с полиморфизмом и освобождением ресурсов в C++. Функция create_document корректно работает с динамической памятью. Однако, если использовать классы Character , Letter и Digit в том виде, в каком мы их оставили в разделе про наследование, то освобождение памяти при удалении объекта Document будет выполнено неверно. Контейнер std::list работает с (умными) указателями на объекты абстрактного класса Character . При удалении объекта std::list происходит удаление всех объектов типа std::unique_ptr , которые в свою очередь вызывают деструкторы объектов Character . Вместо этого мы хотим, чтобы для каждого объекта вызывался деструктор нужного класса-наследника. Вызов только деструктора базового класса снова может привести к утечке памяти.
Как для любого другого метода полиморфизм вызова деструктора реализуется через механизм виртуальных методов, в данном случае нам нужен виртуальный деструктор:
Поскольку ничего особенного, кроме собственно виртуальности, нам не нужно, мы доверили генерирование деструктора компилятору. Теперь наша программа, динамически выделяющая память для полиморфных объектов с помощью умных указателей, будет работать так, как нам нужно.
В любой иерархии классов деструктор базового класса рекомендуется объявлять виртуальным, чтобы избегать проблем с освобождением ресурсов объектами классов-наследников.
Резюме
В этом разделе мы обсудили основы работы с динамической памятью в C++. Рекомендуемыми инструментами работы с динамической памятью являются умные указатели std::unique_ptr и std::shared_ptr . Не забывайте объявлять деструктор базового класса виртуальным, если возможна работа с объектами классов-потомков через указатель на объект базового класса (а такая возможность есть всегда).
Ранее в теме Типы значений и ссылочные типы мы рассматривали отдельные типы данных и как они располагаются в памяти. Так, при использовании переменных типов значений в методе, все значения этих переменных попадают в стек. После завершения работы метода стек очищается.
При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (garbage collector) увидит, что на данный участок памяти больше нет ссылок, и очистит его.
В методе Test создается объект Person. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти. В неявно определенном методе Main мы вызываем метод Test. И после того, как Test отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта Person участок памяти.
Сборщик мусора не запускается сразу после удаления из стека ссылки на объект, размещенный в куче. Он запускается в то время, когда среда CLR обнаружит в этом потребность, например, когда программе требуется дополнительная память.
Как правило, объекты в куче располагаются неупорядочено, между ними могут иметься пустоты. Куча довольно сильно фрагментирована. Поэтому после очистки памяти в результате очередной сборки мусора оставшиеся объекты перемещаются в один непрерывный блок памяти. Вместе с этим происходит обновление ссылок, чтобы они правильно указывали на новые адреса объектов.
Так же надо отметить, что для крупных объектов существует своя куча - Large Object Heap . В эту кучу помещаются объекты, размер которых больше 85 000 байт. Особенность этой кучи состоит в том, что при сборке мусора сжатие памяти не проводится по причине больших издержек, связанных с размером объектов.
Несмотря на то что, на сжатие занятого пространства требуется время, да и приложение не сможет продолжать работу, пока не отработает сборщик мусора, однако благодаря подобному подходу также происходит оптимизация приложения. Теперь чтобы найти свободное место в куче среде CLR не надо искать островки пустого пространства среди занятых блоков. Ей достаточно обратиться к указателю кучи, который указывает на свободный участок памяти, что уменьшает количество обращений к памяти.
Кроме того, чтобы снизить издержки от работы сборщика мусора, все объекты в куче разделяются по поколениям. Всего существует три поколения объектов: 0, 1 и 2-е.
К поколению 0 относятся новые объекты, которые еще ни разу не подвергались сборке мусора. К поколению 1 относятся объекты, которые пережили одну сборку, а к поколению 2 - объекты, прошедшие более одной сборки мусора.
Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколению 0. Те объекты, которые остаются актуальными после очистки, повышаются до поколения 1.
Если после обработки объектов поколения 0 все еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по-прежнему актуальны, повышаются до поколения 2.
Поскольку объекты из поколения 0 являются более молодыми и нередко находятся в адресном пространстве памяти рядом друг с другом, то их удаление проходит с наименьшими издержками.
Класс System.GC
Рассмотрим некоторые методы и свойства класса System.GC:
Метод AddMemoryPressure информирует среду CLR о выделении большого объема неуправляемой памяти, которую надо учесть при планировании сборки мусора. В связке с этим методом используется метод RemoveMemoryPressure , который указывает CLR, что ранее выделенная память освобождена, и ее не надо учитывать при сборке мусора.
Метод Collect приводит в действие механизм сборки мусора. Перегруженные версии метода позволяют указать поколение объектов, вплоть до которого надо произвести сборку мусора
Метод GetGeneration(Object) позволяет определить номер поколения, к которому относится переданый в качестве параметра объект
Метод GetTotalMemory возвращает объем памяти в байтах, которое занято в управляемой куче
Метод WaitForPendingFinalizers приостанавливает работу текущего потока до освобождения всех объектов, для которых производится сборка мусора
Работать с методами System.GC несложно:
С помощью перегруженных версий метода GC.Collect можно выполнить более точную настройку сборки мусора. Так, его перегруженная версия принимает в качестве параметра число - номер поколения, вплоть до которого надо выполнить очистку. Например, GC.Collect(0) - удаляются только объекты поколения 0.
Еще одна перегруженная версия принимает еще и второй параметр - перечисление GCCollectionMode . Это перечисление может принимать три значения:
Default : значение по умолчанию для данного перечисления (Forced)
Forced : вызывает немедленное выполнение сборки мусора
Optimized : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора
Например, немедленная сборка мусора вплоть до первого поколения объектов: GC.Collect(1, GCCollectionMode.Forced);
Читайте также: