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

Gc

Перевод Производительность современной Java при работе с большим объёмом данных, часть 1

15.06.2020 14:21:28 | Автор: admin

Среда исполнения Java в последние годы развивалась быстрее, чем раньше. Спустя 15 лет мы наконец-то получили сборщик мусора по умолчанию G1. Ещё два в разработке и доступны в качестве экспериментальных функций Oracle ZGC и OpenJDK Shenandoah. Мы решили протестировать все эти новые инструменты и выяснить, что лучше работает с нагрузками, типичными для распределённого opensource-движка потоковой обработки Hazelcast Jet.

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

  1. Неограниченная потоковая обработка с низкой задержкой. Пример: выявление тенденций в данных с датчиков 10 000 устройств, которые снимают информацию с частотой 100 Гц, и отправка поправок в течение 10-20 мс.
  2. Неограниченная потоковая обработка с высокой пропускной способностью. Пример: отслеживание GPS-координат миллионов пользователей с вычислением векторов их скоростей.
  3. Классическая пакетная обработка больших данных. Критерием является время, потраченное на обработку, а значит требуется высокая пропускная способность. Пример: анализ собранных за день данных по биржевым торгам для обновления уровня рисков для заданного портфеля активов.

Сначала мы можем наблюдать следующее:

  • В первом сценарии требования к задержке попадают в опасную зону пауз сборщика мусора: 100 мс. Это считается прекрасным результатом для сборки мусора в самых тяжёлых случаях, и во многих ситуациях может стать камнем преткновения.
  • Второй и третий сценарии идентичны по требованиям к сборке мусора. Требования к задержке менее суровые, но большая нагрузка на tenured-поколения.
  • Второй сценарий труднее из-за требований к задержке, пусть даже и не таких жёстких, как в первом сценарии.

Мы попробовали такие комбинации:

  1. JDK 8 со сборщиком по умолчанию Parallel и опциональными ConcurrentMarkSweep и G1.
  2. JDK 11 со сборщиком по умолчанию G1 и опциональным Parallel.
  3. JDK 14 со сборщиком по умолчанию G1 и экспериментальными ZGC и Shenandoah.

Пришли к таким выводам:

  1. С современными версиями JDK сборщик G1 работает шикарно. Он с лёгкостью обрабатывает кучи (heap) в десятки гигабайтов (мы пробовали 60 Гб), с максимальными паузами в 200 мс. При экстремальной нагрузке G1 не переходит в кошмарные критические режимы. Вместо этого длительность пауз на полную сборку мусора возрастает до секунд. Слабым местом сборщика является верхняя граница пауз в благоприятных условиях низкой нагрузки. Нам удалось понизить её до 20-25 мс.
  2. JDK 8 устаревшая среда исполнения. Сборщик по умолчанию Parallel работает с огромными паузами на полную сборку. С G1 такие паузы возникают реже, однако они ещё длиннее, потому что здесь применяется старая версия сборщика, которая использует лишь один поток. Даже на куче среднего размера в 12 Гб паузы достигали 20 секунд с Parallel и целой минуты с G1. ConcurrentMarkSweep во всех случаях работал гораздо хуже G1, а его критический режим приводил к многоминутным паузам на полную сборку.
  3. Хотя у ZGC пропускная способность намного ниже, чем у G1, однако он лучше вёл себя при небольшой нагрузке, когда G1 время от времени увеличивал задержку до 10 мс.
  4. Shenandoah разочаровал нас случайными регулярными увеличениями задержки до 220 мс при небольшой нагрузке.
  5. Ни ZGC, ни Shenandoah не вели себя в критических режимах так же устойчиво, как G1. Их работа была ненадёжной, в режиме с низкой задержкой неожиданно возникали очень долгие паузы, и даже OOME.

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

Бенчмарк потоковой обработки


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

StreamStage<Long> source = p.readFrom(longSource(ITEMS_PER_SECOND))                            .withNativeTimestamps(0)                            .rebalance(); // Introduced in Jet 4.2source.groupingKey(n -> n % NUM_KEYS)      .window(sliding(SECONDS.toMillis(WIN_SIZE_SECONDS), SLIDING_STEP_MILLIS))      .aggregate(counting())      .filter(kwr -> kwr.getKey() % DIAGNOSTIC_KEYSET_DOWNSAMPLING_FACTOR == 0)      .window(tumbling(SLIDING_STEP_MILLIS))      .aggregate(counting())      .writeTo(Sinks.logger(wr -> String.format("time %,d: latency %,d ms, cca. %,d keys",              simpleTime(wr.end()),              NANOSECONDS.toMillis(System.nanoTime()) - wr.end(),              wr.result() * DIAGNOSTIC_KEYSET_DOWNSAMPLING_FACTOR)));

Этот конвейер отражает сценарии использования с неограниченным потоком событий. Движок должен агрегировать данные методом скользящего окна. Такая агрегация нужна, к примеру, для получения производной по времени от изменяющейся величины, для очистки данных от высокочастотного шума (сглаживания) или для измерения частоты возникновения какого-то события (событий в секунду). Движок может сначала разделить поток по категориям (скажем, все отдельные IoT-устройства или смартфоны) на подпотоки. А затем независимо отслеживать агрегированное значение по каждому подпотоку. В Hazelcast Jet скользящее окно движется дискретными шагами, размер которых вы задаёте. Например, при шаге в 1 секунду вы получаете полный набор результатов каждую секунду. А при шаге в 1 минуту результаты будут включать в себя всё, что произошло за последнюю минуту.

Некоторые примечания.

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

Если конвейер отстаёт, то события буферизируются без сохранения. В этом случае конвейер должен всё наверстать, как можно скорее принимая данные. Поскольку наш источник не распараллелен, предел его пропускной способности достигал около 2,2 млн событий в секунду. Мы эмулировали 1 млн событий/с., оставив запас для навёрстывания в 1,2 млн событий/с.

Конвейер измеряет свою задержку, сравнивая временную метку результата скользящего окна с текущим временем. Применялись две стадии агрегации с промежуточным фильтрованием. Результат одного скользящего окна содержит много элементов, по одному для каждого подпотока, и нас интересует задержка для последнего из элементов. Поэтому сначала мы отфильтровываем большую часть результата, оставляя каждый десятитысячный элемент. А затем направляем уменьшенный поток во вторую стадию, с переворачивающимся окном без ключа. На этой стадии мы отмечаем размер полученного результата и измеряем задержку. Агрегирование без применения ключа не распараллелено, поэтому у нас одна точка измерения. Стадия фильтрации распараллелена и является data-local, поэтому влияние дополнительной стадии агрегации очень мало (гораздо ниже 1 мс).

