Сборка мусора в V8

В этой статье мы детально разберем процесс сборки мусора движком V8. Познакомимся с понятиями поколений, Minor и Major Garbage Collection, посмотрим, как аллоцируются, трассируются и маркируются объекты в памяти. Что происходит с пустыми областями после очистки, и как выполняется сборка мусора в фоновом режиме. 

Что собирается сборщиком мусора

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

Во время работы V8 в памяти может находиться множество разных структур данных. Часть этих структур является внутренними представлениями самого V8, такими как скрытые классы (hidden classes), списки, очереди и многое другое. Другая часть — это объекты JavaScript. Практически все типы данных внутри V8, по факту являются объектами, за исключением так называемых SMI — маленьких чисел не более 2^30 - 1, они хранятся в памяти прямым бинарным значением, а все переменные, имеющее такое значение, хранят ссылку на область памяти с этим конкретным SMI-значением. Подробнее про это я писал в статье Глубокий JS. В память о типах и данных. Все остальные типы представлены в памяти в виде объектов и имитируют свою мутабельность/иммутабельность в соответствии со спецификацией ECMA-262, выдавая, как мы привыкли говорить, «примитивные» или «ссылочные» типы.

Вообще, процесс сборки мусора не ограничивается объектами JavaScript. Движок Chromium предусматривает сборку всего, что может вообще быть собрано, будь то удаленные элементы HTML, не используемые таблицы стилей CSS и ресурсы, завершенная анимация и многое другое. Так как Chromium написан на языке C++, его внутренние объекты движка являются, очевидно, классами C++. Однако, объекты JavaScript, хоть и созданы средствами того же языка, являются более абстрактными понятиями и должны реализовывать требования спецификации ECMA-262. В частности, объекты JavaScript не имеют фиксированного размера и могу меняться динамически. А куча JavaScript, по своей структуре отличается от кучи C++. Как такие объекты размещаются в памяти посмотрим чуть ниже. Сейчас важно понять, что процесс сборки мертвых объектов JavaScript будет несколько расходиться со сборкой объектов C++.

Несколько лет назад, приблизительно в 2018-м году команда Chromium начала создавать систему сборки мертвых объектов C++. Система получила название Oilpan и стала частью внутреннего движка рендеринга Blink. Позже, в 2020-м, разработчики решили перенести систему внутрь движка V8, так как, с одной стороны, V8 использует те же механизмы сборки мусора, что и Blink, с другой, перенос в V8 делает систему доступной для сторонних разработчиков, которые интегрируют движок V8, но не используют Blink, например, Node.js. В результате, в API V8 появилась директория cppgc.

Трассировка объектов

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

acec6e60dbeddd3d13dbfa574c34e4f1.png

На рисунке выше изображена упрощенная схема трассировки. Ссылки на все созднанные объекты хранятся в отдельной глобальной таблице GlobalGCInfoTable. В время работы сборщика мусора, происходит итеративный проход объектов от корня (корней может быть несколько, проход осуществляется по всем активным корням). Объекты, до которых смог дотянуться сборщик мусора, будут считаться живыми, они обозначены черным цветом. Все остальные объекты являются «мертвыми» (обозначены белым цветом).

Трассировка объектов C++

Чуть выше я говорил, то объекты C++ отличаются от объектов JavaScript. Дело в том, что объекты JavaScript стандартизированы и описаны в разделе 6.1.7 The Object Type спецификации. Что делает их предсказуемыми для сборщика мусора. Объекты же C++ не стандартизированы и их структуры могут сильно отличаться. Более того, не каждый объект C++ должен быть потенциально очищаемый. В связи с этим, для объектов C++ в Oilpan был предусмотрен специальный API.

В блоге движка V8 этому был посвящен отдельный пост High-performance garbage collection for C++. Не буду пересказывать весь пост, обозначу только основную концепцию. Объекты C++, которые хотят быть очищаемыми сборщиком мусоры, должны реализовывать интерфейсы GarbageCollected или GarbageCollectedMixin. Фактически, это означает, что объект должен иметь метод Trace (), в котором, сам же этот объект должен вызвать трассировку других объектов, на которые ссылается.

Например, вот так выглядит трассировка класса RunningAnimation (на момент написания статьи, версия Chromium 124.0.6339.1):

src/third_party/blink/renderer/core/animation/css/css_animations.h

