RenderingNG im Detail: BlinkNG

Stefan Zager
Stefan Zager
Chris Harrelson
Chris Harrelson

Blink bezieht sich auf die Chromium-Implementierung der Webplattform und umfasst alle Renderingphasen vor dem Compositing, die im Compositor-Commit gipfeln. Weitere Informationen zur Blink-Rendering-Architektur finden Sie in einem früheren Artikel dieser Reihe.

Blink war ursprünglich ein Fork von WebKit, das selbst ein Fork von KHTML aus dem Jahr 1998 ist. Es enthält einige der ältesten (und wichtigsten) Codeteile in Chromium und war 2014 definitiv in die Jahre gekommen. In diesem Jahr haben wir eine Reihe ehrgeiziger Projekte unter dem Namen BlinkNG gestartet, um langjährige Mängel in der Organisation und Struktur des Blink-Codes zu beheben. In diesem Artikel geht es um BlinkNG und die zugehörigen Projekte: warum wir sie durchgeführt haben, was sie erreicht haben, die Leitprinzipien, die ihr Design geprägt haben, und die Möglichkeiten für zukünftige Verbesserungen.

Die Rendering-Pipeline vor und nach BlinkNG

Rendering vor NG

Die Rendering-Pipeline in Blink war immer konzeptionell in Phasen unterteilt (Stil, Layout, Paint usw.), aber die Abstraktionsbarrieren waren löchrig. Die mit dem Rendering verbundenen Daten bestanden im Allgemeinen aus langlebigen, veränderlichen Objekten. Diese Objekte konnten und wurden jederzeit geändert und wurden häufig durch aufeinanderfolgende Rendering-Aktualisierungen recycelt und wiederverwendet. Es war unmöglich, einfache Fragen wie die folgenden zuverlässig zu beantworten:

  • Muss die Ausgabe von Stil, Layout oder Farbe aktualisiert werden?
  • Wann erhalten diese Daten ihren „endgültigen“ Wert?
  • Wann ist es in Ordnung, diese Daten zu ändern?
  • Wann wird dieses Objekt gelöscht?

Beispiele:

Style generierte ComputedStyles basierend auf Stylesheets. ComputedStyle war jedoch nicht unveränderlich; in einigen Fällen wurde es durch spätere Pipeline-Phasen geändert.

Style würde einen LayoutObject-Baum generieren und dann würden diese Objekte mit Informationen zu Größe und Position durch layout kommentiert. In einigen Fällen wird durch das Layout sogar die Struktur des Stammbaums geändert. Es gab keine klare Trennung zwischen den Eingaben und Ausgaben von layout.

Style generierte zusätzliche Datenstrukturen, die den Verlauf der Komposition bestimmten. Diese Datenstrukturen wurden in jeder Phase nach Style vor Ort geändert.

Auf einer niedrigeren Ebene bestehen Rendering-Datentypen hauptsächlich aus speziellen Bäumen (z. B. dem DOM-Baum, dem Stilbaum, dem Layoutbaum und dem Baum der Maleigenschaften). Die Rendering-Phasen werden als rekursive Baumdurchläufe implementiert. Im Idealfall sollte ein Baumdurchlauf eingeschränkt sein: Bei der Verarbeitung eines bestimmten Baumknotens sollten wir nicht auf Informationen außerhalb des untergeordneten Baums zugreifen, der in diesem Knoten verwurzelt ist. Das war vor RenderingNG nie der Fall. Bei Baumumläufen wurden häufig Informationen von den Vorfahren des zu verarbeitenden Knotens abgerufen. Das machte das System sehr empfindlich und fehleranfällig. Außerdem war es nicht möglich, einen Baumspaziergang an einer anderen Stelle als an der Wurzel des Baums zu beginnen.

Außerdem gab es im Code viele Einstiegspunkte in die Rendering-Pipeline: erzwungene Layouts, die von JavaScript ausgelöst wurden, teilweise Aktualisierungen, die beim Laden des Dokuments ausgelöst wurden, erzwungene Aktualisierungen zur Vorbereitung auf das Ereignis-Targeting, geplante Aktualisierungen, die vom Displaysystem angefordert wurden, und spezielle APIs, die nur für Testcode freigegeben waren, um nur einige zu nennen. Es gab sogar einige rekursiv und reentrant Pfade in der Rendering-Pipeline, d. h. Sprünge von der Mitte einer Phase zum Anfang einer anderen. Jede dieser On-Ramps hatte ein eigenes Verhalten und in einigen Fällen hing das Rendering-Ergebnis davon ab, wie die Rendering-Aktualisierung ausgelöst wurde.