Мы использовали простую агрегирующую функцию: подсчёт. Фактически, получали метрику частоты событий в потоке. У него минимальная структура (одно число типа long), мусор не генерируется. При любом объёме использования кучи (в гигабайтах) такое маленькая структура на ключ подразумевает наихудший сценарий для сборщика мусора: очень большое количество объектов. Нагрузка на сборщик растёт не с размером кучи, а с количеством объектов. Также мы протестировали вариант с вычислением той же агрегирующей функции, но с другой реализацией, которая генерирует мусор.

Большую часть потоковых бенчмарков мы прогнали на одной ноде, потому что нас интересовало влияние управления памятью на производительность конвейера. А сетевая задержка только добавляет шума в данные. Для проверки гипотезы о том, что производительность кластера не повлияет на наши выводы, мы повторили некоторые ключевые тесты на кластере Amazon EC2 из трёх узлов. Подробнее об этом будет рассказано ближе к концу второй статьи.

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

Первый сценарий: низкая задержка, средняя структура


Параметры сценария:

  • OpenJDK 14
  • Размер кучи JVM 4 Гб.
  • Для G1 задано -XX:MaxGCPauseMillis=5
  • 1 млн событий/с.
  • 50 000 отдельных ключей.
  • 30-секундное скользящее окно через 0,1 секунды.

При таком сценарии используется меньше 1 Гб кучи. Нагрузка на сборщик небольшая, у него достаточно времени для фоновой конкурентной сборки мусора. Вот максимальные задержки в работе конвейера с тремя протестированными сборщиками:


Эти значения включают в себя фиксированные промежутки примерно в 3 мс на передачу результатов окна. График говорит сам за себя: сборщик по умолчанию G1 отлично справляется, но если вам нужна задержка ещё ниже, можете использовать экспериментальный ZGC. Мы не смогли опустить пики задержки ниже 10 мс. Но в случае с ZGC и Shenandoah они возникают не из-за пауз на сборку мусора, а из-за коротких периодов возросшего объёма фоновой работы сборщиков. Иногда служебные процессы в Shenandoah поднимали задержку выше 200 мс.

Второй сценарий: большая структура, менее строгие требования к задержке


Мы предполагаем, что по не зависящим от нас причинам (например, из-за сотовой сети) задержка может возрастать до секунд. Это смягчает требования к конвейеру потоковой обработки. С другой стороны, мы можем столкнуться с куда более крупными данными, размером в миллионы или десятки миллионов ключей.

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

Мы прогнали много тестов в разных комбинациях, чтобы выяснить, как сочетания факторов влияют на эффективность работы среды исполнения. Выяснилось, что это зависит от двух параметров:

  1. Количество записей, хранящихся в агрегатах.
  2. Требование к пропускной способности для навёрстывания.

Первый параметр описывает количество объектов в tenured-поколении. При агрегировании по методу скользящего окна мы длительное время (на протяжении окна) удерживаем объекты, а затем освобождаем их. Это прямо противоречит гипотезе о сборке мусора с учётом разных поколений (Generational Garbage Hypothesis), которая утверждает, что объекты либо умирают молодыми, либо живут вечно. При таком режиме создаётся максимальная нагрузка на сборщик мусора. А поскольку интенсивность его работы растёт с количеством живых объектов, производительность сильно зависит от этого параметра.

Второй параметра связан с тем, какой объём ресурсов приложение может выделить сборщику мусора. Чтобы было понятнее, давайте построим несколько диаграмм. При агрегировании по методу скользящего окна конвейер проходит через три этапа:

  1. Обработка событий в реальном времени по мере их возникновения.
  2. Передача результатов скользящего окна.
  3. Навёрстывание событий, полученных в течение второго этапа.

Все три этапа можно визуализировать так:


Если передача результатов окна занимает больше времени, мы оказываемся в такой ситуации:


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

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


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

Вот как это будет выглядеть, если передача результатов окна будет занимать слишком много времени:


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

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

Теперь у нас есть два более-менее независимых параметра, полученных на основе многих других параметров, которые описывают каждую отдельную комбинацию. Можно построить двумерный график, круги на котором обозначают прогнанные бенчмарки. Раскрасим круги в соответствии с успешностью или неудачностью комбинации. Например, для связки JDK 14 с G1, работающей на ноутбуке, мы получим такой график:


Мы выделили три категории:

  • да конвейер справляется,
  • нет конвейер не справляется из-за нехватки пропускной способности,
  • сборщик мусора конвейер не справляется из-за частых длинных пауз на сборку.

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

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


У нас был MacBook Pro 2018 с 6-ядерным Intel Core i7 и 16 Гб DDR4 RAM. Для ОМЬ было настроено -Xmx10g. Однако мы считаем, что подобная картина будет наблюдаться и на многих других конфигурациях. График демонстрирует превосходство G1 над другими сборщиками, слабость G1 при использовании с JDK 8, а также слабость экспериментальных сборщиков с низкой задержкой при такого рода нагрузке.

Базовая задержка длительность передачи результатов окна колебалась в районе 500 мс. Однако часто возникали всплески из-за основных пауз на сборку мусора (которые в случае с G1 были неоправданно длинными), вплость до 10 с в пограничных ситуациях (когда конвейер едва справлялся с работой) и снижается до 1-2 с. Мы также заметили влияние JIT-компиляции в пограничных ситуациях: конвейер начинает работать с постоянно растущей задержкой, а примерно через две минуты производительность улучшается и задержка возвращается к нормальным значениям.
Подробнее..

Перевод Производительность современной Java при работе с большим объёмом данных, часть 2

19.06.2020 00:04:04 | Автор: admin

FYI: Первая часть.

Бенчмарк пакетного конвейера


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

Поэтому мы выбрали Parallel. На первом этапе тестирования, при работе на одной ноде, этот сборщик действительно показал лучшую пропускную способность (но только после настройки). Однако это было получено ценой длительных пауз. Если одна из нод кластера останавливается на сборку мусора, это стопорит весь конвейер. А поскольку ноды собирают мусор в разное время, общее время сборки увеличивается с добавлением каждой ноды к кластеру. Мы проанализировали этот эффект, сравнив результаты тестирования на одной ноде и на кластере из трёх нод.

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

