Grundlagen der JavaScript-Engine: Prototypoptimierung. Teil 2

Guten Tag Freunde! Der Kurs "Sicherheit von Informationssystemen" wurde gestartet. In diesem Zusammenhang teilen wir Ihnen den letzten Teil des Artikels "Grundlagen von JavaScript-Engines: Optimierung von Prototypen" mit, dessen erster Teil hier gelesen werden kann .

Wir erinnern Sie auch daran, dass die aktuelle Veröffentlichung eine Fortsetzung dieser beiden Artikel ist: „Grundlagen von JavaScript-Engines: allgemeine Formulare und Inline-Caching. Teil 1 " , " Grundlagen von JavaScript-Engines: Allgemeine Formulare und Inline-Caching. Teil 2 " .



Klassen und Prototypenprogrammierung

Nachdem wir nun wissen, wie wir schnell auf die Eigenschaften von JavaScript-Objekten zugreifen können, können wir uns die komplexere Struktur von JavaScript-Klassen ansehen. So sieht die Klassensyntax in JavaScript aus:

class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } 

Obwohl dies ein relativ neues Konzept für JavaScript zu sein scheint, ist es nur „syntaktischer Zucker“ für die Prototyp-Programmierung, die in JavaScript immer verwendet wurde:

 function Bar(x) { this.x = x; } Bar.prototype.getX = function getX() { return this.x; }; 

Hier weisen wir dem Objekt getX Eigenschaft getX . Dies funktioniert genau wie bei jedem anderen Objekt, da Prototypen in JavaScript dieselben Objekte sind. In Prototyp-Programmiersprachen wie JavaScript wird über Prototypen auf Methoden zugegriffen, während Felder in bestimmten Instanzen gespeichert werden.

Schauen wir uns genauer an, was passiert, wenn wir eine neue Instanz von Bar erstellen, die wir foo nennen werden.

 const foo = new Bar(true); 

Eine mit diesem Code erstellte Instanz hat ein Formular mit einer einzelnen 'x' Eigenschaft. Der foo Prototyp ist Bar.prototype , der zur Bar Klasse gehört.



Dieser Bar.prototype hat die Form von sich selbst und enthält die einzige Eigenschaft 'getX' , deren Wert durch die Funktion 'getX' , die beim Aufruf this.x Der Prototyp Bar.prototype ist Object.prototype , der Teil der JavaScript-Sprache ist. Object.prototype ist die Wurzel des Prototypbaums, während sein Prototyp null .



Wenn Sie eine neue Instanz derselben Klasse erstellen, haben beide Instanzen dieselbe Form, wie wir bereits verstanden haben. Beide Instanzen verweisen auf dasselbe Bar.prototype Objekt.

Zugriff auf Prototypeneigenschaften

Nun wissen wir, was passiert, wenn wir eine Klasse definieren und eine neue Instanz erstellen. Aber was passiert, wenn wir die Methode für die Instanz aufrufen, wie im folgenden Beispiel?

 class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); // ^^^^^^^^^^ 

Sie können jeden Methodenaufruf als zwei separate Schritte betrachten:

 const x = foo.getX(); // is actually two steps: const $getX = foo.getX; const x = $getX.call(foo); 

Der erste Schritt besteht darin, die Methode zu laden, die eigentlich eine Eigenschaft des Prototyps ist (dessen Wert eine Funktion ist). Der zweite Schritt besteht darin, eine Funktion mit einer Instanz aufzurufen, beispielsweise den Wert this . Schauen wir uns den ersten Schritt genauer an, in dem die getX Methode aus der foo Instanz getX .



Die Engine startet eine Instanz von foo und erkennt, dass das Formular foo keine 'getX' hat. 'getX' muss es die Prototypenkette durchlaufen, um es zu finden. Wir kommen zu Bar.prototype , schauen uns das Prototypformular an und sehen, dass es die Eigenschaft 'getX' bei Nullpunktverschiebung hat. Wir suchen den Wert an diesem Offset in Bar.prototype und finden die JSFunction getX , nach der wir gesucht haben.