Was wir geändert haben

BlinkNG besteht aus vielen großen und kleinen Teilprojekten, die alle das gemeinsame Ziel haben, die oben beschriebenen architektonischen Mängel zu beseitigen. Diese Projekte haben einige gemeinsame Leitprinzipien, die die Rendering-Pipeline zu einer echten Pipeline machen sollen:

  • Einheitlicher Einstiegspunkt: Wir sollten die Pipeline immer am Anfang betreten.
  • Funktionelle Phasen: Jede Phase sollte klar definierte Eingaben und Ausgaben haben und ihr Verhalten sollte funktional sein, d. h. deterministisch und wiederholbar. Die Ausgaben sollten nur von den definierten Eingaben abhängen.
  • Konstante Eingaben: Die Eingaben einer Phase sollten während der Ausführung der Phase praktisch konstant sein.
  • Unveränderliche Ausgaben: Sobald eine Phase abgeschlossen ist, sollten ihre Ausgaben für den Rest des Rendering-Updates unveränderlich sein.
  • Checkpoint-Konsistenz: Am Ende jeder Phase sollten die bisher erstellten Rendering-Daten in einem konsistenten Zustand sein.
  • Deduplizierung von Arbeitsschritten: Jedes Element wird nur einmal berechnet.

Eine vollständige Liste der BlinkNG-Unterprojekte wäre langweilig zu lesen, aber im Folgenden finden Sie einige besonders wichtige.

Der Dokumentenlebenszyklus

Die Klasse DocumentLifecycle überwacht den Fortschritt durch die Rendering-Pipeline. So können wir grundlegende Prüfungen durchführen, die die oben aufgeführten Invarianten erzwingen, z. B.:

  • Wenn wir eine ComputedStyle-Eigenschaft ändern, muss der Dokumentlebenszyklus kInStyleRecalc sein.
  • Wenn der Status des Dokumentlebenszyklus kStyleClean oder höher ist, muss NeedsStyleRecalc() für jeden angehängten Knoten false zurückgeben.
  • Wenn die Lebenszyklusphase paint (Malen) erreicht wird, muss der Lebenszyklusstatus kPrePaintClean sein.

Bei der Implementierung von BlinkNG haben wir Codepfade, die gegen diese Invarianten verstoßen, systematisch entfernt und viele weitere Behauptungen in den Code eingefügt, um Regressionen zu vermeiden.

Wenn Sie sich schon einmal mit Low-Level-Rendering-Code beschäftigt haben, fragen Sie sich vielleicht: „Wie bin ich hierher gekommen?“ Wie bereits erwähnt, gibt es verschiedene Einstiegspunkte in die Rendering-Pipeline. Bisher waren dazu rekursive und reentrante Aufrufpfade sowie Stellen in der Pipeline zu zählen, an denen wir nicht von Anfang an, sondern in einer Zwischenphase gestartet sind. Im Rahmen von BlinkNG haben wir diese Aufrufpfade analysiert und festgestellt, dass sie alle auf zwei grundlegende Szenarien reduziert werden können:

  • Alle Renderingdaten müssen aktualisiert werden, z. B. wenn neue Pixel für die Anzeige generiert oder ein Treffertest für das Ereignis-Targeting durchgeführt wird.
  • Wir benötigen einen aktuellen Wert für eine bestimmte Abfrage, der beantwortet werden kann, ohne alle Rendering-Daten zu aktualisieren. Dazu gehören die meisten JavaScript-Abfragen, z. B. node.offsetTop.

Es gibt jetzt nur noch zwei Einstiegspunkte in die Rendering-Pipeline, die diesen beiden Szenarien entsprechen. Die reentranten Codepfade wurden entfernt oder umgeschrieben und es ist nicht mehr möglich, die Pipeline ab einer Zwischenphase zu betreten. Dadurch ist es viel einfacher geworden, das Verhalten des Systems zu verstehen.