Тестирование на одной ноде: конвейер


Для пакетного бенчмарка на одной ноде мы использовали простой конвейер. Полный код здесь.

p.readFrom(longSource) .rebalance() // Introduced in Jet 4.2 .groupingKey(n -> n % NUM_KEYS) .aggregate(summingLong(n -> n)) .filter(e -> (e.getKey() & 0xFF_FFFFL) == 0) .writeTo(Sinks.logger())

Источником является заглушка, которая генерирует последовательность чисел типа long. Ключевая функция определена так, что groupingKey циклически проходит по пространству ключей: 0, 1, 2, ..., NUM_KEYS, 0, 1, 2, Это означает, что в течение первого цикла конвейер видит все ключи и создаёт фиксированную структуру данных для хранения результатов агрегирования. А в течение следующих циклов система лишь обновляет имеющиеся данные. Это полностью соответствует гипотезе о сборке мусора с учётом разных поколений: объекты либо проходят через все вычисления, либо существуют недолго и становятся мусором вскоре после своего создания.

Наш источник сгенерировал 400 млн элементов, и мы создали 100 млн отдельных ключей, то есть прошли четыре раза по всем ключам.

Оператор .rebalance() заменяет используемое по умолчанию в Jet двухэтапное агрегирование на одноэтапное. Это сделало поведение движка в наших бенчмарках более предсказуемым.

Также мы протестировали вариант, при котором операция агрегирования использует в качестве состояния экземпляр объекта типа Long и генерирует мусор при каждом обновлении текущего счёта. В этом случае многие объекты умирают после того, как просуществуют приличное время в старом поколении. В этой ситуации нам пришлось уменьшить количество ключей до 70 млн, потому что при 100 млн нагрузка на сборщик была слишком высокой.

Мы не ориентировались на сборщики с низкой задержкой, потому что они ничего не могут предложить в случае с пакетным конвейером. Поскольку мы уже видели, что JDK 14 работает почти как JDK 11, то прогнали один тест для подтверждения этого. А затем сосредоточились на JDK 8 и JDK 11. И также сравнили с G1 используемый в JDK 8 по умолчанию сборщик Parallel.

Тестирование на одной ноде: результаты


Мы прогнали бенчмарк на ноутбуке с 16 Гб ОЗУ и 6-ядерным Intel Core i7. Размер кучи был 10 Гб.

Сначала из-за Parallel производительность была очень плохой, и нам пришлось настраивать сборку мусора. Поэтому мы очень рекомендуем использовать VisualVM и плагин Visual GC. Если задать максимальную частоту кадров (10 к/с.), то можно насладиться очень подробной визуализацией взаимосвязи между выделением памяти вашего приложения и работой сборщика мусора. Понаблюдав за анимацией, мы поняли, что главной проблемой были слишком большие фрагменты памяти, выделяемые для нового поколения. По умолчанию отношение старого поколения к новому равно всего 2:1, и в течение исполнения оно не меняется динамически. Поэтому мы решили применить настройку -XX:NewRatio=8, и это изменило всю картину. Теперь Parallel работал лучше всего. Также мы применили -XX:MaxTenuringThreshold=2 для уменьшения копирования данных между пространствами survivor spaceами, потому что временные объекты быстро умирают в конвейере.

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



Агрегирование без сборки мусора работает примерно на 30-35 % быстрее, несмотря на более крупный набор ключей. Хуже всего отработала связка G1 и JDK 8, а лучше всего настроенный Parallel и JDK 11. Не сильно уступила связка G1 и JDK 11. Обратите внимание, что мы не трогали конфигурацию G1, это важное замечание. Настройка сборки мусора сильно зависит от ситуации. Результаты могут сильно меняться, например, при увеличении количества данных. И настраивать нужно для всего кластера, под конкретный вид нагрузки.

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


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

Тестирование на трёх нодах: конвейер


Для правильного тестирования кластера пришлось использовать более сложный конвейер:

p.readFrom(longSource()) .rebalance() .flatMap(n -> {     Long[] items = new Long[SOURCE_STEP];     Arrays.setAll(items, i -> n + i);     return traverseArray(items); }) .rebalance() .groupingKey(n -> n % NUM_KEYS) .aggregate(AggregateOperations.summingLong(n -> n)) .filter(e -> e.getKey() % 1_000_000 == 0) .writeTo(Sinks.logger());

Поскольку источник работает не параллельно, мы сделали некоторые оптимизации, чтобы он не превратился в узкое место системы. Источник генерирует числа 0, 10, 20,, а мы распараллелили этап flatMap, на котором интерполируются недостающие числа. Также между источником и flatMap мы использовали rebalance(), распределяя данные по кластеру. А перед началом основного этапа (агрегирования по ключу) мы снова сделали перебалансировку. После агрегирования мы сначала оставляем только каждую миллионную пару ключ-значение, а затем передаём их в логгер. Применялся миллиард элементов данных и набор из 500 млн ключей.

Как и в случае с одной нодой, мы протестировали конвейер с агрегированием без мусора и с ним.

Тестирование на трёх нодах: результат


Мы прогнали этот бенчмарк на кластере AWS из трёх экземпляров c5d.4xlarge. У них было 16 виртуализированных процессорных ядер и 32 Гб памяти. Пропускная способность канала 10 Гбит/с. Результат:



Вскользь отметим примерно трёхкратное общее увеличение пропускной способности по сравнению с одной нодой. Это последствие распределённой обработки. А что касается сборщиков, то победителем обоих тестов стала связка G1 и JDK 11. Другой поразительный результат почти неработоспособная связка G1 и JDK 8. Однако у этого есть более глубокие причины, которые влияют и на другие измерения. Например, очевидное преимущество Parallel на JDK 8 и JDK 11. Это связано с эффектом, который мы отметили в самом начале: как только любая нода встаёт на паузу для сборки мусора, останавливается обработка на всём кластере. А G1 на JDK 8 встаёт на очень длинные паузы, больше минуты. Этого достаточно, чтобы детектор сбоев в кластере сработал и решил, что нода умерла. Задача сбоит, кластер переформирует себя, и задача запускается заново уже на двух нодах. Это ещё быстрее приводит к новому сбою, потому что каждая нода теперь обрабатывает больше данных. В то же время выброшенная нода присоединяется снова, и задача снова начинается на двух нодах, но уже других. Возникает бесконечная петля перезапуска задач.