class RunningAnimation final : public GarbageCollected {
 public:
  RunningAnimation(Animation* animation, NewCSSAnimation new_animation)
      : animation(animation),
        name(new_animation.name),
        name_index(new_animation.name_index),
        specified_timing(new_animation.timing),
        style_rule(new_animation.style_rule),
        style_rule_version(new_animation.style_rule_version),
        play_state_list(new_animation.play_state_list) {}

  AnimationTimeline* Timeline() const {
    return animation->TimelineInternal();
  }
  const std::optional& RangeStart() const {
    return animation->GetRangeStartInternal();
  }
  const std::optional& RangeEnd() const {
    return animation->GetRangeEndInternal();
  }

  void Update(UpdatedCSSAnimation update) {
    DCHECK_EQ(update.animation, animation);
    style_rule = update.style_rule;
    style_rule_version = update.style_rule_version;
    play_state_list = update.play_state_list;
    specified_timing = update.specified_timing;
  }

  void Trace(Visitor* visitor) const {
    visitor->Trace(animation);
    visitor->Trace(style_rule);
  }

  Member animation;
  AtomicString name;
  size_t name_index;
  Timing specified_timing;
  Member style_rule;
  unsigned style_rule_version;
  Vector play_state_list;
};

Здесь мы видим, что класс реализует интерфейс GarbageCollected, а его метод Trace запускает трассировку внутренних объектов animation и style_rule.

Трассировка объектов JavaScript.

В отличие от объектов C++ и системы Oilpan, про объекты JavaScript официальных публикаций нет. Поэтому, не редко, трассировку объектов JavaScript ошибочно ставят в один ряд с объектами C++. На самом же деле, объекты JavaScript полностью контролируются движком V8 и живут в специальной куче V8. А значит, нет никакой необходимости в реализации дополнительного, управляемого из вне, API. Каждый объект JavaScript является наследником класса HeadObject, который имеет Iterate,  IterateFast,  IterateBody и IterateBodyFast. А сама куча имеет метод Visit, который запускает трассировку по объектам в этой куче (на момент написания статьи, версия V8 12.4.147)

src/objects/heap-object.h#204

// HeapObject is the superclass for all classes describing heap allocated
// objects.
class HeapObject : public TaggedImpl {
 public:
  
  ...
  
  // Iterates over pointers contained in the object (including the Map).
  // If it's not performance critical iteration use the non-templatized
  // version.
  void Iterate(PtrComprCageBase cage_base, ObjectVisitor* v);

  template 
  inline void IterateFast(PtrComprCageBase cage_base, ObjectVisitor* v);

  template 
  inline void IterateFast(Tagged map, ObjectVisitor* v);

  template 
  inline void IterateFast(Tagged map, int object_size, ObjectVisitor* v);

  // Iterates over all pointers contained in the object except the
  // first map pointer.  The object type is given in the first
  // parameter. This function does not access the map pointer in the
  // object, and so is safe to call while the map pointer is modified.
  // If it's not performance critical iteration use the non-templatized
  // version.
  void IterateBody(PtrComprCageBase cage_base, ObjectVisitor* v);
  void IterateBody(Tagged map, int object_size, ObjectVisitor* v);

  template 
  inline void IterateBodyFast(PtrComprCageBase cage_base, ObjectVisitor* v);

  template 
  inline void IterateBodyFast(Tagged map, int object_size,
                            ObjectVisitor* v);
  
  ...
} 

Поколения сборки мусора

Процедура нахождения мертвых объектов и их очистка, сама по себе, может быть весьма не быстрой. В современных приложениях в памяти могут находиться тысячи объектов, а их размер может превышать 1 Gb. Если речь идет о сборке объектов C++, работу можно переложить в отдельные потоки и запускать по требованию. Однако, если мы говорим о JavaScript, будучи однопоточным и не имеющим механизма синхронизации между потоками, при выполнении полной трассировки объектов на регулярной основе, разного рода задержки и снижение производительности, практически гарантированы. С этого момента и далее будем говорить о реализации сборки мусора именно JavaScript-объектов.

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

Существует гипотеза, под названием infant mortality или generational hypothesis, согласно которой, в большинстве случаев, молодые объекты умрут раньше с большей вероятности, чем старые.

Так, в V8 появилось понятие «поколение» (generation). Существуют «молодое поколение» (young generation) и «старое поколение» (old generation).

Куча, условно разбивается на маленькие young generations (до 16 Mb), куда попадают все только что аллоцированные объекты. Old generation предназначена для старых объектов и может быть размером до 1.4 Gb.

