Русский
Русский
English
Статистика
Реклама

Garbage collector

Перевод Развеиваем мифы об управлении памятью в JVM

28.05.2021 16:10:14 | Автор: admin

В серии статей я хочу опровергнуть заблуждения, связанные с управлением памятью, и глубже рассмотреть её устройство в некоторых современных языках программирования Java, Kotlin, Scala, Groovy и Clojure. Надеюсь, эта статья поможет вам разобраться, что происходит под капотом этих языков. Сначала мы рассмотрим управление памятью в виртуальной машине Java (JVM), которая используется в Java, Kotlin, Scala, Clojure, Groovy и других языках. В первой статье я рассказал и разнице между стеком и кучей, что полезно для понимания этой статьи.

Структура памяти JVM


Сначала давайте посмотрим на структуру памяти JVM. Эта структура применяется начиная с JDK 11. Вот какая память доступна процессу JVM, она выделяется операционной системой:


Это нативная память, выделяемая ОС, и её размер зависит от системы, процессор и JRE. Какие области и для чего предназначены?

Куча (heap)


Здесь JVM хранит объекты и динамические данные. Это самая крупная область памяти, в ней работает сборщик мусора. Размером кучи можно управлять с помощью флагов Xms (начальный размер) и Xmx (максимальный размер). Куча не передаётся виртуальной машине целиком, какая-то часть резервируется в качестве виртуального пространства, за счёт которого куча может в будущем расти. Куча делится на пространства молодого и старого поколения.

  • Молодое поколение, или новое пространство: область, в которой живут новые объекты. Она делится на рай (Eden Space) и область выживших (Survivor Space). Областью молодого поколения управляет младший сборщик мусора (Minor GC), который также называют молодым (Young GC).
    • Рай: здесь выделяется память, когда мы создаём новые объекты.
    • Область выживших: здесь хранятся объекты, которые остались после работы младшего сборщика мусора. Область делится на две половины, S0 и S1.
  • Старое поколение, или хранилище (Tenured Space): сюда попадают объекты, которые достигли максимального порога хранения в ходе жизни младшего сборщика мусора. Этим пространством управляет старший сборщик (Major GC).

Стеки потоков исполнения


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

Метапространство


Это часть нативной памяти, по умолчанию у неё нет верхней границы. В более ранних версиях JVM эта память называлась пространством постоянного поколения (Permanent Generation (PermGen) Space). Загрузчики классов хранили в нём определения классов. Если это пространство растёт, то ОС может переместить хранящиеся здесь данные из оперативной в виртуальную память, что может замедлить работу приложения. Избежать этого можно, задав размер метапространства с помощью флагов XX:MetaspaceSize и -XX:MaxMetaspaceSize, в этом случае приложение может выдавать ошибки памяти.

Кеш кода


Здесь компилятор Just In Time (JIT) хранит скомпилированные блоки кода, к которым приходится часто обращаться. Обычно JVM интерпретирует байткод в нативный машинный код, однако код, скомпилированный JIT-компилятором, не нужно интерпретировать, он уже представлен в нативном формате и закеширован в этой области памяти.

Общие библиотеки


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

Использование памяти JVM: стек и куча


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

class Employee {    String name;    Integer salary;    Integer sales;    Integer bonus;    public Employee(String name, Integer salary, Integer sales) {        this.name = name;        this.salary = salary;        this.sales = sales;    }}public class Test {    static int BONUS_PERCENTAGE = 10;    static int getBonusPercentage(int salary) {        int percentage = salary * BONUS_PERCENTAGE / 100;        return percentage;    }    static int findEmployeeBonus(int salary, int noOfSales) {        int bonusPercentage = getBonusPercentage(salary);        int bonus = bonusPercentage * noOfSales;        return bonus;    }    public static void main(String[] args) {        Employee john = new Employee("John", 5000, 5);        john.bonus = findEmployeeBonus(john.salary, john.sales);        System.out.println(john.bonus);    }}

Здесь вы можете увидеть, как исполняется вышеприведённая программа и как используются стек и куча:

https://files.speakerdeck.com/presentations/9780d352c95f4361bd8c6fa164554afc/JVM_memory_use.pdf

Как видите:

  • Каждый вызов функции добавляется в стек потока исполнения в качестве фреймового блока.
  • Все локальные переменные, включая аргументы и возвращаемые значения, сохраняются в стеке внутри фреймовых блоков функций.
  • Все примитивные типы вроде int хранятся прямо в стеке.
  • Все типы объектов вроде Employee, Integer или String создаются в куче, а затем на них ссылаются с помощью стековых указателей. Это верно и для статичных данных.
  • Функции, которые вызываются из текущей функции, попадают наверх стека.
  • Когда функция возвращает данные, её фрейм удаляется из стека.
  • После завершения основного процесса объекты в куче больше не имеют стековых указателей и становятся потерянными (сиротами).
  • Пока вы явно не сделаете копию, все ссылки на объекты внутри других объектов делаются с помощью указателей.

Стеком автоматически управляет операционная система, а не JVM. Поэтому не нужно особо заботиться о нём. А вот куча уже так не управляется, и поскольку это самая большая область памяти, содержащая динамические данные, она может расти экспоненциально, и программа способна со временем занять всю память. Кроме того, постепенно куча фрагментируется, тем самым замедляя работу приложений. В решении этих проблем поможет JVM. Она автоматически управляет кучей с помощью сборки мусора.

Управление памятью JVM: сборка мусора


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

JVM управляет куче с помощью сборки мусора. Чтобы освободить место для создания нового объекта, JVM очищает память, занятую потерянными объектами, то есть объектами, на которые больше нет прямых или опосредованных ссылок из стека.


Сборщик мусора в JVM отвечает за:

  • Получение памяти от ОС и возвращение её ОС.
  • Передачу выделенной памяти приложению по его запросу.
  • Определение, какие части выделенной памяти ещё используются приложением.
  • Затребование неиспользованной памяти для использования приложением.

Сборщики мусора в JVM работают по принципу поколений (объекты в куче группируются по возрасту и очищаются во время разных этапов). Есть много разных алгоритмов сборки мусора, но чаще всего применяют Mark & Sweep.

Сборщик мусора Mark & Sweep


JVM использует отдельный поток демона, который работает в фоне для сборки мусора. Этот процесс запускается при выполнении определённых условий. Сборщик Mark & Sweep обычно работает в два этапа, иногда добавляют третий, в зависимости от используемого алгоритма.


  • Разметка: сначала сборщик определяет, какие объекты используются, а какие нет. Те, что используются или доступны для стековых указателей, рекурсивно помечаются как живые.
  • Удаление: сборщик проходит по куче и убирает все объекты, которые не помечены как живые. Эти места в памяти помечаются как свободные.
  • Сжатие: после удаления неиспользуемых объектов все выжившие объекты перемещают, чтобы они были вместе. Это уменьшает фрагментацию и повышает скорость выделения памяти для новых объектов.

Такой тип сборщиков ещё называют stop-the-world, потому что пока они убираются, возникают паузы в работе приложения.

JVM предлагает на выбор несколько разных алгоритмов сборки мусора, и в зависимости от вашего JDK может быть ещё больше вариантов (например, сборщик Shenandoah в OpenJDK). Авторы разных реализаций стремятся к разным целям:

  • Пропускная способность: время, затраченное на сборку мусора, а не работу приложения. В идеале, пропускная способность должна быть высокой, то есть паузы на сборку мусора короткие.
  • Длительность пауз: насколько долго сборщик мусора мешает исполнению приложения. В идеале, паузы должны быть очень короткими.
  • Размер кучи: в идеале, должен быть маленьким.