Паузы Parallel не такие длинные, чтобы развалить кластер, но он сработал гораздо хуже на в тестах на одной ноде. На трёх нодах он уступил связке G1 и JDK 11 на 30 %. А на более крупных кластерах ситуация будет ещё хуже.

Если посмотреть на все тесты, то удивительно, что Parallel на JDK 8 работает быстрее, чем на JDK 11. Однако это связано с очень удачным совпадением: в этих тестовых прогонах полные паузы начинались синхронно на всех узлах, что распараллелило работу сборщика. Очевидно, что на этот эффект нельзя полагаться.

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

С другой стороны, связка G1 и JDK 11 работала с достаточно короткими паузами, поэтому конвейер не останавливался. В нём есть механизм, который смягчает короткие простои, и пока паузы не превышают 150 мс, сборка мусора оказывает только локальное влияние.
Подробнее..

В нативный код из уютного мира Java путешествие туда и обратно (часть 1)

05.10.2020 16:20:50 | Автор: admin


Java и другие управляемые языки просты и удобны во многих случаях, но иногда их возможностей недостаточно например, если нужна библиотека, написанная только на C или C++. Иногда хочется позвать пару методов из системного API, или попытаться улучшить производительность для модуля и тогда прямой путь в нативный код.


Но тут возникают подводные камни: написать нативный метод и вызвать библиотеку может быть и легко, но JVM начинает крашиться в случайных местах, производительность падает, сборщик мусора перестает справляться с работой, а в репозитории царствуют бесконечные C-шные файлы с буквами JNI. Что же могло пойти не так?


Иван Углянский (dbg_nsk) из Huawei разбирается со всем по порядку: что необычного в интеропе между Java и нативным кодом, как оно работало раньше и что нужно делать для их нормальной совместной работы (и можно ли это вообще сделать). Иван рассказывает, как избежать просадок производительности, внезапных OOM и размышляет на тему будущего в контексте проектов Panama и Sulong.


Мы подготовили текстовую версию доклада о работе с нативами в Java. В первой части:


  • Зачем вообще работать с нативным кодом в Java.
  • С какими ошибками и проблемами придётся столкнуться при работе с нативами.

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


Далее повествование от лица спикера.


Сегодня мы говорим про нативный код, про путешествие из Java в него и обратно. Дело в том, что я JVM-инженер, 7.5 лет работал в Excelsior, где мы делали собственную виртуальную машину Excelsior JET, а вот уже чуть больше года работаю в компании Huawei, в команде Excelsior@Huawei, где мы продолжаем заниматься своим любимым делом: компиляторами, JVM и новыми языками программирования.


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


В Java есть такая интересная фича вы можете написать методы без тел, зато со специальным ключевым словом native:


public class JavaToNative {    static native void goNative();    static native void goThere(Callback andBackAgain);}

Это означает, что реализацию этих методов стоит искать где-то ещё, например, в подгружаемых динамических библиотеках. И написана она может быть на каких-то других языках, например, на C/C++ или любом другом языке, где можно сделать C-like бинарные интерфейсы.


Бывают как простые сценарии, так и более сложные, что показывают уже методы на примере выше. Если вызываете первый метод goNative, то просто переходите из Java в C. А вот метод goThere позволяет перейти из Java в C, передать туда Java-объект callback и вызвать от него уже Java-метод.



Таким образом, во время исполнения вашего приложения в call stack могут чередоваться java и нативные фреймы.


Зачем нам нужны нативы


Java замечательный managed-язык, в котором очень много всего сделано для вашего удобства.


Там есть автоматическое управление памятью, и вы, наверное, уже отвыкли от проблем, типа утечек памяти, висячих ссылок и прочего всё это осталось где-то в районе C, а в Java есть GC, который с этим хорошо справляется.


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


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


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


А вот нативный код это его полная противоположность. Это Мордор, где шаг влево-вправо, и вас сжирает горный тролль.


Но знаете, иногда нужно выходить из уютного Шира и идти в путешествие к Роковой горе.


На то могут быть различные причины. Первый тип мотивации в мире огромное количество библиотек, написанных не на Java или подобных, а на C/С++ языках. От компьютерной графики OpenGL до машинного обучения Tensorflow, от огромного количества матбиблиотек до сертифицированной ФСБ библиотеки криптографии. Всё это не Java, а, скорее всего, С. И чтобы всё это использовать, нужно полагаться на механизмы нативных методов.


Кроме того, вы можете захотеть получить что-то напрямую от операционной системы. Допустим, вы хотите узнать, какой прокси стоит у вашего пользователя напрямую из Java вы этого не сделаете, вам опять-таки нужно опуститься на уровень нативного кода и дёрнуть метод, например, из WinAPI в случае Windows.


Есть ещё одна мотивация. Многие люди привыкли думать, что Java тормозит, а вот C++ это очень быстро. Поэтому если взять и переписать самый performance critical модуль проекта на плюсы, связать всё это через нативы, то получится огромное ускорение производительности. Почему эта мотивация довольно сомнительная, я покажу ниже, но в любом случае она присутствует.


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


И вот вы полны энтузиазма, написали своё приложение наполовину на С, наполовину на Java, запускаете, ожидаете, что сейчас всё ускорится, а в результате получаете SIGSEGV, Exception_Access_Violation или ещё один SIGSEGV.

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


На самом деле чаще всего проблема в том, что они неправильно используют нативы.


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


По ходу повествования мы будем все время сверяться вот с такой картой Как позвать натив?

Если вы идете из Шира в Мордор, вам нужно ответить на три вопроса:


  1. Где виртуальная машина должна взять реализацию тех самых нативных методов?
  2. Если вы перешли в натив, то как вы можете взаимодействовать с Java-миром?
  3. Как сборщик мусора должен реагировать на тот факт, что какой-то поток взял и ушёл в натив, что с этим нужно делать?

Ответы на эти вопросы подсветят нам самые больные места в механизме нативных вызовов и помогут избежать проблем.


История до нашей эры


Сначала чуть-чуть истории.

Нативы можно было вызывать в Java ещё в самом начале, буквально в JDK 1.0 уже был Native Method Invocation, который позволял вызывать C-шные методы. Но он был заточен на детали реализации одной конкретной виртуальной машины, а именно на Sun JVM. На то, как там лежат объекты в памяти, какой сборщик мусора там используется.