Дополнительно, оба поколения организованы в, так называемые страницы (pages) по 1 Mb. Объекты больше 600 Кb размещаются в отдельных страницах и считаются частью old generation.

Old generation, так же, включает в себя code space со всеми исполняемыми объектами кода и map space с вовлеченными скрытыми классами (hidden classes).

Полу-пространственное размещение объектов в young generation

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

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

Итак, young generation делится на два полу-пространства (semi-space), активное и неактивное. Все новые объекты изначально размещаются в текущем активном полу-пространстве используя метод bump-pointer.

d7e08f092b4b660b4eebf7f82ab0c48f.png

Метод заключается в том, что в куче всегда есть указатель на текущую свободную область. При создании нового объекта, этот указатель будет являться началом объекта. Конец объекта определяется простым подсчетом его размера. Определив размер и, соответственно конец объекты, указатель смещается на следующий свободный адрес.

Как только полупространство полностью заполнилось, в работу вступает механизм Scavanger. Его задача — пройтись по живым объектам и переместить их в новое, неактивное полупространство. Эта операция называется Minor Garbage Collection (второстепенная сборка мусора).

После этого два полу-пространства меняются местами. Текущее активное полупространство становится нееактивным, а неактивное очищается и становится активным. Таким образом, уже со второй итерации в неактивном полу-пространстве могу оставаться ссылки на живые объекты. При следующем выполнении Minor Garbage Collection, если объекты уже были один раз помещены во второе полупространство — они считаются старыми и перемещаются в old generation, а мертвые — удаляются.

d8896418f8d1ae13a530b0d55df4cf07.png

Продолжительность Minor Garbage Collection зависит от количество живых объектов в young generation. Если все объекты являются мертвыми, процесс займет меньше 1 ms. Однако, если все или большинство объектов живые — значительно дольше. 

Old generation

Old generation тоже использует метод bump-pointer для размещения объектов, а указатели хранятся отдельных сводных таблицах, по аналогии с той, что я приводил в начале статьи.

Это поколение очищается другим процессом, под названием Major Garbage Collection (основная сборка мусора). Запускается он, когда размер живых объектов в old generation превышает эвристически рассчитанный предел.

Для уменьшения задержки и потребления памяти,  old generation использует метод mark-sweep-comparator. Задержка во время маркировки зависит от количества живых объектов, которые должны быть промаркированы. Маркировка всей кучи может занимать до 100 ms для больших Web-страниц. Чтобы избежать этого, V8 маркирует объекты маленькими порциями, такими, чтобы каждый шаг не превышал 5 ms.

Сама схема называется «трехцветная маркировка» (tricolor marking scheme). Более подробно про процесс маркировку можно почитать в после Concurrent marking in V8 блога V8. Опять же, пересказывать пост полностью смысла не имеет. Обозначу общая суть процесса. Каждый объект находится в одном из трех статусов, условно обозначенных цветами. На самом деле, статус объекта — это 2-х битное поле, где

  • 00 — белый — исходный статус всех новых объектов. Он означает, что объект еще небыл обнаружен сборщиком.

  • 01 — серый — в этот статус объект переходит сразу, как только сборщик до него добрался. Такой объект встает в список на дальнейшую трассировку.

  • 11 — черный — конечный статус, означает, что сборщик посетил все дочерние узлы объекта и его можно убрать из списка трассировки.

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

Однако, здесь имеется одна проблема. Между шагами маркировки выполняется JavaScript код, который может добавить новые объекты или удалить старые. Что делает статус уже промаркированных объектов неактуальным. Чтобы решить эту проблему, движок должен сообщать сборщику обо всех изменения в дереве объектов. Делается это посредством, так называемого write-barrier. В блоге V8 приводится пример реализации такого барьера.

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

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

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

src/heap/marking-barrier-inl.h#64

void MarkingBarrier::MarkValueShared(Tagged value) {
  // Value is either in read-only space or shared heap.
  DCHECK(InAnySharedSpace(value));
  // We should only reach this on client isolates (= worker isolates).
  DCHECK(!is_shared_space_isolate_);
  DCHECK(shared_heap_worklist_.has_value());
  // Mark shared object and push it onto shared heap worklist.
  if (marking_state_.TryMark(value)) {
    shared_heap_worklist_->Push(value);
  }
}

