Перейти к основному содержимому

Высокопроизводительный сборщик мусора для C++

· 9 мин. чтения
Антон Бикинеев, Омер Катц ([@omerktz](https://twitter.com/omerktz)), и Михаэль Липпаутц ([@mlippautz](https://twitter.com/mlippautz)), эксперты по памяти C++

В прошлом мы уже писали статьи о сборке мусора для JavaScript, объектной модели документа (DOM) и о том, как все это реализовано и оптимизировано в V8. Однако не весь Chromium написан на JavaScript — большая часть браузера и его движка рендеринга Blink, где встроен V8, написаны на C++. JavaScript используется для взаимодействия с DOM, который затем обрабатывается рендерингом.

Поскольку граф объектов C++ вокруг DOM тесно переплетен с объектами JavaScript, команда Chromium несколько лет назад переключилась на сборщик мусора под названием Oilpan для управления таким типом памяти. Oilpan — это сборщик мусора, написанный на C++ для управления памятью C++, который может быть сопряжен с V8 с использованием прослеживания между компонентами, рассматривающего запутанный граф объектов C++/JavaScript как единое множество.

Этот пост — первый в серии блогов о Oilpan, которые предоставят обзор ключевых принципов Oilpan и его API для C++. В этом посте мы рассмотрим некоторые поддерживаемые функции, объясним, как они взаимодействуют с различными подсистемами сборщика мусора, и углубимся в процесс одновременного восстановления объектов в очистителе.

Самое интересное, что Oilpan в настоящее время реализован в Blink, но переходит в V8 в виде библиотеки сборки мусора. Цель — сделать сборку мусора для C++ легко доступной для всех встраивателей V8 и большего числа разработчиков C++ в целом.

Фон

Oilpan реализует сборщик мусора Mark-Sweep, где сборка мусора разделена на две фазы: обозначение, где управляемая куча сканируется на наличие живых объектов, и очистка, где мертвые объекты в управляемой куче восстанавливаются.

Мы уже рассмотрели основы обозначения при введении одновременного обозначения в V8. Если кратко, сканирование всех объектов на наличие живых можно рассматривать как обход графа, где объекты — это узлы, а указатели между объектами — это ребра. Обход начинается с корней, которые представлены регистрами, стеком выполнения на нативном уровне (далее мы будем называть его стек) и другими глобальными переменными, как описано здесь.

C++ в этом аспекте не отличается от JavaScript. В отличие от JavaScript, объекты C++ имеют статическую типизацию и, следовательно, не могут изменять свое представление во время выполнения. Объекты C++, управляемые с использованием Oilpan, используют этот факт и предоставляют описание указателей на другие объекты (ребра в графе) через паттерн посетителя. Базовый шаблон для описания объектов Oilpan выглядит следующим образом:

class LinkedNode final : public GarbageCollected<LinkedNode> {
public:
LinkedNode(LinkedNode* next, int value) : next_(next), value_(value) {}
void Trace(Visitor* visitor) const {
visitor->Trace(next_);
}
private:
Member<LinkedNode> next_;
int value_;
};

LinkedNode* CreateNodes() {
LinkedNode* first_node = MakeGarbageCollected<LinkedNode>(nullptr, 1);
LinkedNode* second_node = MakeGarbageCollected<LinkedNode>(first_node, 2);
return second_node;
}

В приведенном выше примере LinkedNode управляется Oilpan, как указано наследованием от GarbageCollected<LinkedNode>. Когда сборщик мусора обрабатывает объект, он обнаруживает исходящие указатели, вызывая метод Trace объекта. Тип Member — это умный указатель, синтаксически похожий, например, на std::shared_ptr, который предоставляется Oilpan и используется для поддержания согласованного состояния при обходе графа во время обозначения. Все это позволяет Oilpan точно знать, где находятся указатели в его управляемых объектах.

Увлеченные читатели, вероятно, заметили и, возможно, испугались, что first_node и second_node хранятся в виде сырых указателей C++ в стеке в приведенном выше примере. Oilpan не добавляет абстракций для работы со стеком, полагаясь исключительно на консервативное сканирование стека для поиска указателей в управляемую кучу во время обработки корней. Это достигается путем итерации по стеку слово за словом и интерпретации этих слов как указателей в управляемую кучу. Это означает, что Oilpan не накладывает штрафов на производительность за доступ к объектам, выделенным в стеке. Вместо этого затраты переносятся на время сборки мусора, где стек сканируется консервативно. Oilpan, интегрированный в рендерер, старается откладывать сборку мусора до достижения состояния, где гарантированно отсутствует интересный стек. Поскольку веб основан на событиях, и выполнение управляется обработкой задач в циклах событий, такие возможности встречаются часто.

Oilpan используется в Blink, который является крупной кодовой базой на C++ с большим количеством зрелого кода и, следовательно, поддерживает:

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

Очистка для C++

Следите за отдельной статьей в блоге о том, как подробно работает маркировка в Oilpan. В этой статье мы предполагаем, что маркировка выполнена, и Oilpan обнаружил все доступные объекты с помощью их методов Trace. После маркировки все доступные объекты имеют установленный бит маркировки.

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

Очиститель находит мертвые объекты путем итерации над памятью кучи и проверки битов маркировки. Чтобы сохранить семантику C++, очиститель должен вызвать деструктор каждого мертвого объекта перед освобождением его памяти. Нетривиальные деструкторы реализуются как финализаторы.

С точки зрения программиста порядок выполнения деструкторов не определен, так как итерация, используемая очистителем, не учитывает порядок конструкций. Это накладывает ограничение, согласно которому финализаторы не могут обращаться к другим объектам в куче. Это распространенная задача для написания пользовательских кодов, которые требуют упорядоченности финализации, поскольку управляемые языки, как правило, не поддерживают порядок в семантике финализации (например, Java). Oilpan использует плагин Clang, который статически проверяет, среди прочего, что никаких объектов в куче не используется во время уничтожения объекта:

class GCed : public GarbageCollected<GCed> {
public:
void DoSomething();
void Trace(Visitor* visitor) {
visitor->Trace(other_);
}
~GCed() {
other_->DoSomething(); // ошибка: Финализатор '~GCed' обращается
// к потенциально финализированному полю 'other_'.
}
private:
Member<GCed> other_;
};

Для заинтересованных: Oilpan предоставляет обратные вызовы перед финализацией для сложных случаев использования, которые требуют доступа к куче до уничтожения объектов. Однако такие обратные вызовы накладывают больше нагрузки, чем деструкторы, на каждый цикл сборки мусора и используются редко в Blink.

Инкрементальная и конкурентная очистка

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

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

Начав с простого, изначально Oilpan реализовал очищение с остановкой мира, которое выполнялось как часть финализационной паузы сборки мусора, прерывая выполнение приложения в основном потоке:

Очищение с остановкой мира

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

Инкрементальное очищение

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

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

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

Оба инварианта гарантируют, что не должно быть конкурентов для объекта и его памяти. К сожалению, C++ сильно зависит от деструкторов, которые реализуются как финализаторы. Oilpan заставляет финализаторы выполняться на основном потоке, помогая разработчикам и исключая гонки данных в самом коде приложения. Чтобы решить эту проблему, Oilpan откладывает финализацию объектов на выполнение в основном потоке. Конкретнее, всякий раз, когда параллельный сборщик мусора сталкивается с объектом, имеющим финализатор (деструктор), он помещает его в очередь финализации, которая будет обработана в отдельной фазе финализации, всегда выполняемой на основном потоке, также работающем с приложением. Общий рабочий процесс с параллельной очисткой выглядит следующим образом:

Параллельная очистка с использованием фоновых задач

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

Результаты

Фоновая очистка была включена в Chrome M78. Наш фреймворк для тестирования производительности в реальном мире показывает сокращение времени очистки основного потока на 25%-50% (в среднем 42%). Основные данные показаны ниже.

Время очистки основного потока в миллисекундах

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

Следите за публикациями о сборке мусора в C++ в целом и обновлениями библиотеки Oilpan в частности, поскольку мы приближаемся к выпуску, который сможет использовать каждый пользователь V8.