Были и альтернативы. Например, Microsoft предлагала свой Raw Native Interface. Он был в чем-то лучше, в чем-то хуже, но тоже работал только с одной виртуальной машиной теперь уже Microsoft J++.


Были попытки сделать нейтральные решения, как у Netscape, но в целом это были тёмные времена. Когда вы писали натив, вы не могли быть уверены, что это будет работать на всех JVM или хотя бы на каких-то.


Наша эра: JNI Java Native Interface


Наша эра начинается с появления знаменитого Java Native Interface или JNI. Это был единый интерфейс, чтобы править всеми, и он был прекрасен, потому что был JVM нейтрален.


Он никак не затачивался на то, как сделана конкретная виртуальная машина, не важно, какая раскладка по объектам в памяти, неважно какой GC.


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


Давайте посмотрим, как это работает.


Со стороны Java всё выглядит довольно мило, вы это уже видели.


public class JavaToNative {    static native void goNative();    static native void goThere(Callback andBackAgain);    public static void main(String[] args) {        System.LoadLibrary(NativeLib);        goThere (new Callback(Eagles));}

Пишем методы без тела, пишем где искать реализацию, например, в System.LoadLibrary говорим подгрузить dll-ку, и после этого просто вызываем этим методы и переходим в С или С++.


Callback это просто класс, у которого есть метод call, ничего не возвращающий, который печатает строку Ok, we are in Shire again!, в моём случае мы вернулись в Шир на орлах.


Как получить заголовку функций?


Теперь давайте попробуем написать нативную часть на языке С.


Здесь всё будет уже не так красиво, но нам нужно это сделать.


public class JavatoNative {    static native void goThere (Callback and BackAgain);}

Допустим, у вас был какой-то класс, где вы описали нативный метод без тела. Чтобы получить реализацию вы в первую очередь компилируете его с помощью javac с флажком -h.


javac JavaToNative. java -h .

В таком режиме javac ищет все нативы и генерирует сишный .h-файл с заголовками соответствующих уже сишных функций.


В результате мы получаем JavaToNative.h со всеми заголовками, но при этом то, что там написано, не очень-то похоже на нашу функцию.


JNIEXPORT void JNICALL Java_JavaToNative_goThere    (JNIEnv *, jclass, jobject);

Здесь появились какие-то заклинания типа JNICall. Здесь совсем другое имя метода: оно содержит еще и package и имя класса. И сигнатуры отличаются! У нас был 1 аргумент типа Callback, а здесь их уже три и они совсем другие.

Что за аргументы?


jclass появился, потому что натив был статическим и этим параметром передается Java-класс, чей статический метод вызывается. Callback превратился в jobject и появился новый JNIEnv со звёздочкой (про него чуть позже).


Правила, по которым генерируются заголовки, очень четкие и описаны в JNI-спецификации. Все примитивные типы превращаются в соответствующие примитивные C-шные (заданные макросами и базирующиеся на С-шных примитивных типах), все референс-типы превращаются в jobject или в редких исключениях в его наследников jclass, jstring, jthrowable, jarray.


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

Даже если вы хорошо знаете JNI спецификацию, не советую вам руками писать эти заголовки, javac замечательно справляется сам, если указать ему -h. Так что лучше использовать его, чтобы свести к минимуму человеческий фактор.


Что за JNIEnv?


Аргумент JNIEnv * это указатель на таблицу из 214 специальных функций, которая называется JNINativeInterface.


Вот некоторые из них:

А вот некоторые важные из них, которые, скорее всего, чаще всего используются.

JNINativeInterace помогает нам программировать на метауровне как будто бы на Java, но используя мета-сущности: handle для классов, методов и так далее. Например здесь вы можете получить handle Java-класса, через него создавать его экземпляры (Java объекты), вызывать Java методы через специальные функции Call*Method, выбрасывать исключения.


Это очень похоже на рефлексию, только вы занимаетесь этим не в Java-коде, а в C.


Все эти функции JNI-интерфейса единственный способ хоть как-то взаимодействовать с Java-миром: либо с объектами, либо просто получить информацию от виртуальной машины.


И это ответ на второй вопрос в нашей карте: как взаимодействовать с JVM. Вот так через 214 функций, которые являются вратами в Шир.


Теперь давайте напишем нашей функции тело.


/* * Class:   JavaToNative * Method:  goThere * Signature: (LCallback;)V */JNIEXPORT void JNICALL JAVA JavaToNative goThere (JNIEnv * env, jclass klass, jobject andBackAgain) {    printf("ok, we are in Mordor now! \n");    jclass cls = (*env) ->GetObjectClass (env, andBackAgain);    jmethodID method = (*env)->GetMethodID(env, cls, "call", "()V");    (*env)->CallVoidMethod(env, andBackAgain, method);}

Делаем .c файл, копируем туда наш заголовок из .h, открываем фигурные скобки и начинаем писать обычный C-шный код. В первую очередь я printf-ом напечатаю, что мы пришли в Мордор, а потом я хочу вернуться в Java, позвать Callback.


Для этого я получаю jclass, соответствующий классу моего аргумента, нахожу в нём метод, который называется call, возвращающий void, и вызываю этот метод с помощью JNI-функции CallVoidMethod. Должна напечататься строка, что мы вернулись на орлах и всё ок.


Как все это собрать?


Наконец, давайте обсудим, как все полученное ранее собрать.
Я использую Windows, поэтому гуглю заклинание, как собрать нативную библиотеку для JNI на этой системе:

В результате у нас получается библиотека NativeLib.dll.


Это, конечно, довольно неприятно с точки зрения кроссплатформенности. Потому что, если вы собираете библиотеку для Linux или macOS заклинания будут другими.


К счастью, есть замечательные тулы, которые позволяют от всего этого абстрагироваться. Например, Nokee plugins. Это кроссплатформенное решение, которое позволяет удобно добавить таргет в gradle скрипт и в результате собрать библиотеку под интересующие вас платформы.


Окей, тем или иным способом мы библиотеку собрали, после чего запускаем наше Java приложение, и получаем

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


Что может пойти не так?


А пойти не так может очень много вещей


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


Да, вы передавали объект callback, но он превратился в jobject, и какой был тип изначально сходу не видно.


Допустим, у меня был бы какой-то другой аргумент, теперь уже java.lang.Object. И он бы тоже представлялся в нативном коде, как jobject, а потом я могу совершенно случайно по невнимательности позвать CallVoidMethod, передав туда в качестве аргумента не Callback, а какой-то java.lang.Object и попытаться из него позвать метод call (которого там, конечно, нет).

Меня не остановит компилятор, не остановит runtime ровно до тех пор, пока не случится развал из-за попытки позвать call от java.lang.Object.

Абсолютно похожая история с тем, какую конкретно JNI-функцию вы вызываете. Никто не проконтролирует, что вы используете именно СallVoidMethod, а не CallBooleanMethod или CallStaticVoidMethod или ещё что-то это будет ваша ответственность. Если вы ошиблись, то случается неопределенное поведение (прям как в плохих программах на С), что начнет делать виртуальная машина неизвестно.


Еще один момент, на который стоит обратить внимание: когда вы вызываете из натива Java-метод, он вполне может выбросить исключение, после чего исполнение возвращается в натив. В Java мы привыкли, что необработанное исключение автоматически пролетает дальше, ничего дополнительного делать не нужно. Но в данном случае это снова ваша ответственность! Вы должны проверить, а не случилось ли при вызове Java-метода исключения (с помощью функций ExceptionCheck или ExceptionOccurred), и если так, то обработать его здесь (с помощью ExceptionDescribe и ExceptionClear). Если же вы этого не сделаете, то в следующий раз, когда исполнение придет в Java-код, это исключение полетит уже совсем из другого неожиданного для вас места, и вы снова получите некорректное поведение.


К счастью, 90% подобных проблем с нативами помогает решить волшебная опция: -Xcheck:jni.


Возьмем наш пример, где мы подставляли неправильный аргумент, и запустим с -Xcheck:jni.

Вместо страшного и ужасного развала вы получите привычное исключение с понятным stacktrace, где будет написано, что вы перепутали MethodID или же используете не тот объект при вызове (что, собственно, у нас и происходит!).

Это помогает быстро понять проблему и разобраться в большем проценте ошибок с нативами.


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


Это не означает, что будут вылечены все проблемы, но все самые простые да.


Garbage Collector и Native-код


А теперь поговорим про последний пункт в нашей карте как GC должен взаимодействовать с нативным кодом.


Почему про это вообще нужно говорить? Дело в том, что в Java коде, когда JVM нужно пособирать мусор, она приостанавливает Java потоки в специальных сгенерированных компилятором точках, которые называются GC safepoints. Давайте для простоты рассматривать случай StopTheWorld-коллекторов. В таком сценарии только после того, как все Java-потоки достигли ближайших safepoints и приостановились, начинают работать GC-треды, которые, собственно, собирают мусор.

Это важно, потому что GC может двигать объекты во время своей работы. Для компактизации кучей, для своих каких-то целей неважно. Если в этот момент кто-то из Java-тредов будет смотреть и взаимодействовать с Java хипом читать или записывать поля некоторого Java объекта, то может случится неприятная ситуация: этот объект просто украдут у него из-под носа и перенесут в другую часть памяти. В результате вы получите некорректное поведение (например, развал).


Так вот проблема с safepoints в том, что в нативном коде такой фокус не пройдет.


Safepoints вставляют компиляторы из JVM, а если это какой-то внешний код, например на C или C++, скомпилированный clang-ом, то там нет никаких safepoint! В результате, мы просто не сможем остановить наши потоки, которые исполняют натив, чтобы пособирать мусор. Поэтому мы вынуждены смириться с тем, что нативы будут работать параллельно со сборкой мусора.


И тогда схема меняется так: появляются новые действующие лица, треды, исполняющие нативный код. Допустим, они ушли в натив до того, как нам потребовалось пособирать мусор, и вот они спокойно будут работать параллельно с GC-тредами.

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


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


Как вы помните, все наши Java-объекты в нативах почему-то превратились в jobject.


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

Гарантируется, что Java-машина поддерживает связь этого адреса с реальным адресом объекта. То есть, если мы подвинули объект, то соответствующий jobject тоже будет пропатчен автоматически.

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

Если последним использованием ваших объектов была передача их в нативный код, то гарантируется, что за время исполнения этого натива их никто не соберет. Эти jobject являются своеобразными GC-root, что гарантирует выживание объекта.


Поговорим о том, какие проблемы это может вам доставить.


JNI References


Первая и главная проблема в том, что для хендлов реализована альтернативная система управления памятью. Это не похоже ни на Java, ни на C, скорее, что-то среднее между ними. Всё, что вы в коде видите, как jobject, на самом деле является сложным объектом JNI Reference, причем они бывают трех разных типов.


Во-первых, local references.


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


Они интересны, во-первых, тем, что большинство JNI-reference это именно LR. Передали какие-то Java-аргументы в натив они автоматически заворачиваются в локалрефы, вызываете JNI-функцию, создающую объект из нее тоже вернется локалреф. А во-вторых, с этими штуками, несмотря на, казалось бы, очень естественную и простую схему очистки, чрезвычайно легко получить утечку памяти.


Продемонстрирую это на небольшом примере:


JNIEXPORT void JNICALL Java_JavaToNative_objectsAllocationTest (JNIEnv *env, jclass klass) {    jclass cls = (*env)->FindClass(env, "BornInNative");    jmethodID init = (*env)->GetMethodID(env, cls, "<init>", "(I)V");    jmethodID check = (*env)->GetMethodID(env, cls, "areYouReady", "()Z");    int ready = 0, id = 0;    while (!ready) {        jobject obj = (*env)->NewObject(env, cls, init, id++);        ready = (*env)->CallBooleanMethod(env, obj, check) == JNI_TRUE;    }    printf ("finally ready after %d objects created!\n", id);}

Здесь мы будем аллоцировать в огромных количествах объекты прямо из нативного кода. Для этого находим соответствующий класс BornInNative с помощью JNI-функции FindClass, а получаем конструктор и метод-предикат, который будет говорить по соответствующему инстансу, нужно ли создавать следующий объект или нет. А потом просто в нативном коде с помощью JNI-функции NewObject начинаем эти объекты создавать.



NewObject аллоцирует память, вызывает конструктор, который создает объект и возвращает в нативный код ту самую local reference, которую затем сохраняем в переменную obj типа jobject. От неё вызываем предикат, чтобы понять, нужно ли дальше аллоцировать объекты или нет.


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


На Java бы всё работало, но в нативном коде вам такого никто не гарантирует. Про Local reference гарантируется, что они умирают не позже, чем возврат из нативного метода. Но это и все: сами по себе от того, что вы переназначили переменную на другую LR, они умирать не обязаны и не будут.



Чтобы это показать, давайте залогируем, получилась ли очередная аллокация или нет, и запустим всё это на hotspot с -Xmx 1 ГБ.



Через несколько сотен миллионов итераций мы заметим, что аллокации стали фейлиться. JVM сейчас в коматозном состоянии, она пытается выкинуть out of memory, но ничего не получается, ведь в нативе мы это не обрабатываем. Обратите внимание на потребление памяти.



Вы заказывали 1 ГБ, но потребление на самом деле уже 2 ГБ, потому что а) все Java-объекты удерживаются в heap, б) сами неумирающие jobject тоже занимают (нативную) память. В результате реальное потребление памяти вашим приложением превысило указанный лимит на дополнительный гигабайт.