Pipeline-Stil, Layout und Vorab-Malen

Die Rendering-Phasen vor paint sind insgesamt für Folgendes verantwortlich:

  • Ausführen des Style-Kaskaden-Algorithmus zum Berechnen der endgültigen Stileigenschaften für DOM-Knoten.
  • Generieren des Layoutbaums, der die Boxenhierarchie des Dokuments darstellt.
  • Größe und Position aller Felder bestimmen
  • Rundung oder Anpassen der Subpixelgeometrie an ganze Pixelgrenzen beim Malen.
  • Bestimmung der Eigenschaften von zusammengesetzten Ebenen (affine Transformation, Filter, Deckkraft oder andere Elemente, die GPU-beschleunigt werden können)
  • Feststellen, welche Inhalte sich seit der letzten Paint-Phase geändert haben und neu gerendert werden müssen (Paint-Ungültigkeit).

Diese Liste hat sich nicht geändert, aber vor BlinkNG wurde ein Großteil dieser Arbeit ad hoc durchgeführt, auf mehrere Renderingphasen verteilt, mit vielen duplizierten Funktionen und eingebauten Ineffizienzen. Beispielsweise war die Phase style immer in erster Linie für die Berechnung der endgültigen Stileigenschaften für Knoten verantwortlich. Es gab jedoch einige Sonderfälle, in denen wir die endgültigen Stileigenschaftswerte erst nach Abschluss der Phase style ermittelt haben. Es gab keinen formellen oder durchsetzbaren Punkt im Rendering-Prozess, an dem wir mit Sicherheit sagen konnten, dass die Stilinformationen vollständig und unveränderlich waren.

Ein weiteres gutes Beispiel für Probleme vor BlinkNG ist die Farbannullierung. Bisher wurden die Paint-Ungültigkeiten über alle Rendering-Phasen hinweg verteilt, die zur Paint-Phase führen. Beim Ändern von Stil- oder Layoutcode war es schwierig zu erkennen, welche Änderungen an der Logik zur Invalidation der Malerei erforderlich waren. Außerdem war es leicht, einen Fehler zu machen, der zu Fehlern bei zu wenig oder zu viel Invalidation führte. Weitere Informationen zu den Feinheiten des alten Systems zur Ungültigmachung von Malereien finden Sie im Artikel dieser Reihe zu LayoutNG.

Das Anpinnen der Subpixel-Layoutgeometrie an ganze Pixelgrenzen für das Zeichnen ist ein Beispiel dafür, dass wir mehrere Implementierungen derselben Funktion hatten und viel redundante Arbeit geleistet haben. Es gab einen Codepfad für das Pixel-Snapping, der vom Paint-System verwendet wurde, und einen völlig separaten Codepfad, der immer dann verwendet wurde, wenn eine einmalige On-the-fly-Berechnung von Pixel-Snapped-Koordinaten außerhalb des Paint-Codes erforderlich war. Jede Implementierung hatte natürlich ihre eigenen Fehler und die Ergebnisse stimmten nicht immer überein. Da diese Informationen nicht im Cache gespeichert wurden, führte das System manchmal dieselbe Berechnung wiederholt aus, was die Leistung zusätzlich belastete.

Hier sind einige wichtige Projekte, bei denen die architektonischen Mängel der Renderingphasen vor dem Streichen beseitigt wurden.

Project Squad: Pipeline für die Stilphase

Bei diesem Projekt wurden zwei Hauptmängel in der Stilphase behoben, die eine reibungslose Pipeline verhinderten:

Die Stilphase hat zwei Hauptergebnisse: ComputedStyle, das Ergebnis der Ausführung des CSS-Kaskadenalgorithmus über den DOM-Baum, und einen LayoutObjects-Baum, der die Abfolge der Vorgänge für die Layoutphase festlegt. Der Kaskadenalgorithmus sollte konzeptionell streng vor dem Generieren des Layoutbaums ausgeführt werden. Bisher wurden diese beiden Vorgänge jedoch verschachtelt. Project Squad hat es geschafft, diese beiden in verschiedene, aufeinanderfolgende Phasen aufzuteilen.

