February 27

Chromium. Отрисовка страницы с помощью Blink, CC и планировщика 

Движок Chromium от компании Google состоит из огромного числа внутренних механизмов, подсистем и других движков. В этой статье мы погрузимся в процесса компоновки и вывода Web-страницы непосредственно на экран. А так же, чуть ближе познакомимся с движком Blink, композитором (или, как его еще называют, сопоставитель контента) и планировщиком задач.

Парсинг Web-страницы

Давайте, для начала вспомним, как вообще происходит рендеринг Web-страницы.

Получив документ HTML, браузер производит его парсинг. Так как HTML изначально разрабатывался совместимым с традиционной структурой XML, никаких, интересных для нас особенностей на этом этапе нет. В результате парсинга браузер получает иерархическое дерево объектов - DOM (Document Object Model).

По мере прохождения по структуре HTML и парсинга его в DOM, браузер встречает такие элементы, как стили и JS-скрипты (как встроеные, так и в виде удаленных ресурсов). Такие элементы требуют дополнительной обработки. JS-скрипт парсится JavaScript-движком в структуру AST, и далее раскладывается в память в виде внутренних объектов самого движка. Стили же выстраиваются в каскадное дерево CSSOM. В это же дерево будут добавлены и inline-стили элементов.

Получив DOM и CSSOM, браузер теперь имеет возможность произвести все необходимые расчеты позиционирования и отображения элементов и, как результат, составить одно общее дерево Render Tree, на основании которого и будет производиться отрисовка графики непосредственно на экране.

Отрисовка Web-страницы

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

Blink

За всё, что связано с отрисовкой контента в табе браузера отвечает отдельная система под названием Blink. Вообще, Blink - это большой и сложный движок, в обязанности которого входят такие функции как реализация спецификации HTML в части DOM, CSS и Web IDL, интеграция движка V8 и запуск JavaScript-кода, отрисовка графики на экране (интеграция движка Skia), запрос сетевых ресурсов, обработка операций ввода, выстраивание деревьев (DOM, CSSOM, Render Tree), расчет стилей и позиционирования и еще много чего другого, включая Chrome Compositor (CC).

Сам движок не является "коробочным" решением и не может быть запущен самостоятельно. Это, своего рода, форк компонента WebCore движка WebKit.

Blink используется в таких платформах, как Chromium, Android WebView, Opera, Microsoft Edge и многих других Chromium-based браузерах.

Chrome Compositor (CC)

В кодовой базе Chromium этот механизм находится в директории сс/. Так исторически сложилось, что аббревиатуру СС расшифровывают как Chrome Compositor ("компоновщик"), хотя, на сегодняшний день, он компоновщиком, как таковым не является. Дана Дженсенс (danakj) даже предложила альтернативный вариант названия - Content Collator ("обобщитель" или "сопоставитель" контента).

СС запускается движком Blink и может работать как в однопоточном, так и в многопоточном режимах. Работа СС требует отдельной статьи, здесь мы не будем подробно разбирать все механики системы. Отметим только, что основными сущностями, которыми оперирует СС являются слои и деревья слоев. Эти сущности доступны из CC в качестве API. Слои (Layer) могут быть множества разных видов, например, слой изображения, слой текстур, поверхностный слой и др. Задача клиент CC (в нашем случае, клиентом для СС является Blink) собрать свое дерево слоев (LayerTreeHost) и сообщить СС о том, что оно готово и может быть отрисовано. Такой подход позволяет сделать процесс формирования итоговой композиции атомарным.

Принципиальная схема рендеринга

Chromium является многопоточным движком. Под множество конкретных операций, связанных с рендерингом, выделяются отдельные поток. Основными потоками, условно, можно считать main thread и compositor thread.

Main thread - основной поток, в котором работает Blink. Именно здесь составляются конечные RenderTree и LayerTreeHost. Здесь же обрабатываются сгруппированные операции ввода и исполняется JavaScript-код. После того, как все необходимые операции и расчеты произведены, Blink сообщит СС о том, что деревья готовы к отрисовке.

Compositor thread отвечает за работу CC, т.е. за планирование задач рендеринга и непосредственную отрисовку на экране.

Blink стремиться отображать графику с частотой 60 FPS (60 кадров в секунду), т.е. один кадр должен быть выведен на экран примерно за 16.6 ms. Такой фреймрейт считается оптимальным для восприятия человеческим глазом. Меньшая частота может приводить к неравномерной анимации, скачкам и дрожаниям графики.

На схеме выше изображена упрощенная схема рендеринга. Как я же упоминал, СС запускается в отдельном потоке. В определенный момент он решает, что наступило время инициировать рендеринг кадра. СС подает сигнал в главный поток о начале нового фрейма. Blink получает сигнал в главном потоке и выполняет необходимые запланированные операции, такие как пакетная обработка ввода, исполнение JavaScript-кода (точнее, JavaScript-задач из Event Loop) и обновление RenderTree. После того, как RenderTree готово, движок производит соответствующие изменения в LayerTreeHost (в его временной копии) и отправляет сигнал commit в СС, где тот, в свою очередь забирает все расчеты и отправляет задачи на отрисовку графики на конечном устройстве используя API соответствующих графических библиотек ОС (таких как OpenGL и DirectX).