Чтобы это починить, есть специальная функция DeleteLocalRef, которая говорит JVM, что локальная ссылка больше не нужна, ее можно уничтожить, а соответствующий объект собрать во время GC.



Исправленная программа будет работать с любым разумным Xmx.


Так что с local Reference легко получить memory leak, но также легко получить и висящую ссылку. Попробуйте сохранить LR в static-поле, выйти из натива, вернуться и прочитать это поле. Получите некорректное значение.


Кроме LR есть другие хендлы, например Global Reference. Такие ссылки существуют до тех пор, пока вы явно их не освободите. Здесь ещё легче получить утечку памяти (достаточно просто забыть вызвать DeleteGlobalRef), но с другой стороны они более прямолинейны, нет неожиданностей. Забыли позвать DeleteGlobalRef значит, будет утечка.


Наконец есть Weak Global Reference, это GR, но в них не гарантируется, что GC не соберет ваш объект. Это полная аналогия со слабыми ссылками из Java. Таким образом, все проблемы с ними актуальны и для нативов тоже.



Еще больше сложностей с GC


Кроме проблем с JNI Reference стоит упомянуть, что у некоторых функций JNI-интерфейса есть очень интересные отношения со сборщиками мусора. Допустим, вы передаете в натив массив, он завернется в jobject, но получать доступ к каждому элементу по одному через jni-функции это очень долго.