void MarkingBarrier::MarkValueLocal(Tagged value) {
  DCHECK(!InReadOnlySpace(value));
  if (is_minor()) {
    // We do not need to insert into RememberedSet here because the
    // C++ marking barrier already does this for us.
    // TODO(v8:13012): Consider updating C++ barriers to respect
    // POINTERS_TO_HERE_ARE_INTERESTING and POINTERS_FROM_HERE_ARE_INTERESTING
    // page flags and make the following branch a DCHECK.
    if (Heap::InYoungGeneration(value)) {
      WhiteToGreyAndPush(value);  // NEW->NEW
    }
  } else {
    if (WhiteToGreyAndPush(value)) {
      if (V8_UNLIKELY(v8_flags.track_retaining_path)) {
        heap_->AddRetainingRoot(Root::kWriteBarrier, value);
      }
    }
  }
}

...

bool MarkingBarrier::WhiteToGreyAndPush(Tagged obj) {
  if (marking_state_.TryMark(obj)) {
    current_worklist_->Push(obj);
    return true;
  }
  return false;
}

После того, как весь граф объектов был промаркирован, те объекты, до которых дотянулся сборщик оказываются помеченными черным статусом. Все остальные остаются белыми. Как и в случае с Minor Garbage Collection, живые объекты перемещаются в новое полу-пространство или в новую страницу old generation.

Продолжительнось Major Garbage Collection линейна и зависит от количества живых объектов в old generation. Используя пошаговый алгоритм сборки мусора, V8 старается держать время работы Major Garbage Collection в пределах 6 ms.

Фрагментация страницы

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

4005456e5b643dec05c76b9192c8be01.png

На рисунке выше изображены примеры высоко-фрагментированной (слева) и низко-фрагментированной (справа) страниц. Движок V8 проводит оценку степени фрагментации и принимает решение о том, что делать дальше со страницей.

Compaction

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

Sweeping

При низкой фрагментации, между живыми объектами остаются сравнительно большие области. Вместо того, чтобы копировать и компоновать объекты, планировщик просто помещает адреса объединенных мертвых областей в специальный список free-list. Позже, когда движку понадобится аллоцировать новый объект в памяти, он, в первую очередь заглянет в free-list, и если там есть подходящее, место разместит новый объект в нем. Этот процесс и называется Sweeping.

Планирование Idle-задачь

В статье Chromium. Отрисовка страницы с помощью Blink, CC и планировщика я подробно описывал процесс рендеринга Web-страницы и планировщик задач. Давайте коротко вспомним, о чем шла речь.

Движок Blink запускает процесс CC в отдельном потоке compositor thread. CC получает сигналы от разных систем Chromium и решает когда и что будет выводиться на экран. Если, например, инициировано начало анимации или скролл, СС посылает в главный поток сигнал о начале нового фрейма. Вместе с этим сигналом передается и дедлайн — время, до которого фрейм должен быть сформирован. Так как Chromium старается держать фреймрейт в районе 60 FPS, на один кадр приходится 1000/60 = ~16.6 ms. Ровно столько времени есть у Blink, чтобы поочередно выполнить задачи по обработке пользовательского ввода, части JavaScript и других приоритетных задач и обновить RenderTree. Если Blink не уложиться в заданное время, кадр будет задержан, что может сказать на визуальном пользовательском опыте (могут появиться скачки анимации, дрожание и т.д.). Однако, зачастую, движку требуется гораздо меньше времени. При маленьком количестве задач Chromium может справить с кадром за время <1 ms.

Оставшееся неиспользованное время называется временем простоя (Idle time). Однако,  idle time, на самом деле, не совсем простой. Это тоже задача, такая же как и остальные задачи в очереди, только с низким приоритетом. Длительность этой задачи равно времени, оставшемуся до начала формирования следующего кадра. Если следующего кадра не предвидится,  idle time выставляется в 50 ms. Число выбрано в связи с исследованиями, которые показывают, что ответ на пользовательский ввод в течение 100 ms воспринимается пользователем как мгновенный. Если по окончании 50 ms новых кадров не появилось, в очередь ставится новый idle time и так далее. Однако, если поступит сигнал о начале нового кадра,  idle time будет прерван, не дожидаясь окончания времени.

Когда стартует idle time, могут начать выполняться задачи из специальной низкоприоритетной очереди. Такие задачи называются idle tasks. Чтобы не превысить время дедлайна, в задачу передается время окончания idle time. Ответственность самой задачи — быть завершенной до указанного срока. Задача может выполнить часть работы, которая уложиться в отведенное время, а оставшуюся часть — поставить в очередь новой задачей. Аналогично, если задача не может выполнить сколько-нибудь значимую работу за указанное время, она должна переставить себя в очередь новой задачей.