Сборщики в JDK 11


JDK 11 это текущая версия LTE. Ниже приведён список доступных в ней сборщиков мусора, и JVM выбирает по умолчанию один из них в зависимости от текущего оборудования и операционной системы. Мы всегда можем принудительно выбрать какой-либо сборщик с помощью переключателя -XX.

  • Серийный сборщик: использует один поток, эффективен для приложений с небольшим количеством данных, наиболее удобен для однопроцессорных машин. Его можно выбрать с помощью -XX:+UseSerialGC.
  • Параллельный сборщик: нацелен на высокую пропускную способность и использует несколько потоков, чтобы ускорить процесс сборки. Предназначен для приложений со средним или большим количеством данных, исполняемых на многопоточном/многопроцессорном оборудовании. Его можно выбрать с помощью -XX:+UseParallelGC.
  • Сборщик Garbage-First (G1): работает по большей части многопоточно (то есть многопоточно выполняются только объёмные задачи). Предназначен для многопроцессорных машин с большим объёмом памяти, по умолчанию используется на большинстве современных компьютеров и ОС. Нацелен на короткие паузы и высокую пропускную способность. Его можно выбрать с помощью -XX:+UseG1GC.
  • Сборщик Z: новый, экспериментальный, появился в JDK11. Это масштабируемый сборщик с низкой задержкой. Многопоточный и не останавливает исполнение потоков приложения, то есть не относится к stop-the-world. Предназначен для приложений, которым необходима низкая задержка и/или очень большая куча (на несколько терабайтов). го можно выбрать с помощью -XX:+UseZGC.

Процесс сборки мусора


Вне зависимости от того, какой выбран сборщик, в JVM используется два вида сборки младший и старший сборщик.

Младший сборщик


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

Здесь вы можете увидеть процесс работы этого сборщика:

https://files.speakerdeck.com/presentations/f4783404769145f4b990154d0cc05629/JVM_minor_GC.pdf

  1. Допустим, в раю уже есть объекты (блоки с 01 по 06 помечены как используемые).
  2. Приложение создаёт новый объект (07).
  3. JVM пытается получить необходимую память в раю, но там уже нет места для размещения нового объекта, поэтому JVM запускает младший сборщик.
  4. Он рекурсивно проходит по графу объектов начиная со стековых указателей и помечает используемые объекты как (используемая память), остальные как мусор (потерянные).
  5. JVM случайно выбирает один блок из S0 и S1 в качестве целевого пространства (To Space), пусть это будет S0. Теперь сборщик перемещает все живые объекты в целевое пространство, которое было пустым, когда мы начали работу, и повышает их возраст на единицу.
  6. Затем сборщик очищает рай, и в нём выделяется память для нового объекта.
  7. Допустим, прошло какое-то время, и в раю стало больше объектов (блоки с 07 по 13 помечены как используемые).
  8. Приложение создаёт новый объект (14).
  9. JVM пытается получить в раю нужную память, но там нет свободного места для нового объекта, поэтому JVM снова запускает младший сборщик.
  10. Повторяется этап разметки, который охватывает и те объекты, что находятся в пространстве выживших в целевом пространстве.
  11. Теперь JVM выбирает в качестве целевого свободный блок S1, а S0 становится исходным. Сборщик перемещает все живые объекты из рая и исходного в целевое (S1), которое было пустым, и повысил возраст объектов на единицу. Поскольку некоторые объекты сюда не поместились, сборщик переносит их в хранилище, ведь область выживших не может увеличиваться, и этот процесс называют преждевременным продвижением (premature promotion). Такое может происходить, даже если свободна одна из областей выживших.
  12. Теперь сборщик очищает рай и исходное пространство (S0), а новый объект размещается в раю.
  13. Так повторяется при каждой сессии младшего сборщика, выжившие перемещаются между S0 и S1, а их возраст увеличивается. Когда он достигает заданного максимального порога, по умолчанию это 15, объект перемещается в хранилище.

Мы рассмотрели, как младший сборщик очищает память в пространстве молодого поколения. Это процесс типа stop-the-world, но он настолько быстрый, что его длительностью обычно можно пренебречь.

Старший сборщик


Следит за чистотой и компактностью пространства старого поколения (хранилищем). Запускается при одном из таких условий:

  • Разработчик вызывает в программе System.gc() или Runtime.getRunTime().gc().
  • JVM решает, что в хранилище недостаточно памяти, потому что оно заполнено в результате прошлых сессий младшего сборщика.
  • Если во время работы младшего сборщика JVM не может получить достаточно памяти в раю или области выживших.
  • Если мы задали в JVM параметр MaxMetaspaceSize и для загрузки новых классов не хватает памяти.

Процесс работы старшего сборщика попроще, чем младшего:

  1. Допустим, прошло уже много сессий младшего сборщика и хранилище почти заполнено. JVM решает запустить старший сборщик.
  2. В хранилище он рекурсивно проходит по графу объектов начиная со стековых указателей и помечает используемые объекты как (используемая память), остальные как мусор (потерянные). Если старший сборщик запустили в ходе работы младшего сборщика, то его работа охватывает пространство молодого поколения (рай и область выживших) и хранилище.
  3. Сборщик убирает все потерянные объекты и возвращает память.
  4. Если в ходе работы старшего сборщика в куче не осталось объектов, JVM также возвращает память из метапространства, убирая из него загруженные классы, если это относится к полной сборке мусора.

Заключение


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

Но для большинства JVM-разработчиков (Java, Kotlin, Scala, Clojure, JRuby, Jython) этого объёма информации будет достаточно. Надеюсь, теперь вы сможете писать более качественный код, создавать более производительные приложения, избегая различных проблем с утечкой памяти.

Ссылки


Подробнее..

Перевод Dont Fear the Reaper

03.07.2020 00:15:20 | Автор: admin

D, как и многие активно используемые сегодня языки, поставляется со сборщиком мусора (Garbage Collector, GC). Многие виды ПО можно разрабатывать, вообще не задумываясь о GC, в полной мере пользуясь его преимуществами. Однако у GC есть свои изъяны, и в некоторых сценариях сборка мусора нежелательна. Для таких случаев язык позволяет временно отключить сборщик мусора или даже совсем обойтись без него.


Чтобы получить максимальное преимущество от сборщика мусора и свести недостатки к минимуму, необходимо хорошо понимать, как работает GC в языке D. Хорошим началом будет страничка Garbage Collection на dlang.org, которая подводит обоснование под GC в языке D и даёт несколько советов о том, как с ним работать. Это первая из серии статей, которая призвана более подробно осветить тему.


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


Самая первая вещь, которую нужно уяснить: сборщик мусора в D запускается только во время выделения памяти и только в том случае, если нет памяти, которую можно выделить. Он не сидит в фоне, периодически сканируя кучу и собирая мусор. Это необходимо понимать, чтобы писать код, эффективно использующий память под контролем GC. Рассмотрим следующий пример:


void main() {    int[] ints;    foreach(i; 0..100) {        ints ~= i;    }}

Эта программа создаёт динамический массив значений типа int, а затем при помощи имеющегося в D оператора присоединения добавляет в него числа от 0 до 99 в цикле foreach. Что неочевидно неопытному глазу, так это то, что оператор присоединения выделяет память для добавляемых значений через сборщик мусора.