Вместо этого вы наверняка захотите получить доступ ко всему региону данных из массива за раз. Для этого есть специальные функции, например, GetIntArrayElements. Однако у нас опять есть проблема: мы не можем получить доступ к объекту, если в этот момент его может подвинуть GC. С этим нужно что-то сделать.


Есть две техники, как это можно реализовать. Во-первых, можно запинить объект, сказать сборщику мусора давай мы не будем двигать пока массив, ты собирай мусор, а его не двигай.


Вторая тактика просто скопировать его в нативную память, в нативе поработаем с копией, а потом обновим соответствующий массив.


JNI функции типа GetIntArrayElements даже поддерживают такую двойственность решения этой проблемы: у них есть третий аргумент указатель на флажок. Если виртуальная машина решилась скопировать, то туда запишется true, если нет, то false, так что вы узнаете, что конкретно произошло.


Подводный камень здесь в том, что большинство виртуальных машин и сборщиков мусора не умеют pin-ать объекты по одному. Есть исключения, но скорее всего, как бы вы не надеялись на то, что копирования не случилось, оно произойдет. Так что при работе в нативе с массивом на 2 ГБ вы столкнетесь с копированием его в нативную память, что, конечно, может ударить и по производительности, и по общему потреблению памяти вашим приложением.

Конечно, есть особенные JNI-функции типа GetArrayElementsCritical (и другие функции с суффиксом Critical), они всячески стараются не скопировать массив.


Пиннинга в большинстве GC нет, как они выходят из ситуации?


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


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


Производительность нативных методов


И наконец, нельзя говорить про нативы и не обсудить их производительность. Раз вы вызываете C-код, то, конечно, кажется, что это должно чертовски быстро работать по сравнению с обычной Java. На самом деле это большое заблуждение. Дело в том, что сам вызов нативных методов это серьезная сложность для виртуальной машины. Давайте измерять!


Все замеры будем проводить на машине: Intel Core i7-7700 @ 3.60 GHz;16GB RAM, Linux Ubuntu 18.04


Начнем с простого примера. Мы из Java вызываем другой Java метод без параметров и обязательно без инлайна. Мерим это с помощью JMH, получаем 696 попугаев, (больше лучше).

Проведем другой эксперимент и вызовем из Java нативный метод, тоже пустой, без параметров и возвращаемого значения. И получаем просадку производительности в 3,3 раза на jdk8u252.

При этом на jdk11 вы уже получаете просадку уже в 6 раз. Причины такой разницы в поведении разных версий Java рассмотрим в конце доклада, а сейчас продолжим наши измерения.

Теперь давайте проведем более зловещий эксперимент и вызовем из Java натив, а оттуда через callback позовём пустой Java-метод. Логично предположить, что здесь случится проседание раза в два (ведь стало в два раза больше работы). На самом деле просадка будет в 10 раз.

Т.е.возвращаться обратно из Java в натив дороже, чем просто уходить в натив.


Почему так происходит?


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

И вы этот call получите, но вокруг него есть ещё некоторое количество работы для подготовки к вызову и обработки результата.

Более конкретно кроме самого вызова нам нужно:


  1. Синхронизироваться с GC, объявить, что вы ушли в натив или вернулись.
  2. Если есть аргументы, то завернуть их в Local References. При этом один параметр-то есть всегда, даже в нашем случае это либо jclass для статического метода, либо объект, от которого вызвали метод в случае instance-метода.
  3. На выходе нужно сделать exception check, проверять, не полетело ли исключение.
  4. Есть системная работа: выравнивание стека, перекладывание параметров с одних регистров на другие, и так далее.

И всё это даёт просадку производительности в шесть раз.


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


Ну и про возвращение обратно в Java так медленно работает из-за реализации конкретной виртуальной машины Hotspot. Когда вы делаете callback, происходит много лишней и тяжелой работы, в других виртуальных машинах результат мог бы быть гораздо лучше.


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

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


В следующей части поговорим про сегодняшние альтернативы JNI, их сильные и слабые стороны, а также обсудим будущие проекты, которые вполне могут кардинально поменять все наше представление о нативах в Java: проекте Panama и Sulong.