Durch die Flexibilität von JavaScript können sich Prototyp-Kettenglieder ändern, zum Beispiel:

 const foo = new Bar(true); foo.getX(); // → true Object.setPrototypeOf(foo, null); foo.getX(); // → Uncaught TypeError: foo.getX is not a function 

In diesem Beispiel rufen wir an
 foo.getX() 
zweimal, aber jedes Mal hat es völlig andere Bedeutungen und Ergebnisse. Trotz der Tatsache, dass Prototypen nur Objekte in JavaScript sind, ist die Beschleunigung des Zugriffs auf Eigenschaften eines Prototyps für JavaScript-Engines eine noch wichtigere Aufgabe als die Beschleunigung des eigenen Zugriffs auf Eigenschaften regulärer Objekte.

In der täglichen Praxis ist das Laden von Prototyp-Eigenschaften eine ziemlich häufige Operation: Dies geschieht jedes Mal, wenn Sie eine Methode aufrufen!

 class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); // ^^^^^^^^^^ 

Zuvor haben wir darüber gesprochen, wie Engines das Laden regulärer Eigenschaften mithilfe von Formularen und Inline-Caches optimieren. Wie kann ich das Laden von Prototypeneigenschaften für Objekte derselben Form optimieren? Von oben haben wir gesehen, wie Eigenschaften geladen werden.



Um dies in diesem speziellen Fall bei wiederholten Downloads schnell zu tun, müssen Sie die folgenden drei Dinge wissen:

  • Das Formular foo enthält kein 'getX' und wurde nicht geändert. Dies bedeutet, dass niemand das foo-Objekt geändert hat, indem er eine Eigenschaft hinzugefügt oder entfernt oder eines der Eigenschaftsattribute geändert hat.
  • Der foo-Prototyp ist immer noch der ursprüngliche Bar.prototype . Daher hat niemand den Prototyp foo mit Object.setPrototypeOf() geändert oder der speziellen Eigenschaft _proto_ .
  • Das Bar.prototype Formular enthält 'getX' und wurde nicht geändert. Dies bedeutet, dass niemand Bar.prototype durch Hinzufügen oder Entfernen einer Eigenschaft oder Ändern eines der Eigenschaftsattribute geändert hat.

Im allgemeinen Fall bedeutet dies, dass Sie eine Prüfung der Instanz selbst und zwei weitere Prüfungen für jeden Prototyp bis zu dem Prototyp durchführen müssen, der die gewünschte Eigenschaft enthält. 1 + 2N-Prüfungen, bei denen N die Anzahl der verwendeten Prototypen ist, klingen in diesem Fall nicht so schlecht, da die Prototypenkette relativ flach ist. Motoren müssen jedoch häufig mit viel längeren Prototypenketten umgehen, wie dies bei regulären DOM-Klassen der Fall ist. Zum Beispiel:

 const anchor = document.createElement('a'); // → HTMLAnchorElement const title = anchor.getAttribute('title'); 

Wir haben ein HTMLAnchorElement und rufen die Methode getAttribute() . Die Kette für dieses einfache Element enthält bereits 6 Prototypen! Die meisten DOM-Methoden, die uns interessieren, befinden sich nicht im HTMLAnchorElement Prototyp HTMLAnchorElement , sondern irgendwo in der Kette.



Die Methode getAttribute() befindet sich in Element.prototype . Dies bedeutet, dass die JavaScript-Engine jedes Mal, wenn wir anchor.getAttribute() aufrufen, anchor.getAttribute() benötigt:

  1. 'getAttribute' dass 'getAttribute' per se kein 'getAttribute' .
  2. HTMLAnchorElement.prototype , dass der endgültige Prototyp HTMLAnchorElement.prototype .
  3. Bestätigen Sie dort das Fehlen von 'getAttribute' .
  4. HTMLElement.prototype , dass der nächste Prototyp HTMLElement.prototype .
  5. Bestätigen Sie das Fehlen von 'getAttribute' .
  6. Element.prototype , dass der nächste Prototyp Element.prototype .
  7. Überprüfen Sie, ob 'getAttribute' vorhanden ist.