Реализация динамического массива в рантайме D вовсе не тупая. В нашем примере не произойдёт сотни выделений памяти, по одному на каждое значение. Когда требуется больше памяти, массив выделяет больше памяти, чем запрашивается. Мы можем определить, сколько на самом деле будет выделений памяти, задействовав свойство capacity. Это свойство возвращает количество элементов, которые можно поместить в массив, прежде чем потребуется выделение памяти.


void main() {    import std.stdio : writefln;    int[] ints;    size_t before, after;    foreach(i; 0..100) {        before = ints.capacity;        ints ~= i;        after = ints.capacity;        if(before != after) {            writefln("Before: %s After: %s",                before, after);        }    }}

Скомпилировав этот код в DMD 2.073.2 и выполнив, мы увидим шесть сообщений шесть выделений памяти через GC. Это означает, что у GC шесть раз была возможность собрать мусор. В этом маленьком примере это вряд ли произошло. Если бы этот цикл был частью большой программы, использующей GC, то это вполне могло бы произойти.


Кроме того, любопытно взглянуть на значения before и after. Программа выдаёт последовательность: 0, 3, 7, 15, 31, 63, и 127. После выполнения цикла массив ints содержит 100 значений, и в нём есть место под ещё 27 значений, прежде чем произойдёт следующее выделение памяти, которое увеличит объём массива до 255, экстраполируя предыдущие значения. Это, однако, уже детали реализации рантайма D, и в будущих релизах всё может поменяться. Чтобы узнать больше о том, как GC контролирует массивы и срезы, взгляните на прекрасную статью Стива Швайхоффера (Steve Schveighoffer) на эту тему.


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


Даже когда речь идёт о языках без встроенного сборщика мусора, таких как C и C++, большинство программистов рано или поздно узнают, что для общей производительности лучше заранее выделить как можно больше ресурсов и свести к минимуму выделение памяти во внутренних циклах. Это одна из многих преждевременных оптимизаций, которые не является корнем всех зол то, что мы называем лучшими практиками. Учитывая, что GC в языке D запускается только когда происходит выделение памяти, ту же самую стратегию можно применять как простой способ минимизировать его влияние на производительность. Вот как можно переписать пример:


void main() {    int[] ints = new int[](100);    foreach(i; 0..100) {        ints[i] = i;    }}

Мы сократили шесть выделений памяти до одного. Единственная возможность для GC запуститься перед внутренним циклом. Этот код выделяет место для по крайней мере 100 элементов и инициализирует их значением нулями перед входом в цикл. После new длина массива будет 100, но в нём почти наверняка будет дополнительная ёмкость.


Есть также другой способ: функция reserve:


void main() {    int[] ints;    ints.reserve(100);    foreach(i; 0..100) {        ints ~= i;    }}

Это выделит память под по крайней мере 100 значений, но массив всё ещё будет пустым (его свойство length будет возвращать 0), так что ничего не будет инициализировано значениями по умолчанию. Учитывая, что цикл добавляет только 100 значений, гарантируется, что выделения памяти не произойдёт.


Помимо new и reserve, можно также выделять память явным образом, напрямую вызывая GC.malloc.


import core.memory;void* intsPtr = GC.malloc(int.sizeof * 100);auto ints = (cast(int*)intsPtr)[0 .. 100];

Литералы массивов обычно выделяет память.


auto ints = [0, 1, 2];

Это верно также в том случае, когда литерал массива используется в enum.


enum intsLiteral = [0, 1, 2];auto ints1 = intsLiteral;auto ints2 = intsLiteral;

Значение типа enum существует только во время компиляции и не имеет адреса в памяти. Его имя синоним его значения. Где бы вы его не использовали, это будет как если бы вы скопировали и вставили его значение на месте его имени. И inst1, и inst2 вызовут выделение памяти, как если бы мы определили их вот так:


auto ints1 = [0, 1, 2];auto ints2 = [0, 1, 2];

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


int[3] noAlloc1 = [0, 1, 2];auto noAlloc2 = "No Allocation!";

Оператор конкатенации всегда выделяет память:


auto a1 = [0, 1, 2];auto a2 = [3, 4, 5];auto a3 = a1 ~ a2;

У ассоциативных массивов в D своя стратегия выделения памяти, но можно ожидать, что они будут выделять память при добавлении элементов и при их удалении. Также они реализуют два свойства: keys и values, которые выделяют память под массив и заполняют его копиями соответствующих элементов. Когда ассоциативный массив нужно изменять во время того, как вы по нему итерируете, или если нужно отсортировать элементы или ещё каким-то образом обработать их в отрыве от массива, эти свойства то что доктор прописал. В других случаях это лишь лишнее выделение памяти, чрезмерно нагружающее GC.


Когда сборка мусора всё-таки запускается, время, которое она займёт, будет зависеть от объёма сканируемой памяти. Чем меньше, тем лучше. Никогда не будет лишним избегать ненужных выделений памяти, и это ещё один хороший способ минимизировать влияние сборщика мусора на производительность. Как раз для этого у ассоциативных массивов в D есть три свойства: byKey, byValue и byKeyValue. Каждое из них возвращает диапазон, который можно итерировать ленивым образом. Они не выделяют память, поскольку напрямую обращаются к элементам массива, поэтому не следует его изменять во время итерирования. Более подробно о диапазонах можно прочитать в главах Ranges и More Range из книги Али Чехрели (Ali ehreli) Programming in D.


Замыкания делегаты или функции, которые должны нести в себе указатель на фрейм стека также выделяют память. Последняя возможность языка, упомянутая на страничке Garbage Collection выражение assert. Если проверка проваливается, выражение assert выделяет память, чтобы породить AssertError, которое является частью иерархии исключений языка D, основанной на классах (в будущих статьях мы рассмотрим, как классы взаимодействуют с GC).


И наконец, есть Phobos стандартная библиотека D. Когда-то давным-давно большая часть Phobosа была реализована без особой заботы о выделении памяти через GC, отчего его трудно было использовать в ситуациях, когда это было нежелательно. Однако потом было приложено много усилий, чтобы сделать его более сдержанным в обращении с GC. Некоторые функции были переделаны, чтобы они могли работать с ленивыми диапазонами, другие были переписаны, чтобы они принимали буфер, а некоторые были переработаны, чтобы избежать лишних выделений памяти внутри себя. В результате стандартная библиотека стала гораздо более пригодной для написания кода, свободного от GC (хотя, возможно, ещё остаются места, которые ещё предстоит обновить пулл-реквесты всегда приветствуются).


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


Спасибо Гийому Пьола (Guillaume Piolat) и Стиву Швайхофферу за их помощь в подготовке этой статьи.

Подробнее..

Перевод Life in the Fast Lane

07.07.2020 00:22:55 | Автор: admin
Серия статей о GC
  1. Dont Fear the Reaper
  2. Life in the Fast Lane
  3. Go Your Own Way. Часть первая: Стек
  4. Go Your Own Way. Часть первая: Куча

В первой из серии статей о GC я представил сборщик мусора в языке D и возможности языка, которые его используют. Два ключевых момента, которые я пытался донести:


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


  2. Простые стратегии выделения памяти в стиле C и C++ позволяют уменьшить нагрузку на GC. Не выделяйте память внутри циклов вместо этого как можно больше ресурсов выделяйте заранее или используйте стек. Сведите к минимуму общее число выделений памяти через GC. Эти стратегии работают благодаря пункту 1. Разработчик может диктовать, когда допустимо запустить сборку мусора, грамотно используя выделение памяти из кучи, управляемой GC.