Минутка нативной рекламы в тексте про нативный код. Раз вы здесь похоже, вы Java-разработчик, который не боится покидать уютную хоббичью нору и покорять что-то новое для себя. В таком случае на конференции Joker (25-28 ноября, онлайн) наверняка будет интересное для вас можете сами посмотреть программу на сайте.
Подробнее..

Перевод 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 вопрос можно считать более или менее решённым, и Майкл обещает взяться за продолжение, как только появится время.
Подробнее..

Перевод Go Как использовать nil-значения без использования ссылочных типов

31.07.2020 00:18:01 | Автор: admin


Создано на базе изображений gopherize.me


Довольно часто из Go кода нам приходится работать с различными HTTP API или самим выполнять роль HTTP сервиса.


Один из частых случаев: получаем данные в виде структуры из базы данных, отправляем структуру внешнему API, в ответ получаем другую структуру, как-то её преобразуем и сохраняем в базу.


Другими словами: такая обработка не требует множества отдельных операций со структурами запроса и ответа.


Для API нормальна ситуация, когда в структурах запроса и ответа есть поля, которые могут быть nil и могут принимать какие-то не-nil значения. Такие структуры выглядят обычно так


type ApiResponse struct {  Code *string json:"code"`}

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


Что можно сделать в такой ситуации:


  • Изменить внешнее API так, чтобы не использовать nil значения. Иногда это допустимо, но изменение API не всегда хорошая идея: во-первых, это лишняя работа, во-вторых ошибки, которые могут появиться от такой переделки.
  • Изменить наш Go код так, чтобы мы могли принять nil значения, но не использую для этого ссылочные типы.

Для начала, давайте сравним разницу между работой со ссылочными типами и передачей переменных "по значению"


Все бенчмарки воспроизводимы и размещены здесь.


В Go коде мы обычно используем такие структуры со ссылочными типами


type pointerSmall struct { Field000 *string Field001 *string Field002 *string Field003 *string Field004 *string Field005 *string}

Давайте сравним их со структурами с типами, которые передаются по значению


type valueSmall struct { Field000 string Field001 string Field002 string Field003 string Field004 string Field005 string}

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


Небольшое замечание: в данном бенчмарке мы видим две механики Go, которые объясняют эти (для кого-то неожиданные) результаты.


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


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


BenchmarkPointerSmall-8    1000000000          0.295 ns/op        0 B/op        0 allocs/opBenchmarkValueSmall-8      184702404          6.51 ns/op        0 B/op        0 allocs/op

Давайте сделаем бенчмарк с вызовом функций и передачей туда структур со ссылками и указателями. Как мы видим, структуры со ссылками обрабатываются всё-ещё быстрее структур со значениями и по-прежнему с нулевым количеством аллокаций.


BenchmarkPointerSmallChain-8    1000000000          0.297 ns/op        0 B/op        0 allocs/opBenchmarkValueSmallChain-8      59185880         20.3 ns/op        0 B/op        0 allocs/op

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


BenchmarkPointerSmallJSON-8       49522      23724 ns/op    14122 B/op       28 allocs/opBenchmarkValueSmallJSON-8         52234      22806 ns/op    14011 B/op       15 allocs/op

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


BenchmarkPointerSmallEasyJSON-8       64482      17815 ns/op    14591 B/op       21 allocs/opBenchmarkValueSmallEasyJSON-8         63136      17537 ns/op    14444 B/op       14 allocs/op

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


Пойдём дальше. Иногда структуры растут в размере


type pointerBig struct { Field000 *string ... Field999 *string}type valueBig struct { Field000 string ... Field999 string}

Сделаем бенчмарк для этих структур. Далее мы видим, что для структуры со значениями обработка как и раньше даёт 0 аллокаций, но увеличилось время обработки (это нормально, т.к. структура стала больше). Так же, структура со ссылками потеряла преимущество: ненулевое количество аллокаций и значительно большее время обработки и использование памяти на одну операцию


BenchmarkPointerBig-8       36787      32243 ns/op    24192 B/op     1001 allocs/opBenchmarkValueBig-8        721375       1613 ns/op        0 B/op        0 allocs/op

Попробуем передать данные структуры через цепочку вызовов функций. Для структур со ссылками ничего не изменилось. Для структур со значениями незначительно выросло время обработки (но всё ещё меньше, чем для структуры со ссылками).


BenchmarkPointerBigChain-8       36607      31709 ns/op    24192 B/op     1001 allocs/opBenchmarkValueBigChain-8        351693       3216 ns/op        0 B/op        0 allocs/op

Попробуем сделать кодирование и декодирование. Структура со значениями лучше по всем параметрам


BenchmarkPointerBigJSON-8         250    4640020 ns/op  5326593 B/op     4024 allocs/opBenchmarkValueBigJSON-8           270    4289834 ns/op  4110721 B/op     2015 allocs/op

Попробуем улучшить результат, используя easyjson. Структура со значениями лучше во всём. Структура со ссылками обрабатывается лучше, чем в jsoniter.


BenchmarkPointerBigEasyJSON-8         364    3204100 ns/op  2357440 B/op     3066 allocs/opBenchmarkValueBigEasyJSON-8           380    3058639 ns/op  2302248 B/op     1063 allocs/op

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


Переключение на структуры со значениями


Переключение выглядит просто использовать Nullable типы. Пример из библиотеки sql sql.NullBool, sql.NullString и другие.


Так же, для типа потребуется описать функции кодирования и декодирования


func (n NullString) MarshalJSON() ([]byte, error) {    if !n.Valid {        return []byte("null"), nil    }    return jsoniter.Marshal(n.String)}func (n *NullString) UnmarshalJSON(data []byte) error {    if bytes.Equal(data, []byte("null")) {        *n = NullString{}        return nil    }    var res string    err := jsoniter.Unmarshal(data, &res)    if err != nil {        return err    }    *n = NullString{String: res, Valid: true}    return nil}

Как результат избавления от ссылочных типов в API я разработал библиотеку nan, с основными Nullable типами с функциями кодирования и декодирования для JSON, jsoniter, easyjson, gocql.


Удобство использования Nullable типов


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


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


При использовании ссылки мы пишем


if a != nil && *a == "sometext" {

С Nullable типом мы пишем


if a.Valid && a.String == "sometext" {
Подробнее..
Категории: Gc , Api , Go , Nil , Pointers

Категории

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

  • Имя: Макс
    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