Bisher wurde ComputedStyle bei der Stilneuberechnung nicht immer auf den endgültigen Wert gesetzt. In einigen Fällen wurde ComputedStyle in einer späteren Pipelinephase aktualisiert. Das Projektteam hat diese Codepfade erfolgreich umstrukturiert, sodass ComputedStyle nach der Stilphase nie mehr geändert wird.

LayoutNG: Layoutphase in Pipeline bringen

Dieses monumentale Projekt, einer der Eckpfeiler von RenderingNG, war eine vollständige Neufassung der Layout-Rendering-Phase. Wir können hier nicht das gesamte Projekt abdecken, aber es gibt einige bemerkenswerte Aspekte für das gesamte BlinkNG-Projekt:

  • Bisher wurde in der Layoutphase ein LayoutObject-Baum übergeben, der in der Stilphase erstellt wurde, und der Baum wurde mit Informationen zu Größe und Position versehen. Daher gab es keine klare Trennung von Eingaben und Ausgaben. Mit LayoutNG wurde der Fragmentbaum eingeführt. Dies ist die primäre, schreibgeschützte Ausgabe des Layouts und dient als primärer Eingabewert für nachfolgende Renderingphasen.
  • Mit LayoutNG wurde die Eigenschaft „Begrenzung“ eingeführt: Bei der Berechnung der Größe und Position eines bestimmten LayoutObject wird nicht mehr außerhalb des untergeordneten Knotens gesucht, der in diesem Objekt verwurzelt ist. Alle Informationen, die zum Aktualisieren des Layouts für ein bestimmtes Objekt erforderlich sind, werden vorab berechnet und als schreibgeschützter Eingabe an den Algorithmus übergeben.
  • Bisher gab es Grenzfälle, in denen der Layoutalgorithmus nicht ordnungsgemäß funktionierte: Das Ergebnis des Algorithmus hing vom letzten vorherigen Layoutupdate ab. LayoutNG hat diese Fälle beseitigt.

Die Vormalphase

Bisher gab es keine formale Renderingphase vor dem Malen, sondern nur eine Reihe von Post-Layout-Vorgängen. Die Phase Pre-Paint entstand aus der Erkenntnis, dass es einige ähnliche Funktionen gibt, die am besten als systematisches Durchlaufen des Layoutbaums nach Abschluss des Layouts implementiert werden können. Am wichtigsten sind:

  • Paint-Invalidierungen ausstellen: Es ist sehr schwierig, Paint-Invalidierungen während des Layouts korrekt auszuführen, wenn wir unvollständige Informationen haben. Es ist viel einfacher, die richtige Lösung zu finden und kann sehr effizient sein, wenn es in zwei separate Prozesse unterteilt wird: Während des Stils und Layouts können Inhalte mit einem einfachen booleschen Flag als „möglicherweise erfordert Neuanpassung der Darstellung“ gekennzeichnet werden. Bei der Vorab-Begehung des Baumbestands prüfen wir diese Markierungen und heben bei Bedarf die Gültigkeit der entsprechenden Segmente auf.
  • Baumstruktur der Maleigenschaften generieren: Dieser Vorgang wird weiter unten ausführlicher beschrieben.
  • Berechnen und Aufzeichnen von Pixel-Snapped-Malpositionen: Die aufgezeichneten Ergebnisse können von der Malphase und von allen nachfolgenden Codeabschnitten, die sie benötigen, ohne redundante Berechnung verwendet werden.

Gebäudebäume: Einheitliche Geometrie

Property-Bäume wurden zu Beginn von RenderingNG eingeführt, um mit der Komplexität des Scrollens umzugehen, das im Web eine andere Struktur als alle anderen Arten von visuellen Effekten hat. Vor Property-Bäumen verwendete der Chromium-Compositor eine einzelne „Ebenen“-Hierarchie, um die geometrische Beziehung von zusammengesetzten Inhalten darzustellen. Das funktionierte jedoch nicht lange, da die Komplexität von Funktionen wie „position:fixed“ schnell deutlich wurde. Die Ebenenhierarchie wurde um zusätzliche nicht lokale Verweise erweitert, die das „Scroll-Elternelement“ oder „Clip-Elternelement“ einer Ebene angeben. Bald war der Code kaum noch zu verstehen.