Стратегии из пункта 2 подходят для кода, который программист пишет сам, но они не особенно помогут, когда речь идёт о сторонних библиотеках. В этих случаях при помощи механизмов языка D и его рантайма можно гарантировать, что в критических местах кода никакого выделения памяти не произойдёт. Также есть параметры командной строки, которые помогают убедиться, что GC не встанет на вашем пути.


Давайте представим, что вы пишете программу на D и по тем или иным причинам решили полностью исключить сборку мусора. У вас есть два очевидных решения.


Таблетка от жадности


Первое решение вызвать GC.disable при запуске программы. Выделение памяти через GC всё ещё будет работать, но сборка мусора остановится. Вся сборка мусора, включая ту, что могла произойти в других потоках.


void main() {    import core.memory;    import std.stdio;    GC.disable;    writeln("Goodbye, GC!");}

Вывод:


Goodbye, GC!

Преимущество этого способа в том, что все возможности языка, использующие GC, продолжат работать, как и ожидалось. Но если учесть, что будет происходить выделение памяти без всякой очистки, то по здравому размышлению вы поймёте, что такое решение может выйти вам боком. Если всё время бесконтрольно выделять память, то рано или поздно какое-то из звеньев цепи сдаст. Из документации:


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

Насколько это плохо, зависит от конкретного случая. Если для вас такое ограничение приемлемо, то есть ещё кое-какие инструменты, которые помогут держать всё под контролем. Вы можете по необходимости вызывать GC.enable и GC.collect. Такая стратегия позволяет контролировать циклы освобождения ресурсов лучше, чем простые техники из C и C++.


Антимусоросборочная стена


Когда запуск сборщика мусора абсолютно неприемлем, вы можете обратиться к атрибуту @nogc. Повесь его на main, и минует тебя сборка мусора.


@nogcvoid main() { ... }

Это окончательное решение вопроса GC. Атрибут @nogc, применённый к main, гарантирует, что сборщик мусора не запустится никогда и нигде на всём протяжении стека вызовов. Больше никаких подводных камней если это необходимо для дальнейшей корректной работы программы.


Не первый взгляд такое решение кажется гораздо лучшим, чем GC.disable. Давайте попробуем.


@nogcvoid main() {    import std.stdio;    writeln("GC be gone!");}

На этот раз мы не продвинемся дальше компиляции:


Error: @nogc function 'D main' cannot call non-@nogc function 'std.stdio.writeln!string.writeln'(Ошибка: @nogc-функция 'D main' не может вызвать не-@nogcфункцию 'std.stdio.writeln!string.writeln')

Сила атрибута @nogc в том, что компилятор не позволяет его обойти. Он работает очень прямолинейно. Если функция обозначена как @nogc, то любая функция, которую вы вызываете внутри неё, также должна быть обозначена как @nogc. Очевидно, что writeln это требование не выполняет.


И это ещё не всё:


@nogc void main() {    auto ints = new int[](100);}

Компилятор не спустит вам с рук и этого:


Error: cannot use 'new' in @nogc function 'D main'(Ошибка: нельзя использовать 'new' в @nogc-функции 'D main')

Также внутри @nogc-функции нельзя использовать любые возможности языка, которые выделяет память через GC (их мы рассмотрели в предыдущей статье серии). Мир без сборщика мусора. Большое преимущество такого подхода в том, что он гарантирует, что даже сторонний код не может использовать эти возможности и выделять память через GC за вашей спиной. Недостаток же в том, что сторонние библиотеки, разработанные без @nogc, становятся для вас недоступны.


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


throw new Exception("Blah");

Из-за того, что здесь есть new, в @nogc-функции так написать нельзя. Чтобы обойти это ограничение, требуется заранее выделить место под все исключения, которые могут быть выброшены, а для этого требуется потом как-то освобождать эту память, из чего проистекают идеи об использовании для механизма исключений подсчёта ссылок или о выделении памяти на стеке Короче говоря, это большой клубок проблем. Сейчас появилось предложение по улучшению D Уолтера Брайта, которое призвано распутать этот клубок и сделать так, чтобы throw new Exception работало без GC, когда это необходимо.


К сожалению, проблема использования исключений в @nogc-коде не решена до сих пор. (прим. пер.)

Справиться с ограничениями @nogc main вполне выполнимая задача, она просто потребует немного мотивации и дисциплины.


Ещё одна вещь, которую стоит отметить: даже @nogc main не исключает GC из программы полностью. D поддерживает статические конструкторы и деструкторы. Первые срабатывают перед входом в main, а последние после выхода из неё. Если они есть в коде и не обозначены как @nogc, то, технически, выделение памяти через GC и сборка мусора могут происходить даже в @nogc-программе. Тем не менее, атрибут @nogc, применённый к main, означает, что на протяжение работы main сборка мусора запускаться не будет, так что по сути это то же самое, что не иметь никакого GC.


Добиваемся хорошего результата


Здесь я выскажу мнение. Существует широких спектр программ, которые можно написать на D, не отключая GC на время и не отказываясь от него полностью. Очень много можно добиться, минимизируя выделение памяти через GC и исключая его из горячих точек кода и именно так и следует делать. Я не устаю это повторять, потому что часто неправильно понимают, как происходит сборка мусора в языке D: она может запуститься только когда программист выделяет память через GC и только когда это нужно. Используйте это знание с пользой, выделяя мало, редко и за пределами внутренних циклов.


В тех программах, где действительно нужен полный контроль, возможно, нет необходимости полностью отказываться от GC. Рассудительное использование @nogc и/или API core.memory.GC зачастую позволяет избежать любых проблем с производительностью. Не вешайте атрибут @nogc на main, повесьте его на функции, где точно нужно запретить выделение памяти через GC. Не вызывайте GC.disable в начале программы. Вызывайте её перед критическим местом, а после него вызывайте GC.enable. Сделайте так, чтобы GC собирал мусор в стратегических точках (например, между уровнями игры), при помощи GC.collect.


Как и всегда в оптимизации производительности при разработке ПО, чем полнее вы понимаете, что происходит под капотом, тем лучше. Необдуманное использование API core.memory.GC может заставить GC выполнять лишнюю работу или не оказать никакого эффекта. Для лучшего понимания внутренних процессов вы можете использовать тулчейн D.


В скомпилированную программу (не компилятор!) можно передать параметр рантайма D --DRT-gcopt=profile:1, который поможет вам в тонкой настройке. Вы получите полезную информацию от профилировщика GC, такую как суммарное количество сборок мусора и суммарное время, затраченное на них.


В качестве примера: gcstat.d добавляет двадцать значений в динамический массив целых чисел.


void main() {    import std.stdio;    int[] ints;    foreach(i; 0 .. 20) {        ints ~= i;    }    writeln(ints);}

Компиляция и запуск с параметром профилировщика GC:


dmd gcstat.dgcstat --DRT-gcopt=profile:1[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]        Number of collections:  1        Total GC prep time:  0 milliseconds        Total mark time:  0 milliseconds        Total sweep time:  0 milliseconds        Total page recovery time:  0 milliseconds        Max Pause Time:  0 milliseconds        Grand total GC time:  0 millisecondsGC summary:    1 MB,    1 GC    0 ms, Pauses    0 ms <    0 ms

Отчёт сообщает об одной сборке мусора, которая, по всей вероятности, произошла во время выхода из программы. Рантайм D завершает работу GC на выходе, что (в текущей реализации) обычно вызовет сборку мусора. Это делается главным образом для того, чтобы запустить деструкторы собранных объектов, хотя D и не требует, чтобы деструкторы объектов под контролем GC когда-либо вызывались (это тема для одной из следующих статей).