Idle tasks — прерогатива не только движка V8. Web-разработчику тоже доступен этот механизм. Браузерами предусмотрен метод Web API — requestIdleCallback, который ставит задачу в очередь idle tasks. Правда, на момент написания статьи, метод еще находится в процессе проработки и не поддерживается браузером Safari.

Планирование Garbage Collection для времени простоя

Выше мы говорили, что процесс сборки мусора может быть довольно нагрузочным. Чтобы не блокировать систему этой утилитарной операцией, V8 разбивает весь процесс на мелкие шаги, на каждый шаг, все равно может занимать до 5 ms процессорного времени. Чтобы сделать процесс максимально незаметным для всей остальной системы, V8 ставит задачи по сборке мусора именно в очередь idle tasks.

Minor Garbage Collection не может быть разбито на части о должна выполниться целиком за один раз.

Major Garbage Collection состоит из трех частей:

  • начало пошаговой маркировки (incremental marking)

  • несколько шагов маркировки

  • завершение (finalization)

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

Запуск Minor Garbage Collection сокращает время задержки, но имеет сравнительно небольшое влияние на потребляемую память. Тем не менее, регулярное выполнение Minor Garbage Collection позволяет предотвратить попадание объектов в стадию консервативной сборки мусора, которая выполняется вне времени простоя. Консервативная сборка мусора может быть запущена в критических ситуациях, например, при обнаружении нехватки выделенной памяти.

Таким образом, планирование должно планировщик должен балансировать между стартом слишком рано (и, как следствие, продвижению объектов в old generation слишком рано) и слишком поздно, что может привести к разрастанию размера young generation.

Планирование Minor Garbage Collection для времени простоя

Для осуществления Minor Garbage Collection требуется:

  1. предикат, который скажет, можно ли выполнить Minor Garbage Collection в установленное время

  2. механизм постановки idle task в Blink-планировщик для запуска в нужное время

V8 выполняет idle-time Minor Garbage Collection, тольк если её ожидаемое время выполнения укладывается в дедлайн пекущего idle-пероида и, при этом, есть достаточное количество объектов в young generation.

Пусть H — общий размер объектов в байтах в young generation.

S' — средняя скорость обработки предыдущих Minor Garbage Collections в байтах в секунду

T — дедлайн текущего idle-периода в секундах

T' — средний дедлайн idle task в секундах

V8 выполнит Minor Garbage Collection, если:

max(T'  S' - N, Hmin) < H <= S'  T

где N — ожидаемое количество байт, которые будут аллоцированы перед следующей idle task

Hmin — минимальный размер young generation, гарантирующий сборку мусора

(T' * S' - N) < H можно переписать, как (T' * S') < (H + N), что оценивает, будет ли следующий Minor Garbage Collection больше среднего дедлайна idle-задачи. Если больше — Minor Garbage Collection должна быть выполнена в текущем idle-периоде. Если меньше — в young generation пока еще недостаточно объектов для эффективной работы.

В целях запроса idle time для Minor Garbage Collection от Blink-планировщика, V8 размещает аварийные маркеры каждые N байт в новом generation. Когда очередная аллокация пересекает аварийный маркер, она вызывает runtime-функцию V8, которая публикует idle task в Blink-планировщик.

Эксперементальным путем установлено, что N = 512 Kb достаточно, чтобы не снижать пропускную способность, при этом достаточно мало, чтобы V8 имел несколько возможностей опубликовать idle task.

Планирование Major Garbage Collection для времени простоя

Процесс запуска пошаговой маркировки мы рассмотрим чуть ниже. Пока представим, что она уже запущена. Сразу, как только произошел запуск, V8 ставит idle task в Blink-планировщик. Задача выполняет, непосредственно, сам процесс маркировки.

Взяв за основу среднюю скорость маркировки,  idle task пытается достигнуть максимального объема работы, возможного в текущем idle-периоде. 

Пусть Tidle — дедлайн idle-задачи в секундах,

M — скорость маркировки в байтах в секунду.

Тогда,

Tidle * M — количество байт, которые будут промаркированы в этой idle task.