Insgesamt 7 Schecks. Da diese Art von Code im Web weit verbreitet ist, verwenden Engines verschiedene Tricks, um die Anzahl der zum Laden von Prototypeneigenschaften erforderlichen Überprüfungen zu verringern.

Zurück zu einem früheren Beispiel, in dem wir nur drei Überprüfungen durchgeführt haben, als wir 'getX' für foo 'getX' :

 class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const $getX = foo.getX; 

Für jedes Objekt, das vor dem Prototyp auftritt, der die gewünschte Eigenschaft enthält, müssen die Formulare auf das Fehlen dieser Eigenschaft überprüft werden. Es wäre schön, wenn wir die Anzahl der Prüfungen reduzieren könnten, indem wir die Prototypprüfung als Prüfung für das Fehlen einer Eigenschaft präsentieren. Im Wesentlichen ist dies genau das, was Engines mit einem einfachen Trick tun: Anstatt den Prototyp-Link zur Instanz selbst zu speichern, speichern die Engines ihn in Form.



Jedes Formular gibt einen Prototyp an. Dies bedeutet, dass jedes Mal, wenn sich der Prototyp foo ändert, der Motor in eine neue Form wechselt. Jetzt müssen wir nur noch die Form des Objekts überprüfen, um das Fehlen bestimmter Eigenschaften zu bestätigen und die Prototypverbindung zu schützen (die Prototypverbindung schützen).

Mit diesem Ansatz können wir die Anzahl der erforderlichen Überprüfungen von 2N + 1 auf 1 + N reduzieren, um den Zugriff zu beschleunigen. Dies ist immer noch eine ziemlich teure Operation, da sie immer noch eine lineare Funktion der Anzahl der Prototypen in der Kette ist. Motoren verwenden verschiedene Tricks, um die Anzahl der Überprüfungen weiter auf einen bestimmten konstanten Wert zu reduzieren, insbesondere bei sequentiellem Laden derselben Eigenschaften.

Gültigkeitszellen

Der V8 verarbeitet Prototypformulare speziell für diesen Zweck. Jeder Prototyp hat eine eindeutige Form, die nicht mit anderen Objekten (insbesondere mit anderen Prototypen) geteilt wird, und jeder dieser Prototypformen ist eine spezielle ValidityCell zugeordnet.



Diese ValidityCell jedes Mal deaktiviert, wenn jemand den damit verbundenen Prototyp oder einen anderen darüber liegenden Prototyp ändert. Mal sehen, wie es funktioniert.
Um nachfolgende Prototyp-Downloads zu beschleunigen, platziert V8 den Inline-Cache an einem Ort mit vier Feldern:



Wenn der Inline-Cache beim ersten Ausführen des Codes erwärmt wird, merkt sich V8 den Offset, bei dem die Eigenschaft im Prototyp gefunden wurde, diesen Prototyp (z. B. Bar.prototype ), das Bar.prototype (in unserem Fall das Formular foo ) und bindet auch die aktuelle ValidityCell an den empfangenen Prototyp aus der Instanz des Formulars (in unserem Fall wird Bar.prototype übernommen).

Wenn Sie das nächste Mal den Inline-Cache verwenden, muss die Engine das Instanzformular und ValidityCell überprüfen. Wenn es noch gültig ist, verwendet die Engine direkt den Offset des Prototyps und überspringt die zusätzlichen Suchschritte.



Wenn Sie den Prototyp ändern, wird ein neues Formular hervorgehoben und die vorherige ValidityCell Zelle deaktiviert. Aus diesem Grund wird der Inline-Cache beim nächsten Start übersprungen, was zu einer schlechten Leistung führt.

Kehren wir zum Beispiel mit dem DOM-Element zurück. Jede Änderung in Object.prototype nicht nur die Inline-Caches für Object.prototype ungültig, sondern auch für alle Prototypen in der darunter EventTarget.prototype Kette, einschließlich EventTarget.prototype , Node.prototype , Element.prototype usw. bis hin zu HTMLAnchorElement.prototype .



Tatsächlich ist das Ändern von Object.prototype während der Ausführung des Codes ein schrecklicher Leistungsverlust. Tu das nicht!

Schauen wir uns ein konkretes Beispiel an, um besser zu verstehen, wie dies funktioniert. loadX , wir haben eine Bar Klasse und eine loadX Funktion, die eine Methode für Objekte vom Typ Bar aufruft. Wir rufen die loadX Funktion mehrmals mit Instanzen derselben Klasse auf.

 class Bar { /* … */ } function loadX(bar) { return bar.getX(); // IC for 'getX' on `Bar` instances. } loadX(new Bar(true)); loadX(new Bar(false)); // IC in `loadX` now links the `ValidityCell` for // `Bar.prototype`. Object.prototype.newMethod = y => y; // The `ValidityCell` in the `loadX` IC is invalid // now, because `Object.prototype` changed. 

Der Inline-Cache in loadX jetzt auf ValidityCell für Bar.prototype . Wenn Sie dann den (mutierten) Object.prototype , der die Wurzel aller Prototypen in JavaScript darstellt, wird ValidityCell ungültig und vorhandene Inline-Caches werden beim nächsten Mal nicht verwendet, was zu einer schlechten Leistung führt.

Das Ändern des Object.prototype ist immer eine schlechte Idee, da zum Zeitpunkt der Änderung alle Inline-Caches für geladene Prototypen ungültig werden. Hier ist ein Beispiel, wie man NICHT macht:

 Object.prototype.foo = function() { /* … */ }; // Run critical code: someObject.foo(); // End of critical code. delete Object.prototype.foo; 

Wir erweitern Object.prototype , wodurch alle von der Engine zu diesem Zeitpunkt geladenen Inline-Prototyp-Caches ungültig werden. Dann werden wir einen Code ausführen, der die von uns beschriebene Methode verwendet. Die Engine muss von Anfang an starten und Inline-Caches für den Zugriff auf die Prototyp-Eigenschaft konfigurieren. Und schließlich „bereinigen“ und entfernen Sie die zuvor hinzugefügte Prototypmethode.

Sie denken, Reinigung ist eine gute Idee, oder? In diesem Fall wird sich die Situation weiter verschlechtern! Durch das Entfernen von Eigenschaften wird Object.prototype , sodass alle Inline-Caches wieder deaktiviert werden und die Engine die Arbeit von Object.prototype beginnen muss.

Zusammenfassend . Obwohl Prototypen nur Objekte sind, werden sie speziell von JavaScript-Engines verarbeitet, um die Leistung der Methodensuche nach Prototypen zu optimieren. Lass die Prototypen in Ruhe! Oder wenn Sie sich wirklich mit ihnen befassen müssen, tun Sie dies, bevor Sie den Code ausführen, damit Sie zumindest nicht alle Versuche ungültig machen, Ihren Code während seiner Ausführung zu optimieren!

Fassen Sie zusammen

Wir haben gelernt, wie JavaScript Objekte und Klassen speichert und wie Formulare, Inline-Caches und Gültigkeitszellen zur Optimierung von Prototypoperationen beitragen. Basierend auf diesem Wissen haben wir verstanden, wie die Leistung aus praktischer Sicht verbessert werden kann: Berühren Sie keine Prototypen! (oder wenn Sie es wirklich brauchen, tun Sie es, bevor Sie den Code ausführen).

Der erste Teil

War diese Reihe von Veröffentlichungen für Sie hilfreich? Schreiben Sie in die Kommentare.

Source: https://habr.com/ru/post/de449144/


All Articles