На весь этот процесс отведено окно в 1000/60 = ~16.6 ms. Если движок не успеет выполнить все необходимые операции за это время, кадр будет задержан, что может привести к снижению фреймрейта. Поэтому, крайне важной задачей для Blink является подсчет и прогнозирование времени выполнения предстоящих задач. Зная, сколько времени займет та или иная операция, движок имеет возможность взять в работу только то, что успеет выполнить за отведенное время, остальные операции будут отложены до следующего раза.

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

Планировщик задач

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

Задачи внутри очереди выполняют в том порядке, в котором они туда попали (вспоминаем Event Loop). Однако, из какой именно очереди выполнить следующую задачу, выбирает планировщик, который волен решать это динамически, меняя приоритет очереди по своему усмотрению. Когда и какой установить приоритет, планировщик решает на основании получаемых сигналов от множества разных систем. Так, если страница находится еще на этапе загрузки, в приоритете будут сетевые запросы и парсинг HTML. А если зафиксировано событие touch, планировщик временно повысит приоритет операций ввода, в течение следующих 100 ms, дабы корректно распознать возможные жесты. Предполагается, что именно в этот временной интервал следующие возможные события могут оказаться операциями скролла, тапа, зума и т.д.

Обладая полной информацией об очередях и задачах в них, а так же о сигналах от других компонентов, планировщик имеет возможность рассчитать примерное время простоя системы. Чуть выше мы рассматривали пример рендеринга кадров. Вместе с сигналом от CC о начале отрисовки кадра посылается, так же, предполагаемое время следующего кадра, если кадр имеется (+16.6ms). Зная, есть ли необходимые для отрисовки кадра, операции ввода и какой JavaScript-код требуется выполнить, планировщик может прикинуть продолжительность выполнения этих задач. А зная время следующего кадра, может, так же, рассчитать время, так называемого простоя (Idle time). На самом деле, этот период является не совсем простоем. Он может быть использован для выполнения ряда низко-приоритетных задач (Idle tasks). Такие задачи помещаются в свою очередь и выполняются порциями, только после того, как другие очереди опустели, и в ограниченный промежуток времени. В частности, эту очередь активно использует сборщик мусора. Именно здесь происходит большая часть работы по маркировке мертвых объектов и дефрагментации памяти. Принудительно, с высоким приоритетом, консервативный сборщик запускается только в крайних случаях, например, при обнаружении нехватки памяти. Подробнее об этом поговорим в статье Сборка мусора в V8.

Регулярность частоты кадров

Я уже говорил, что Chromium стремиться достичь частоты кадров в 60 FPS. Для достижения этой цели движок оснащен планировщиком, СС и множеством других систем. Однако, на практике, добиться идеальной регулярности - задача практически невыполнимая, так как на процесс может влиять большое количество непредвиденных ситуаций, как внутренних (одна или несколько задач могут отрабатывать дольше, чем оценил планировщик), так и внешних (например, загруженность CPU или GPU другими процессами).

Примерно вот так выглядит скролл страницы https://www.google.com/chrome в трассировщике. Далеко не все кадры анимации смогли уложиться в отведенное окно 16.6 ms.

Более того, кроме задержек в отрисовке, часть кадров и вовсе может быть отклонена самим движком Blink. Такое нередко случается, например, во время анимации. Причин сброса кадров анимации существует целое множество. Blink предусматривает таких причин порядка двадцати (версия Chromium на момент написания статьи 124.0.6326.0)

/third_party/blink/renderer/core/animation/compositor_animations.h#65

enum FailureReason : uint32_t {
  kNoFailure = 0,
  
  // Cases where the compositing is disabled by an exterior cause.
  kAcceleratedAnimationsDisabled = 1 << 0,
  kEffectSuppressedByDevtools = 1 << 1,
  
  // There are many cases where an animation may not be valid (e.g. it is not
  // playing, or has no effect, etc). In these cases we would never composite
  // it in any world, so we lump them together.
  kInvalidAnimationOrEffect = 1 << 2,
  
  // The compositor is not able to support all setups of timing values; see
  // CompositorAnimations::ConvertTimingForCompositor.
  kEffectHasUnsupportedTimingParameters = 1 << 3,
  
  // Currently the compositor does not support any composite mode other than
  // 'replace'.
  kEffectHasNonReplaceCompositeMode = 1 << 4,
  
  // Cases where the target element isn't in a valid compositing state.
  kTargetHasInvalidCompositingState = 1 << 5,
  
  // Cases where the target is invalid (but that we could feasibly address).
  kTargetHasIncompatibleAnimations = 1 << 6,
  kTargetHasCSSOffset = 1 << 7,
  
  // This failure reason is no longer used, as multiple transform-related
  // animations are allowed on the same target provided they target different
  // transform properties (e.g. rotate vs scale).
  kObsoleteTargetHasMultipleTransformProperties = 1 << 8,
  
  // Cases relating to the properties being animated.
  kAnimationAffectsNonCSSProperties = 1 << 9,
  kTransformRelatedPropertyCannotBeAcceleratedOnTarget = 1 << 10,
  kFilterRelatedPropertyMayMovePixels = 1 << 12,
  kUnsupportedCSSProperty = 1 << 13,
  
  // This failure reason is no longer used, as multiple transform-related
  // animations are allowed on the same target provided they target different
  // transform properties (e.g. rotate vs scale).
  kObsoleteMultipleTransformAnimationsOnSameTarget = 1 << 14,
  
  kMixedKeyframeValueTypes = 1 << 15,
  
  // Cases where the scroll timeline source is not composited.
  kTimelineSourceHasInvalidCompositingState = 1 << 16,
  
  // Cases where there is an animation of compositor properties but they have
  // been optimized out so the animation of those properties has no effect.
  kCompositorPropertyAnimationsHaveNoEffect = 1 << 17,
  
  // Cases where we are animating a property that is marked important.
  kAffectsImportantProperty = 1 << 18,
  
  kSVGTargetHasIndependentTransformProperty = 1 << 19,
  
  // When adding new values, update the count below *and* add a description
  // of the value to CompositorAnimationsFailureReason in
  // tools/metrics/histograms/enums.xml .
  // The maximum number of flags in this enum (excluding itself). New flags
  // should increment this number but it should never be decremented because
  // the values are used in UMA histograms. It should also be noted that it
  // excludes the kNoFailure value.
  kFailureReasonCount = 20,
};

Из всего набора, наиболее частыми причинами являются отсутствие визуального эффекта анимации (kCompositorPropertyAnimationsHaveNoEffect), когда результат анимации не приводит к изменениям в графике и, соответственно, не требует перерисовки.

Так же, к сбросу кадра может привести не поддерживаемое CSS-свойство (kUnsupportedCSSProperty). Такое может случиться, если движок не понимает, как пересчитать то или иное свойство, даже если само это свойство выглядит вполне валидным.

<style>
  #block1 {
    animation: expand 1s linear infinite;
  }

  @keyframes expand {
    to {
      height: auto;
    }
  }
</style>

<div id="block1"></div>

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

В трассировщике, в этом случае мы нейдем запись, вроде

{"args":{"data":{"compositeFailed":8224,"unsupportedProperties":["height"]}},"cat":"blink.animations,...

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

Расхождение как показатель регулярности частоты кадров

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

В связи с этим, разработчики Google предложили свою методику оценки регулярности кадров. Метод основан на расхождении в последовательности длительностей кадров, по аналогии с математическим методом Monte Carlo integration.

Теоретическая база метода была представлена на 37-ой ежегодной конференции ACM SIGPLAN в 2016-м году.

На рисунке ниже представлен пример расхождения регулярности кадров.

Каждая линия представляет набор временных меток. Черными точками обозначены отрисованные кадры. Белыми - сброшенные. Расстояние между точками - 1 VSYNC, который равен 16.6 ms для 60-герцовой частоты обновления. Итоговые расхождения расчитываются в VSYNC-интервалах:

D(S1) = 1
D(S2) = 2
D(S3) = 2
D(S4) = 25/9
D(S5) = 3

В идеальном случае (S1) расхождение равно интервалу между кадрами (1 VSYNC). Если один из кадров был сброшен (S2), расхождение будет равно наибольшему расстоянию между двумя отрисованными кадрами. В случае S2 наибольшее расстояние будет между точками 2 и 3 и равно 2 VSYNC. Тоже самое будет и в случае, если имеется два сброшенных кадра, но далеко друг от друга (S3). Это обусловлено тем, что задача метода - определить наихудший случай производительности, а не средний, что отражено в расчетных формулах. Поэтому метод комбинируется со средней продолжительностью кадра, чтобы отличить один пропущенный кадр от серии повторяющихся пропусков (последнее, очевидно, хуже). В случае S4 мы наблюдаем два сброшенных кадра, которые находятся близко друг к другу. Такие кадры составляют уже единую потерянную область и расхождение здесь составить 25/9 (~2.7) VSYNC. Еще хуже дела обстоят в случае S5, так как между двумя сброшенными кадрами не было ни одного отрисованного. Наибольшее расстояние между отрисованными кадрами здесь будет между точками 2 и 4, что составляет 3 интервала (3 VSYNC).


Мои телеграмм-каналы:

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

English version: https://blog.frontend-almanac.com/chromium-rendering