Idle task итеративно переставляет себя в очередь очередь до тех пор вся Heap не будет промаркирована. После этого V8 ставит задачу на завершение Major Garbage Collection. Процесс завершения будет выполнен, если оценочное время этой задачи укладывается в дедлайн. V8 определяет время процесса завершения на основе скорости предыдущих задач на завершение.

Memory Reducer

Memory reducer — это контроллер, который пытается высвободить память в неактивных Web-страницах.

Major Garbage Collector включается в работу, когда куча достигает определенного заданного размера. Этот размер рассчитывается и выставляется в конце предыдущей Major Garbage Collection, на основе коэффициента увеличения кучи f и общего размера живых объектов в old generation

limit' = f * size

Следующая Major Garbage Collection ставится в очередь, когда количество байт, аллоцированных с момента предыдущей сборки, превысит этот предельный размер limit' - size.

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

Зачастую, многие Web-страницу демонстрируют высокую интенсивность выделения памяти в момент загрузки, посколько они инициализируют свои внутренние структуры данных. Через несколько секунд или минут после загрузки, Web-страница часто становится неактивной, при этом в памяти находится большое количество объектов, которые уже не нужны. Что может приводить к снижению скорости выделения памяти и, как следствие, к снижению скорости выполнения JavaScript кода.

fe62261ab0d4c892f2e574fd0b4228dc.png

На диаграмме выше приведен пример планирования Major Garbage Collection. Первая сборка случается в момент времени t1, так как был достигнут предельный размер кучи. V8 устанавливает новое значение предела на основе коэффициента увеличения кучи и размера кучи. Следующий вызов Major Garbage Collectionпроизойдет в момент t2, когда будет достигнут новый предел, потом, в момент t3 и так далее. Пунктирной линией показан размер кучи, каким бы он был без memory reducer.

Таким образом, memory reducer может запустить Major Garbage Collection, не дожидаясь лимита аллоцирования. Однако, бесконтрольное снижение предела может привести к ухудшению показателя задержки. Поэтом команда V8 разработала эвристический метод, который полагается не только на idle time, предоставленным планировщиком Blink между кадрами, но и на то, является ли страница неактивной в данный момент. Индикатором того, активна страница или нет, являются скорость аллоцирования JavaScript и частота вызовов JavaScript из браузера. Когда обе скорости падают ниже заданного порога, страница считается неактивной.

37d7886228f8a67dea753436a98e3426.png

На рисунке показаны состояния и переходы memory reducer. В состоянии done контроллер ожидает сигнала о том, что было выполнено достаточное количество выделений, чтобы гарантировать запуск сборщика мусора. В этот момент применяется, так называемый non-idle Major Garbage Collection, который запускается по достижении лимита выделения, что является сигналом для перехода в состояние wait(i). В этом состоянии контроллер ждет, когда Web-страница станет неактивной. Когда это происходит, запускает пошаговая марировка и переход в состояние run(i), до тех пор, пока Major Garbage Collection не завершит работу. Далее, если расчеты показывают, что еще одна Major Garbage Collection, вероятно, позволит высвободить еще памяти, контроллер переходит в состояние wait (i+1). В противном случае, он вернется в исходное состояние done.

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

Пусть g — скорость  Major Garbage Collection,

a — частота аллоцирования.

Тогда

μ = g/(g+a)

Соотношение μ можно рассматривать как использование мутатора во временном интервала с настоящего момента до следующей Major Garbage Collection. Предполагая, что текущая частота аллоцировани остается постоянной и куча в данный момент пуста:

Tmu = limit/a               (mutator time)

  Tgc = limit/g             (GC time)
  
    μ = Tmu /(Tmu + Tgc )   (mutator utilization)
    
      = (limit /a)/(limit /a + limit /g)
      
      = g/(g + a)

Это дает нам условие для неактивной скорости распределения:  μ ≥ µinactive , где µinactive — фиксированное значение.

В коде V8 µinactive имеет значение 0.993.

bool Heap::HasLowOldGenerationAllocationRate() {
  double mu = ComputeMutatorUtilization(
      "Old generation",
      tracer()->OldGenerationAllocationThroughputInBytesPerMillisecond(),
      tracer()->CombinedMarkCompactSpeedInBytesPerMillisecond());
  const double kHighMutatorUtilization = 0.993;
  return mu > kHighMutatorUtilization;
}

Эту и другие мои статьи, так же, читайте в моем канале:

RU:  https://t.me/frontend_almanac_ru
EN:  https://t.me/frontend_almanac

© Habrahabr.ru