In Property-Bäumen wurde dieses Problem behoben, indem die Überlaufscroll- und Clip-Aspekte von Inhalten getrennt von allen anderen visuellen Effekten dargestellt wurden. So konnte die visuelle und scrollbare Struktur von Websites korrekt modelliert werden. Als Nächstes mussten wir nur noch Algorithmen auf den Property-Bäumen implementieren, z. B. die Bildschirmtransformation zusammengesetzter Ebenen oder die Bestimmung, welche Ebenen gescrollt werden und welche nicht.

Tatsächlich haben wir bald festgestellt, dass es viele andere Stellen im Code gab, an denen ähnliche geometrische Fragen aufgeworfen wurden. (Eine vollständigere Liste finden Sie im Artikel zu wichtigen Datenstrukturen.) Einige davon hatten doppelte Implementierungen derselben Funktion, die der Code des Renderers ausführte, alle hatten unterschiedliche Fehler und keine davon modellierte die tatsächliche Websitestruktur richtig. Die Lösung war dann klar: Alle Geometriealgorithmen an einem Ort zentralisieren und den gesamten Code so umstrukturieren, dass er verwendet werden kann.

Diese Algorithmen hängen wiederum alle von Property-Bäumen ab. Daher sind Property-Bäume eine Schlüsseldatenstruktur, die in der gesamten Pipeline von RenderingNG verwendet wird. Um dieses Ziel zu erreichen, mussten wir das Konzept der Property-Bäume viel früher in der Pipeline einführen – in der Vorab-Painting-Phase – und alle APIs ändern, die jetzt davon abhängig waren, dass die Vorab-Painting-Phase ausgeführt wurde, bevor sie ausgeführt werden konnten.

Diese Geschichte ist ein weiterer Aspekt des BlinkNG-Refactoring-Musters: Schlüsselberechnungen identifizieren, um Duplikate zu vermeiden, und klar definierte Pipeline-Phasen erstellen, in denen die Datenstrukturen erstellt werden, die sie versorgen. Wir berechnen die Property-Bäume genau dann, wenn alle erforderlichen Informationen verfügbar sind. Außerdem sorgen wir dafür, dass sich die Property-Bäume nicht ändern können, während spätere Rendering-Phasen ausgeführt werden.

Compositing nach dem Paint-Vorgang: Paint- und Compositing-Pipeline

Bei der Beschichtung wird ermittelt, welche DOM-Inhalte in eine eigene zusammengesetzte Ebene eingefügt werden (die wiederum eine GPU-Textur darstellt). Vor RenderingNG wurde die Ebenenverwaltung vor dem Malen ausgeführt, nicht danach. Die aktuelle Pipeline finden Sie hier. Beachten Sie die Änderung der Reihenfolge. Wir würden zuerst entscheiden, welche Teile des DOM in welche zusammengesetzte Ebene aufgenommen werden, und erst dann Displaylisten für diese Texturen zeichnen. Natürlich hingen die Entscheidungen von Faktoren wie den animierten oder scrollenden DOM-Elementen, 3D-Transformationen und den Elementen ab, die übereinander gemalt wurden.

Das führte zu großen Problemen, da es mehr oder weniger dazu zwang, zyklische Abhängigkeiten im Code zu haben, was ein großes Problem für eine Rendering-Pipeline ist. Sehen wir uns das anhand eines Beispiels an. Angenommen, wir müssen die Darstellung ungültig machen, d. h., wir müssen die Anzeigeliste neu zeichnen und dann noch einmal rastern. Die Notwendigkeit zur Invalidation kann durch eine Änderung im DOM oder durch einen geänderten Stil oder ein geändertes Layout verursacht werden. Natürlich möchten wir nur die Teile ungültig machen, die sich tatsächlich geändert haben. Dazu mussten wir herausfinden, welche zusammengesetzten Ebenen betroffen waren, und dann teilweise oder vollständig die Anzeigelisten für diese Ebenen ungültig machen.

Das bedeutet, dass die Ungültigmachung vom DOM, Stil, Layout und früheren Entscheidungen zur Ebenenstruktur abhing (früher: für den vorherigen gerenderten Frame). Aber auch die aktuelle Schichtung hängt von all diesen Faktoren ab. Da wir nicht zwei Kopien aller Daten zur Schichtung hatten, war es schwierig, den Unterschied zwischen den bisherigen und zukünftigen Entscheidungen zur Schichtung zu erkennen. So hatten wir am Ende viel Code mit zirkulären Argumenten. Das führte manchmal zu unlogischem oder falschem Code oder sogar zu Abstürzen oder Sicherheitsproblemen, wenn wir nicht sehr vorsichtig waren.

Um diese Situation zu bewältigen, haben wir schon früh das Konzept des DisableCompositingQueryAsserts-Objekts eingeführt. Wenn Code versucht, frühere Entscheidungen zur Schichtung abzufragen, führt dies in den meisten Fällen zu einem Assertion-Fehler und zum Absturz des Browsers, wenn er sich im Debug-Modus befindet. So konnten wir neue Fehler vermeiden. In jedem Fall, in dem der Code berechtigterweise frühere Entscheidungen zur Schichtung abfragen musste, haben wir Code eingefügt, um dies zu ermöglichen, indem wir ein DisableCompositingQueryAsserts-Objekt zugewiesen haben.

Wir wollten im Laufe der Zeit alle DisableCompositingQueryAssert-Objekte für Anrufwebsites entfernen und den Code dann als sicher und korrekt deklarieren. Wir haben jedoch festgestellt, dass einige der Aufrufe praktisch nicht entfernt werden konnten, solange die Schichtung vor dem Malen erfolgte. (Wir konnten es erst vor Kurzem entfernen.) Dies war der erste Grund, der für das Projekt „Composite After Paint“ entdeckt wurde. Wir haben gelernt, dass selbst eine gut definierte Pipelinephase für einen Vorgang nicht weiterhilft, wenn sie an der falschen Stelle in der Pipeline liegt.

Der zweite Grund für das Projekt „Composite After Paint“ war der Fehler beim grundlegenden Compositing. Dieser Fehler lässt sich so zusammenfassen: DOM-Elemente sind keine gute 1:1-Darstellung eines effizienten oder vollständigen Schichtungsschemas für den Inhalt von Webseiten. Da das Compositing vor dem Paint-Vorgang stattfand, war es mehr oder weniger von DOM-Elementen abhängig, nicht von Displaylisten oder Property-Bäumen. Das ist sehr ähnlich wie bei der Einführung von Property-Bäumen. Genau wie bei Property-Bäumen ergibt sich die Lösung direkt, wenn Sie die richtige Pipelinephase ermitteln, sie zur richtigen Zeit ausführen und die richtigen Schlüsseldatenstrukturen bereitstellen. Und wie bei Property-Bäumen war dies eine gute Gelegenheit, dafür zu sorgen, dass die Ausgabe nach Abschluss der Paint-Phase für alle nachfolgenden Pipeline-Phasen unveränderlich ist.

Vorteile

Wie Sie gesehen haben, bietet eine gut definierte Rendering-Pipeline enorme Vorteile auf lange Sicht. Es gibt sogar noch mehr, als Sie vielleicht denken:

  • Stark verbesserte Zuverlässigkeit: Das ist ziemlich einfach. Klarer Code mit klar definierten und verständlichen Schnittstellen ist leichter zu verstehen, zu schreiben und zu testen. Das macht sie zuverlässiger. Außerdem wird der Code sicherer und stabiler, da es weniger Abstürze und weniger „Use-After-Free“-Fehler gibt.
  • Erweiterte Testabdeckung: Im Rahmen von BlinkNG haben wir unserer Testsuite viele neue Tests hinzugefügt. Dazu gehören Unit-Tests, die eine gezielte Überprüfung der internen Funktionen ermöglichen, Regressionstests, die verhindern, dass alte Fehler, die wir behoben haben, wieder auftreten (so viele!), und viele Ergänzungen zur öffentlichen, gemeinsam verwalteten Web Platform Test Suite, die alle Browser verwenden, um die Einhaltung von Webstandards zu messen.
  • Einfacher zu erweitern: Wenn ein System in klare Komponenten unterteilt ist, müssen andere Komponenten nicht bis ins Detail verstanden werden, um Fortschritte bei der aktuellen zu erzielen. So können alle den Rendering-Code optimieren, ohne ein Experte sein zu müssen. Außerdem lässt sich das Verhalten des gesamten Systems leichter nachvollziehen.
  • Leistung: Die Optimierung von Algorithmen, die in Spaghetticode geschrieben sind, ist schon schwierig genug. Ohne eine solche Pipeline ist es jedoch fast unmöglich, noch größere Dinge zu erreichen, z. B. universelles Scrollen und Animationen mit Threads oder die Prozesse und Threads für die Website-Isolation. Parallelität kann die Leistung enorm steigern, ist aber auch extrem kompliziert.
  • Yielding und Containment: BlinkNG ermöglicht mehrere neue Funktionen, mit denen die Pipeline auf neue und innovative Weise genutzt werden kann. Was wäre beispielsweise, wenn wir die Rendering-Pipeline nur so lange ausführen möchten, bis ein Budget abgelaufen ist? Oder das Rendern von untergeordneten Bäumen überspringen, die für den Nutzer derzeit nicht relevant sind? Das ist mit der CSS-Property content-visibility möglich. Was halten Sie davon, den Stil einer Komponente vom Layout abhängig zu machen? Das sind Containerabfragen.

Fallstudie: Containerabfragen

Containerabfragen sind eine mit Spannung erwartete neue Funktion der Webplattform. Seit Jahren ist dies die am häufigsten angefragte Funktion von CSS-Entwicklern. Wenn es so toll ist, warum gibt es es dann noch nicht? Der Grund dafür ist, dass die Implementierung von Containerabfragen ein sehr genaues Verständnis und eine genaue Kontrolle der Beziehung zwischen dem Stil- und dem Layoutcode erfordert. Sehen wir uns das genauer an.

Mit einer Containerabfrage können die Stile, die auf ein Element angewendet werden, von der Größe eines übergeordneten Elements abhängen. Da die Größe des Layouts während des Layouts berechnet wird, müssen wir die Stilneuberechnung nach dem Layout ausführen. Die Stilneuberechnung wird jedoch vor dem Layout ausgeführt. Dieses Henne-Ei-Paradoxon ist der Grund, warum wir Containerabfragen vor BlinkNG nicht implementieren konnten.

Wie können wir das Problem beheben? Ist das nicht eine abwärts gerichtete Pipelineabhängigkeit, also dasselbe Problem, das Projekte wie Composite After Paint gelöst haben? Was ist noch schlimmer: Was ist, wenn die neuen Stile die Größe des übergeordneten Elements ändern? Führt das nicht manchmal zu einer Endlosschleife?

Im Prinzip kann die zyklische Abhängigkeit durch die Verwendung der CSS-Eigenschaft „contain“ gelöst werden, die es ermöglicht, dass das Rendern außerhalb eines Elements nicht vom Rendern innerhalb des untergeordneten Knotens dieses Elements abhängt. Das bedeutet, dass sich die neuen Stile, die von einem Container angewendet werden, nicht auf die Größe des Containers auswirken können, da Containerabfragen Begrenzungen erfordern.

Das war aber nicht genug. Es war notwendig, eine weniger strenge Eindämmung als nur die Größeneindämmung einzuführen. Das liegt daran, dass es häufig gewünscht ist, dass ein Container-Abfragecontainer basierend auf seinen Inline-Abmessungen nur in eine Richtung (normalerweise Block) skaliert werden kann. Daher wurde das Konzept der Inline-Größe hinzugefügt. Wie Sie jedoch an der sehr langen Anmerkung in diesem Abschnitt sehen können, war es lange Zeit überhaupt nicht klar, ob eine Begrenzung der Inline-Größe möglich war.

Es ist eine Sache, Begrenzungen in einer abstrakten Spezifikationssprache zu beschreiben, und eine ganz andere, sie richtig zu implementieren. Denken Sie daran, dass eines der Ziele von BlinkNG darin bestand, das Begrenzungsprinzip auf die Baumdurchläufe anzuwenden, die die Hauptlogik des Renderings bilden: Beim Durchlaufen eines untergeordneten Baums sollten keine Informationen von außerhalb des untergeordneten Baums erforderlich sein. Es ist viel einfacher, CSS-Begrenzungen zu implementieren, wenn der Rendering-Code dem Begrenzungsprinzip folgt.

Zukunft: Off-Main-Thread-Compositing und mehr

Die hier gezeigte Rendering-Pipeline ist der aktuellen RenderingNG-Implementierung sogar ein wenig voraus. Die Ebenen werden als außerhalb des Hauptthreads angezeigt, obwohl sie sich derzeit noch im Hauptthread befinden. Es ist jedoch nur eine Frage der Zeit, bis dies der Fall ist, da Composite After Paint veröffentlicht wurde und die Ebenen nach dem Malen erstellt werden.

Um zu verstehen, warum das wichtig ist und wohin es sonst noch führen kann, müssen wir uns die Architektur der Rendering-Engine aus einer etwas höheren Perspektive ansehen. Eines der größten Hindernisse bei der Leistungssteigerung von Chromium ist die einfache Tatsache, dass der Haupt-Thread des Renderers sowohl die Hauptanwendungslogik (d. h. das laufende Script) als auch den Großteil des Renderings verarbeitet. Daher ist der Hauptthread häufig mit Arbeit überlastet und die Überlastung des Hauptthreads ist häufig das Nadelöhr im gesamten Browser.

Die gute Nachricht ist: Das muss nicht so sein. Dieser Aspekt der Chromium-Architektur stammt aus der KHTML-Ära, als die Ausführung mit nur einem Thread das vorherrschende Programmiermodell war. Als Multicore-Prozessoren auf Verbrauchergeräten üblich wurden, war die Annahme, dass nur ein Thread verwendet wird, bereits fest in Blink (früher WebKit) verankert. Wir wollten schon lange mehr Threads in die Rendering-Engine einführen, aber das war im alten System einfach nicht möglich. Eines der Hauptziele von Rendering NG war es, aus diesem Loch herauszukommen und es möglich zu machen, die Rendering-Arbeit teilweise oder vollständig in einen anderen Thread (oder mehrere Threads) zu verschieben.

Da BlinkNG kurz vor der Fertigstellung steht, beginnen wir bereits, diesen Bereich zu erkunden. Non-Blocking Commit ist ein erster Schritt zur Änderung des Threading-Modells des Renderers. Ein Compositor-Commit (oder einfach Commit) ist ein Synchronisierungsschritt zwischen dem Haupt- und dem Compositor-Thread. Während des Commits erstellen wir Kopien der Rendering-Daten, die im Hauptthread erstellt werden, um sie vom Downstream-Compositing-Code zu verwenden, der im Compositor-Thread ausgeführt wird. Während dieser Synchronisierung wird die Ausführung des Hauptthreads angehalten, während der Kopiercode im Compositor-Thread ausgeführt wird. So wird sichergestellt, dass der Hauptthread seine Renderingdaten nicht ändert, während der Compositor-Thread sie kopiert.

Bei einem nicht blockierenden Commit muss der Hauptthread nicht angehalten werden und auf das Ende der Commit-Phase warten. Der Hauptthread führt seine Arbeit fort, während der Commit gleichzeitig im Compositor-Thread ausgeführt wird. Der Nettoeffekt eines nicht blockierenden Commits ist eine Verringerung der Zeit, die für die Rendering-Arbeit im Hauptthread aufgewendet wird. Dies verringert die Überlastung des Hauptthreads und verbessert die Leistung. Zum Zeitpunkt der Erstellung dieses Artikels (März 2022) haben wir einen funktionierenden Prototyp für das nicht blockierende Commit. Wir sind dabei, die Auswirkungen auf die Leistung ausführlich zu analysieren.

In der Pipeline befindet sich das Off-Main-Thread-Compositing, mit dem Ziel, die Rendering-Engine an die Illustration anzupassen, indem die Beschichtung aus dem Haupt-Thread in einen Worker-Thread verschoben wird. Ähnlich wie beim nicht blockierenden Commit wird dadurch die Überlastung des Hauptthreads reduziert, da die Rendering-Arbeitslast verringert wird. Ein solches Projekt wäre ohne die architektonischen Verbesserungen von Composite After Paint nie möglich gewesen.

Und es gibt noch weitere Projekte in der Pipeline (im wahrsten Sinne des Wortes). Wir haben endlich eine Grundlage, die es ermöglicht, mit der Umverteilung von Rendering-Aufgaben zu experimentieren. Wir sind sehr gespannt, was möglich ist!