DMD поддерживает параметр командной строки -vgc, который отобразит каждое выделение памяти через GC в вашей программе включая те, что спрятаны за возможностями языка, такими как оператор присоединения ~=.


В качестве примера: взгляните на inner.d.


void printInts(int[] delegate() dg){    import std.stdio;    foreach(i; dg()) writeln(i);} void main() {    int[] ints;    auto makeInts() {        foreach(i; 0 .. 20) {            ints ~= i;        }        return ints;    }    printInts(&makeInts);}

Здесь makeInts внутренняя функция. Указатель на нестатическую внутреннюю функцию является не указателем на функцию, а делегатом, то есть парным указателем на функцию/контекст (если внутренняя функция обозначена как static, то вместо типа delegate вы получаете тип function). В этом конкретном случае делегат обращается к переменной в родительской области видимости.


Вот вывод компилятора с опцией -vgc:


dmd -vgc inner.dinner.d(11): vgc: operator ~= may cause GC allocationinner.d(7): vgc: using closure causes GC allocation(inner.d(11): vgc: оператор ~= может вызвать выделение памяти через GC)(inner.d(7): vgc: использование замыкания может вызвать выделение памяти через GC)

Здесь мы видим, что нужно выделить память, чтобы делегат мог нести в себе состояние ints, что делает его замыканием (которое не является отдельным типом тип по-прежнему delegate). Переместите объявление ints внутрь области видимости makeInts и скомпилируйте снова. Вы увидите, что выделение памяти из-за замыкания пропало. Ещё лучше изменить объявление printInts таким образом:


void printInts(scope int[] delegate() dg)

Добавление scope к параметру функции гарантирует, что никакие ссылки в этом параметре не могут выйти за пределы функции. Иначе говоря, становится невозможным присвоить делегат dg глобальной переменной или вернуть его из функции. В результате отпадает необходимость создавать замыкание, так что никакого выделения памяти не будет. См. в документации об указателях на функции, делегатах и замыканиях, а также о классах хранения параметров функций.


Резюме


Учитывая, что GC языка D сильно отличается от того, что есть в языках вроде Java и C#, его производительность будет иметь другую характеристику. Кроме того, программы на D как правило производят гораздо меньше мусора, чем программы на языках вроде Java, где почти все типы имеет ссылочную семантику. Полезно это понимать, приступая к своему первому проекту на D. Стратегии, которые применяют опытные программисты на Java, чтобы уменьшить влияние GC на производительность, здесь вряд ли применимы.


Хотя определённо существует программы, где паузы на сборку мусора абсолютно неприемлемы, их, пожалуй, меньшинство. В большинстве проектов на D можно и нужно начинать с простых приёмов из пункта 2 в начале статьи, а затем адаптировать код к использованию @nogc и core.memory.GC там, где требуется производительность. Параметры командной строки, представленные в этой статье, помогут найти места, где это может быть необходимо.


Чем больше времени проходит, тем проще становится управлять сборщиком мусора в программах на D. Идёт организованная работа над тем, чтобы сделать Phobos стандартную библиотеку D как можно более совместимой с @nogc. Улучшения языка, такие как предложение Уолтера о выделении памяти под исключения, должны значительно ускорить этот процесс.


В будущих статьях мы рассмотрим, как выделять память, не прибегая к GC, и использовать её параллельно с памятью из GC, чем заменить недоступные в @nogc-коде возможности языка и многое другому.


Спасибо Владимиру Пантелееву, Гильяму Пьола (Guillaume Piolat) и Стивену Швайхофферу (Steven Schveighoffer) за ценные отзывы о черновике этой статьи.

Подробнее..

Перевод Go Your Own Way. Часть первая. Стек

09.07.2020 00:05:40 | Автор: admin
Серия статей о GC
  1. Dont Fear the Reaper
  2. Life in the Fast Lane
  3. Go Your Own Way. Часть первая. Стек
  4. Go Your Own Way. Часть вторая. Куча

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


Когда сборщик мусора отключён через GC.disable или запрещён к использованию атрибутом функции @nogc, память всё ещё надо откуда-то выделять. И даже если вы используете GC на всю катушку, всё равно желательно минимизировать объём и количество выделений памяти через GC. Это означает выделение памяти или на стеке, или в обычной куче. Эта статья будет посвящена первому. Выделение памяти в куче будет предметом следующей статьи.


Выделение памяти на стеке


Самая простая стратегия выделения памяти в D такая же, как в C: избегать использование кучи и использовать стек, насколько возможно. Если нужен массив, и его размер известен во время компиляции, используйте статический массив вместо динамического. Структуры имеют семантику значений и по умолчанию создаются на стеке, а классы имеют семантику ссылок и обычно создаются в куче (обычной, в стиле C, или управляемой GC); предпочтение следует отдавать структурам, когда это возможно. Возможности D, доступные во время компиляции, помогают достичь многого из того, что иначе было бы невозможно.


Статические массивы


Статические массивы в D требуют, чтобы размер был известен во время компиляции.


// OKint[10] nums;// Ошибка: переменную x нельзя прочитать во время компиляцииint x = 10;int[x] err;

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


@nogc void main() {    int[3] nums = [1, 2, 3];}

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


void printNums(int[] nums) {    import std.stdio : writeln;    writeln(nums);}void main() {    int[]  dnums = [0, 1, 2];    int[3] snums = [0, 1, 2];    printNums(dnums);    printNums(snums);}

Находить при помощи флага -vgc места, где создаётся динамические массивы, и по возможности заменять их на статические простая и эффективная техника. Только берегитесь случаев вроде такого:


int[] foo() {    auto nums = [0, 1, 2];    // Сделать что-то с nums...    return nums;}

В этом примере заменить nums на статический массив было бы неправильно. Функция вернула бы срез памяти, выделенной на стеке, а это программная ошибка. К счастью, компилятор не даст вам этого сделать.


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


int[] foo() {    int[3] nums = [0, 1, 2];    // Пусть x  результат какой-то операции над nums    bool condition = x;    if(condition) return nums.dup;    else return [];}

Эта функция всё ещё выделяет память GC через .dup, но делает это только если это нужно. Обратите внимание, что в данном случае [] эквивалентен null это срез, у которого длина (свойство length) равна 0, а свойство ptr возвращает null.


Структуры против классов


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


struct Foo {    int x;    ~this() {        import std.stdio;        writefln("#%s says bye!", x);    }}void main() {    Foo f1 = Foo(1);    Foo f2 = Foo(2);    Foo f3 = Foo(3);}

Программа печатает то, что вы и ожидаете:


#3 says bye!#2 says bye!#1 says bye!

Классы, будучи типом с семантикой ссылок, почти всегда создаются в куче. Обычно это делается через GC при помощи new, хотя это можно сделать и без GC при помощи собственного аллокатора. Но никто не говорил, что их нельзя создать на стеке. При помощи шаблона [std.typecons.scoped](http://personeltest.ru/aways/dlang.org/phobos/std_typecons.html#.scoped) из стандартной библиотеки это очень просто сделать.


class Foo {    int x;    this(int x) {         this.x = x;     }    ~this() {        import std.stdio;        writefln("#%s says bye!", x);    }}void main() {    import std.typecons : scoped;    auto f1 = scoped!Foo(1);    auto f2 = scoped!Foo(2);    auto f3 = scoped!Foo(3);}

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


Обратите внимание, что на данный момент ни scoped, ни destroy нельзя использовать в @nogc-функциях. Это не всегда плохо, потому что необязательно помечать функцию соответствующим аттрибутом, чтобы избежать GC, но это может стать головной болью, если вы пытаетесь запихнуть всё в @nogc-код. В следующих статьях мы рассмотрим некоторые проблемы проектирования, которые возникают при использовании nogc, и как их избежать.


При создании собственного типа выбор между структурой и классом в основном зависит от того, как его планируется использовать. Для простых данных (Plain Old Data, POD) очевидным кандидатом будет структура, тогда как для какого-нибудь GUI, где крайне полезны будут иерархии наследования и динамические интерфейсы, предпочтительным будет класс. Кроме этих очевидных случаев, есть также ряд соображений, которые могут послужить темой отдельного поста. Пока что держите в уме, что вне зависимости от того, используете ли вы для своего типа структуры или классы, его экземпляры всегда можно создавать на стеке.


alloca


Поскольку стандартная библиотека C доступна в языке D из коробки, для выделения памяти на стеке также можно использовать функцию alloca. Она особенно полезна, когда для массива желательно избежать использования GC, но размер массива неизвестен во время компиляции. Следующий пример создаёт на стеке динамический массив, размер которого известен только во время выполнения:


import core.stdc.stdlib : alloca;void main() {    size_t size = 10;    void* mem = alloca(size);    // Slice the memory block    int[] arr = cast(int[])mem[0 .. size];}

Как и в C, при использовании alloca нужно соблюдать осторожность: не переполните стек. И как и в случае с локальными статическими массивами, нельзя возвращать срез arr из функции. Вместо этого возвращайте arr.dup.


Простой пример


Предположим, что вы хотите создать тип Queue, представляющий структуру данных типа очередь. Характерной для D реализацией такого типа будет шаблонная структура, статическим параметром которой будет хранимый тип. В Java коллекции сильно опираются на интерфейсы, и рекомендуется объявлять экземпляр, используя тип интерфейса вместо типа имплементации. В D структуры не поддерживают наследование, но во многих случаях они могут реализовывать абстрактные интерфейсы благодаря проектированию через интроспекцию (Design by Introspection). Эта парадигма позволяет программировать общие интерфейсы, которые верифицируются во время компиляции без нужды в интерфейсе как отдельном типе, и потому могут работать со структурами, классами и, благодаря UFCS, даже свободными функциями (если они находятся в той же области видимости).


На русском про DbI можно прочитать на Хабре. (прим. пер.)

Очевидным выбором для внутреннего хранилища в нашей реализации Queue будут массивы. Кроме того, есть возможность сделать хранилище статическим массивом, если предполагается ограничить очередь фиксированным размером. Поскольку это уже и так шаблон, мы без труда добавим ещё один параметр: статический параметр-значение со значением по умолчанию. Теперь можно с лёгкостью указать во время компиляции, должен ли массив быть статическим, и если да, то сколько места он будет требовать.


// Размер `Size` по умолчанию 0 означает использование в качестве // внутреннего хранилища динамического массива; ненулевой размер// означает использование статического массиваstruct Queue(T, size_t Size = 0) {    // Тип константы будет автоматически определён как двоичный.    // Уровень доступа `public` позволит DbI-шаблону вне этого модуля    // определять, может ли Queue расти или нет.    enum isFixedSize = Size > 0;    void enqueue(T item)     {        static if(isFixedSize) {            assert(_itemCount < _items.length);        }        else {            ensureCapacity();        }        push(item);    }    T dequeue() {        assert(_itemCount != 0);        static if(isFixedSize) {            return pop();        }        else {            auto ret = pop();            ensurePacked();            return ret;        }    }    // Доступно только в очереди с неограниченным размером    static if(!isFixedSize) {        void reserve(size_t capacity) {             /* Выделить память под несколько элементов */         }    }private:       static if(isFixedSize) {        T[Size] _items;         }    else T[] _items;    size_t _head, _tail;    size_t _itemCount;    void push(T item) {         /* Добавить item, обновить _head и _tail */        static if(isFixedSize) { ... }        else { ... }    }    T pop() {         /* Изъять item, обновить _head и _tail */         static if(isFixedSize) { ... }        else { ... }    }    // Доступно только в очереди с неограниченным размером    static if(!isFixedSize) {        void ensureCapacity() { /* Выделить память, если нужно */ }        void ensurePacked() { /* Сжать массив, если нужно */}    }}

Теперь в клиентском коде можно вот так создавать экземпляры:


Queue!Foo qUnbounded;Queue!(Foo, 128) qBounded;

Очередь qBounded не выделяет память из кучи. Как работает qUnbounded, зависит от реализации. При помощи интроспекции можно проверить во время компиляции, имеет ли экземпляр очереди фиксированный размер. Для этого мы добавили удобную константу isFixedSize:


void doSomethingWithQueueInterface(T)(T queue){    static if(T.isFixedSize) { ... }    else { ... }}

Вместо этого можно было бы использовать встроенные возможности языка: __traits(hasMember, T, "reserve"), или стандартную библиотеку: hasMember!T("reserve"). Выражение __traits и пакет стандартной библиотеки std.traits отличные инструменты для DbI; последнему стоит отдавать предпочтение при схожей функциональности.


Заключение


Это был краткий обзор способов выделения памяти на стеке для избежания обращений к GC. Использовать их там, где возможно, простой способ свести к минимуму размер и количество выделений памяти через GC и тем самым смягчить возможное влияние сборок мусора на производительность.


В следующей статье серии мы рассмотрим способы выделять память в обычной куче, минуя GC.

Подробнее..

Перевод Go Your Own Way. Часть вторая. Куча

13.07.2020 22:18:54 | Автор: admin
Серия статей о GC

Мы продолжаем цикл статей о сборщике мусора в языке D. Этот вторая часть статьи, посвящённой выделению памяти за пределами GC. В первой части говорилось о выделении памяти на стеке. Теперь мы рассмотрим выделение памяти из кучи.


Хотя это только четвёртая публикация в этой серии, это уже третья, в которой я рассказываю о способах избежать использования GC. Не обманитесь: я не пытаюсь отпугнуть программистов от сборщика мусора в языке D. Как раз наоборот. Понимание того, когда и как обходиться без GC, необходимо для эффективного его использования.


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


Невозможно точно и всеобъемлюще определить, в каких приложениях влияние GC будет ощутимым, а в каких нет это очень сильно зависит от конкретной программы. Но можно смело сказать, что в большинстве приложений нет необходимости отключать GC временно или полностью, но когда это всё-таки нужно, то важно знать, обходиться без него. Очевидное решение выделять её на стеке, но D также позволяет выделять память в обычной куче, минуя GC.


Вездесущий Си


Хорошо это или плохо, C окружает нас повсюду. На сегодняшний день любая программа, на каком бы языке она бы ни была написана, на каком-то уровне наверняка обращается к API языка C. Несмотря на то, что спецификация C не определяет стандартного ABI, его платформо-зависимые причуды достаточно широко известны, чтобы большинство языков умело с ним взаимодействовать. Язык D не исключение. На самом деле, все программы на D по умолчанию имеют доступ к стандартной библиотеке C.


Пакет core.stdc набор модулей D, транслированных из заголовков стандартной библиотеки C. Когда линкуется исполняемый файл на D, вместе с ним линкуется и стандартная библиотека C. Чтобы получить к ней доступ, нужно только импортировать соответствующие модули.


import core.stdc.stdio : puts;void main() {    puts("Hello C standard library.");}

Те, кто только начал знакомство с D, могут думать, что обращение к коду на C требует аннотации extern(C), или, после недавней статьи Уолтера Брайта D as a Better C, что код нужно компилировать с флагом -betterC. Ни то, ни другое не верно. Обычные функции в D могут вызывать функции из C без всяких дополнительных условий, кроме как наличия extern(C) в объявлении вызываемой функции. В примере выше объявление puts находится в модуле core.stdc.stdio и это всё, что нам нужно, чтобы её вызвать.


malloc и его друзья


Раз в D у нас есть стандартная библиотека C, значит, нам доступны функции malloc, calloc, realloc и free. Чтобы получить их в своё распоряжение, достаточно импортировать core.stdc.stdlib. А благодаря магии срезов языка D использовать их для работы с памятью без GC проще простого.


import core.stdc.stdlib;void main() {    enum totalInts = 10;    // Выделить место под 10 значений типа int.    int* intPtr = cast(int*)malloc(int.sizeof * totalInts);    // assert(0) (и assert(false)) всегда остаются в исполняемом файле, даже     // если проверки assert выключены, что делет их удобными для обработки     // сбоев в malloc.    if(!intPtr) assert(0, "Out of memory!");    // Освобождает память на выходе из функции. В этом примере это     // необязательно, но полезно в других функциях, которые временно    // выделают память.    scope(exit) free(intPtr);    // Снять срез с указателя, чтобы получить более удобную     // пару указатель+длина.    int[] intArray = intPtr[0 .. totalInts];}

Таким образом мы обходим не только GC, но и обычную для D инициализацию значениями по умолчанию. В массиве значений типа T, выделенном через GC, все элементы были бы инициализированы значением T.init для int это 0. Если вы хотите имитировать стандартное поведение D, потребуются дополнительные усилия. В данном примере мы могли бы просто заменить malloc на calloc, но это будет корректно только для целых чисел. Например, float.init это float.nan, а не 0.0f. Позже мы к этому ещё вернёмся.


Конечно, чтобы сделать наш код более идиоматичным, мы должны обернуть malloc и free в специальные функции и работать уже только со срезами. Минимальный пример:


import core.stdc.stdlib;// Выделяет бестиповый блок памяти, с которым можно работать через срез.void[] allocate(size_t size){    // Результат malloc(0) зависит от имплементации (может вернуть null или какой-то адрес), но это явно не то, что мы хотим делать.    assert(size != 0);    void* ptr = malloc(size);    if(!ptr) assert(0, "Out of memory!");    // Возвращает срез с указателя, чтобы адрес был сцеплен с размером    // блока памяти.    return ptr[0 .. size];}T[] allocArray(T)(size_t count) {     // Убедимся, что мы учитываем размер элементов массива!    return cast(T[])allocate(T.sizeof * count); }// Две версии deallocate для удобстваvoid deallocate(void* ptr){       // free handles null pointers fine.    free(ptr);}void deallocate(void[] mem) {     deallocate(mem.ptr); }void main() {    import std.stdio : writeln;    int[] ints = allocArray!int(10);    scope(exit) deallocate(ints);    foreach(i; 0 .. 10) {        ints[i] = i;    }    foreach(i; ints[]) {        writeln(i);    }}

Функция allocate возвращает void[] вместо void*, потому что срез несёт в себе количество выделенных байт в своём свойстве length. В нашем случае, поскольку мы выделяем память под массив, мы могли бы из allocate возвращать указатель, а в allocArray уже снимать с него срез, но тогда каждому, кто вызывал бы allocate напрямую, пришлось бы учитывать размер блока памяти. То, что в C длина массива отделена от него самого, источник большого количества ошибок, и чем раньше мы их объединим, тем лучше. Дополните наш пример обёртками для calloc и realloc, и вы получите заготовку для менеджера памяти, основанного на куче языка C.


К слову, предыдущие три примера (да, даже шаблон allocArray) работают и -betterC, и без него. Но в дальнейшем мы будем придерживаться обычного кода на D.


Чтобы не текло, как из-под крана


Когда вы работаете со срезами памяти, расположенной за пределами GC, будьте осторожны с добавлением новых элементов, конкатенацией и изменением размера. По умолчанию, операторы дополнения ~= и конкатенации ~, применённые к динамическим массивам и срезам, выделяют память через GC. Конкатенация всегда выделяет новый блок памяти для объединённого массива (или строки). Оператор дополнения обычно выделяет память только если это требуется. Как показывает следующий пример, это требуется всегда, когда дан срез памяти за пределами GC.


import core.stdc.stdlib : malloc;import std.stdio : writeln;void main(){    int[] ints = (cast(int*)malloc(int.sizeof * 10))[0 .. 10];    writeln("Capacity: ", ints.capacity);    // Сохранить указатель на массив для сравнения    int* ptr = ints.ptr;    ints ~= 22;    writeln(ptr == ints.ptr);}

Должно вывести следующее:


Capacity: 0false

Ёмкость 0 указывает, что добавление следующего элемента вызовет выделение ресурсов. Массивы, выделенные через GC, обычно имеют свободное место сверх запрошенного, так что добавление элементов может произойти без выделения новой памяти. Это свойство отвечает скорее за память, на которую указывает массив, нежели за сам массив. Память, выделенная через GC, ведёт внутренний учёт того, сколько элементов в нём может храниться до того, как потребуется выделение новой памяти. В нашем примере, поскольку место под ints было выделено не через GC, никакого учёта не происходит, поэтому добавление следующего элемента обязательно вызовет выделение памяти (см. статью Стивена Швайхоффера D slices за дополнительной информацией).


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


Взгляните на эти две функции:


void leaker(ref int[] arr){    ...    arr ~= 10;    ...}void cleaner(int[] arr){    ...    arr ~= 10;    ...}

Несмотря на то, что массив тип с семантикой ссылок, то есть изменение существующих элементов массива внутри функции изменит их и в оригинальном массиве, в функции они передаются по значению. Всё, что влияет на структуру массива (например, изменение свойств length и ptr) повлияет только на локальную переменную внутри функции. Оригинальный массив не изменится если его не передали по ссылке.


Если передать в leaker массив, выделенный в куче языка C, то добавление нового элемента приведёт к выделению нового массива через GC. Хуже того: если после этого освободить память, передав в free его свойство ptr (которое теперь уже указывает на адрес в куче, управляемой GC, а не в куче языка C), то мы попадём на территорию неопределённого поведения. Зато с функцией cleaner всё нормально. Любой массив, переданный в неё, останется неизменным. Внутри неё произойдёт выделение памяти через GC, но свойство ptr исходного массива всё ещё будет указывать на первоначальный блок памяти.


Пока вы не перезаписываете исходный массив и не выпускаете его из области видимости, проблем не будет. Функции вроде cleaner могут что угодно делать со своим локальным срезом, и снаружи всё будет в порядке. Если вы хотите избежать выделений памяти, то вы можете повесить на функции, к которым у вас есть доступ, атрибут @nogc. Если это невозможно или нежелательно, то либо сохраняйте отдельно указатель, возвращаемый malloc, чтобы потом передать его в free, либо напишите собственные функции для дополнения и конкатенации, либо пересмотрите свою стратегию выделения памяти.


Обратите внимание на тип Array из модуля std.container.array: он не зависит от GC, и может быть полезно использовать его, чем управлять памятью вручную.


Другие API


Стандартная библиотека C не единственный игрок на поле выделения памяти в куче. Существует несколько альтернативных реализаций malloc, и любую из них можно использовать. Потребуется вручную скомпилировать исходники и слинковать с получившимися объектами, но это не неподъёмная задача. Также можно воспользоваться системными API: например, в Win32 доступна функция HeapAlloc (просто импортируйте core.sys.windows.windows). Если есть указатель на блок памяти, то вы всегда можете снять с него срез и использовать в программе на D так же, как если бы вы получили его через GC.


Агрегатные типы


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


Выделить память под один экземпляр структуры или целый массив зачастую не сложнее, чем с типом int.


struct Point { int x, y; }Point* onePoint = cast(Point*)malloc(Point.sizeof);Point* tenPoints = cast(Point*)malloc(Point.sizeof * 10);

Идиллия разрушается, когда имеется конструктор. Функция malloc и её друзья не умеют создавать объекты языка D. К счастью, Phobos предоставляет шаблонную функцию, которая умеет это делать.


Функция std.conv.emplace принимает либо указатель на типизированную память, либо void[], а также опциональные аргументы, и возвращает указатель на полностью готовый экземпляр этого типа. Следующий пример показывает, как использовать emplace и с malloc, и с нашей функцией allocate из предыдущих примеров:


struct Vertex4f {     float x, y, z, w;     this(float x, float y, float z, float w = 1.0f)    {        this.x = x;        this.y = y;        this.z = z;        this.w = w;    }}void main(){    import core.stdc.stdlib : malloc;    import std.conv : emplace;    import std.stdio : writeln;    Vertex4f* temp1 = cast(Vertex4f*)malloc(Vertex4f.sizeof);    Vertex4f* vert1 = emplace(temp1, 4.0f, 3.0f, 2.0f);     writeln(*vert1);    void[] temp2 = allocate(Vertex4f.sizeof);    Vertex4f* vert2 = emplace!Vertex4f(temp2, 10.0f, 9.0f, 8.0f);    writeln(*vert2);}

Функция emplace также инициализирует все переменные значениями по умолчанию. Помните, что структуры в D не обязательно имеют конструктор. Вот что будет, если мы уберём конструктор из реализации Vertex4f:


struct Vertex4f {    // x, y и z инициализируются значением float.nan    float x, y, z;    // w инициализируется значением 1.0f    float w = 1.0f;}void main(){    import core.stdc.stdlib : malloc;    import std.conv : emplace;    import std.stdio : writeln;    Vertex4f vert1, vert2 = Vertex4f(4.0f, 3.0f, 2.0f);    writeln(vert1);    writeln(vert2);        auto vert3 = emplace!Vertex4f(allocate(Vertex4f.sizeof));    auto vert4 = emplace!Vertex4f(allocate(Vertex4f.sizeof), 4.0f, 3.0f, 2.0f);    writeln(*vert3);    writeln(*vert4);}

Программа выведет следующее:


Vertex4f(nan, nan, nan, 1)Vertex4f(4, 3, 2, 1)Vertex4f(nan, nan, nan, 1)Vertex4f(4, 3, 2, 1)

Итак, emplace позволяет инициализировать созданные в куче структуры таким же образом, что и созданные на стеке с конструктором или без него. Она также работает со встроенными типами вроде int и float. Также у этой функции есть версия, предназначенная для классов, но к этому мы вернёмся в следующей статье. Только всегда помните, что emplace создаёт один экземпляр, а не массив экземпляров.


std.experimental.allocator


Весь предыдущий текст описывает основы создания собственного менеджера памяти. Во многих случаях лучше воздержаться от того, чтобы лепить что-то самому, и вместо этого воспользоваться пакетом std.experimental.allocator из стандартной библиотеки D. Это высокоуровневое API, которое использует низкоуровневые техники вроде тех, что описаны выше, а также парадигму проектирования через интроспекцию (Design by Introspection), чтобы облегчить создание аллокаторов различных типов, которые умеют выделять память под экземпляры типов и целые массивы, производить инициализацию и вызов конструкторов. Аллокаторы вроде Mallocator и GCAllocator можно либо использовать напрямую, либо комбинировать с другими строительными блоками, когда нужно что-то специфическое. Реальный пример их использования библиотека emsi-containers.


Держим GC в курсе


Поскольку обычно не рекомендуется отключать GC полностью, большинство программ на D, которые выделяют память за пределами GC, сочетают её с памятью, выделенной через GC. Чтобы сборщик мусора мог корректно работать, он должен знать обо всех внешних ссылках на память из GC. Например, основанный на malloc связный список может содержать ссылки на экземпляры классов, созданные через new.


GC можно известить об этом при помощи GC.addRange.


import core.memory;enum size = int.sizeof * 10;void* p1 = malloc(size);GC.addRange(p1, size);void[] p2 = allocate!int(10);GC.addRange(p2.ptr, p2.length);

Когда блок памяти больше не нужен, можно вызвать соответствующую функцию GC.removeRange, чтобы предотвратить его сканирование. Этим вы не освобождаете этот блок памяти. Это нужно сделать вручную через free или интерфейс аллокатора, который вы для него использовали. Обязательно прочитайте документацию, прежде чем использовать эти функции.


Поскольку мы выделяем память за пределами GC во многом для того, чтобы уменьшить количество сканируемой памяти во время сборки мусора, то может показаться, что всё это обесценивает наши старания. Но так думать неправильно. Если за пределами сборщика мусора хранятся ссылки на память из него, то жизненно важно, чтобы он об этом знал. Иначе GC может освободить память, на которую всё ещё есть ссылки. Функция addRange предназначена специально для таких ситуаций. Если есть уверенность, что блок внешней памяти не содержит ссылок на объекты из GC, то addRange вызывать не нужно.


Предупреждение


Будьте внимательны при использовании addRange. Поскольку эта функция реализована в стиле C и принимает указатель на блок памяти вместе с количеством байт в нём, то здесь можно ошибиться.


struct Item { SomeClass foo; }auto items = (cast(Item*)malloc(Item.sizeof * 10))[0 .. 10];GC.addRange(items.ptr, items.length);

GC будет сканировать блок памяти в 10 байт. Свойство length возвращает количество элементов в срезе массива. Это не то же самое, что и общий размер этих элементов в байтах если только это не срез типа void[] (или срез элементов размером в один байт, таких как byte и ubyte). Правильно будет так:


GC.addRange(items.ptr, items.length * Item.sizeof);

Пока в API рантайма не появится альтернатива, лучше написать для этой функции обёртку, принимающую параметр типа void[].


void addRange(void[] mem) {    import core.memory;    GC.addRange(mem.ptr, mem.length);}

Тогда вызов addRange(items) будет делать всё правильно. Неявное преобразование среза в тип void[] означает, что mem.length будет выдавать тот же результат, что items.length * Item.sizeof.


Цикл статей о GC продолжается


Эта статья осветила самые основы того, как использовать кучу, не прибегая к GC. Помимо классов, в нашем рассказе остался ещё один зияющий пробел: что делать с деструкторами. Я сохраню эту тему для следующей статьи, где она будет очень к месту. Вот что запланировано для следующей из цикла статей о GC. Оставайтесь на связи!


Спасибо Уолтеру Брайту (Walter Bright), Гильяму Пьола (Guillaume Piolat), Адаму Руппу (Adam D. Ruppe) и Стивену Швайхофферу (Steven Schveighoffer) за неоценимую помощь в подготовке этой статьи.


Вместо продолжения
К сожалению, следующих статей мы до сих пор не дождались. На момент написания этой серии в языке ожидались некоторые изменения, касающиеся деструкторов, поэтому автор решил повременить со следующей статьёй. С появлением в API core.memory.GC функции inFinalizer вопрос можно считать более или менее решённым, и Майкл обещает взяться за продолжение, как только появится время.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru