Πώς λειτουργεί το JavaScript: Under the Hood του V8 Engine

Σήμερα θα κοιτάξουμε κάτω από το καπό του κινητήρα V8 του JavaScript και θα καταλάβουμε πώς ακριβώς εκτελείται το JavaScript.

Σε ένα προηγούμενο άρθρο μάθαμε πώς είναι δομημένο το πρόγραμμα περιήγησης και έχουμε μια επισκόπηση υψηλού επιπέδου του Chromium. Ας ανακεφαλαιώσουμε λίγο, ώστε να είμαστε έτοιμοι να βουτήξουμε εδώ.

Ιστορικό

Τα Πρότυπα Ιστού είναι ένα σύνολο κανόνων που εφαρμόζει το πρόγραμμα περιήγησης. Ορίζουν και περιγράφουν πτυχές του Παγκόσμιου Ιστού.

Το W3C είναι μια διεθνής κοινότητα που αναπτύσσει ανοιχτά πρότυπα για τον Ιστό. Βεβαιώνουν ότι ο καθένας ακολουθεί τις ίδιες οδηγίες και δεν χρειάζεται να υποστηρίζει δεκάδες εντελώς διαφορετικά περιβάλλοντα.

Ένα σύγχρονο πρόγραμμα περιήγησης είναι ένα πολύπλοκο λογισμικό με βάση κώδικα δεκάδων εκατομμυρίων γραμμών κώδικα. Έτσι χωρίζεται σε πολλές ενότητες που είναι υπεύθυνες για διαφορετική λογική.

Και δύο από τα πιο σημαντικά μέρη ενός προγράμματος περιήγησης είναι η μηχανή JavaScript και μια μηχανή απόδοσης.

Το Blink είναι μια μηχανή απόδοσης που είναι υπεύθυνη για ολόκληρο τον αγωγό απόδοσης, συμπεριλαμβανομένων δέντρων DOM, στυλ, εκδηλώσεων και ενσωμάτωσης V8. Αναλύει το δέντρο DOM, επιλύει στυλ και καθορίζει την οπτική γεωμετρία όλων των στοιχείων.

Ενώ παρακολουθείτε συνεχώς δυναμικές αλλαγές μέσω πλαισίων κινουμένων σχεδίων, το Blink χρωματίζει το περιεχόμενο στην οθόνη σας. Ο κινητήρας JS είναι ένα μεγάλο μέρος του προγράμματος περιήγησης - αλλά δεν έχουμε φτάσει ακόμα σε αυτές τις λεπτομέρειες.

Μηχανή JavaScript 101

Η μηχανή JavaScript εκτελεί και μεταγλωττίζει τη JavaScript σε εγγενή κώδικα μηχανής. Κάθε μεγάλο πρόγραμμα περιήγησης έχει αναπτύξει τη δική του μηχανή JS: Το Chrome της Google χρησιμοποιεί V8, το Safari χρησιμοποιεί JavaScriptCore και ο Firefox χρησιμοποιεί SpiderMonkey.

Θα δουλέψουμε ιδιαίτερα με το V8 λόγω της χρήσης του στα Node.js και Electron, αλλά άλλοι κινητήρες είναι κατασκευασμένοι με τον ίδιο τρόπο.

Κάθε βήμα θα περιλαμβάνει έναν σύνδεσμο προς τον κωδικό που είναι υπεύθυνος για αυτόν, ώστε να μπορείτε να εξοικειωθείτε με τη βάση κώδικα και να συνεχίσετε την έρευνα πέρα ​​από αυτό το άρθρο.

Θα συνεργαστούμε με έναν καθρέφτη του V8 στο GitHub, καθώς παρέχει μια βολική και γνωστή διεπαφή χρήστη για πλοήγηση στη βάση κώδικα.

Προετοιμασία του πηγαίου κώδικα

Το πρώτο πράγμα που πρέπει να κάνει το V8 είναι να κατεβάσετε τον πηγαίο κώδικα. Αυτό μπορεί να γίνει μέσω δικτύου, προσωρινής μνήμης ή εργαζομένων υπηρεσιών.

Μόλις ληφθεί ο κωδικός, πρέπει να τον αλλάξουμε με τρόπο που ο μεταγλωττιστής μπορεί να κατανοήσει. Αυτή η διαδικασία ονομάζεται ανάλυση και αποτελείται από δύο μέρη: τον σαρωτή και τον ίδιο τον αναλυτή.

Ο σαρωτής παίρνει το αρχείο JS και το μετατρέπει στη λίστα γνωστών διακριτικών. Υπάρχει μια λίστα με όλα τα διακριτικά JS στο αρχείο Keywordss.txt.

Ο αναλυτής το παίρνει και δημιουργεί ένα Abstract Syntax Tree (AST): μια αναπαράσταση δέντρου του πηγαίου κώδικα. Κάθε κόμβος του δέντρου δηλώνει μια κατασκευή που εμφανίζεται στον κώδικα.

Ας ρίξουμε μια ματιά σε ένα απλό παράδειγμα:

function foo() { let bar = 1; return bar; }

Αυτός ο κωδικός θα παράγει την ακόλουθη δομή δέντρου:

Μπορείτε να εκτελέσετε αυτόν τον κώδικα εκτελώντας μια προπαραγγελία διέλευσης (ρίζα, αριστερά, δεξιά)

  1. Ορίστε τη fooσυνάρτηση.
  2. Δηλώστε τη barμεταβλητή.
  3. Ανάθεση 1σε bar.
  4. Επιστρέψτε barαπό τη λειτουργία.

Θα δείτε επίσης VariableProxy- ένα στοιχείο που συνδέει την αφηρημένη μεταβλητή με ένα μέρος στη μνήμη. Η διαδικασία επίλυσης VariableProxyονομάζεται Ανάλυση πεδίου .

Στο παράδειγμά μας, το αποτέλεσμα της διαδικασίας θα VariableProxyέδειχνε την ίδια barμεταβλητή.

Το παράδειγμα Just-in-Time (JIT)

Γενικά, για να εκτελεστεί ο κώδικάς σας, η γλώσσα προγραμματισμού πρέπει να μετατραπεί σε κώδικα μηχανήματος. Υπάρχουν αρκετές προσεγγίσεις για το πώς και πότε μπορεί να συμβεί αυτός ο μετασχηματισμός.

Ο πιο συνηθισμένος τρόπος για να μετατρέψετε τον κώδικα είναι να εκτελέσετε σύνταξη εκ των προτέρων. Λειτουργεί ακριβώς όπως ακούγεται: ο κώδικας μετατρέπεται σε κώδικα μηχανήματος πριν από την εκτέλεση του προγράμματος σας κατά το στάδιο της σύνταξης.

Αυτή η προσέγγιση χρησιμοποιείται από πολλές γλώσσες προγραμματισμού, όπως C ++, Java και άλλες.

Στην άλλη πλευρά του πίνακα, έχουμε ερμηνεία: κάθε γραμμή του κώδικα θα εκτελείται κατά το χρόνο εκτέλεσης. Αυτή η προσέγγιση ακολουθείται συνήθως από δυναμικά δακτυλογραφημένες γλώσσες όπως το JavaScript και το Python επειδή είναι αδύνατο να γνωρίζουμε τον ακριβή τύπο πριν από την εκτέλεση.

Επειδή η εκ των προτέρων συλλογή μπορεί να εκτιμήσει όλο τον κώδικα μαζί, μπορεί να προσφέρει καλύτερη βελτιστοποίηση και τελικά να παράγει πιο αποδοτικό κώδικα. Η ερμηνεία, από την άλλη πλευρά, είναι πιο απλή στην εφαρμογή, αλλά συνήθως είναι πιο αργή από την επιλογή που έχει μεταγλωττιστεί.

Για να μετατρέψετε τον κώδικα πιο γρήγορα και πιο αποτελεσματικά για δυναμικές γλώσσες, δημιουργήθηκε μια νέα προσέγγιση που ονομάζεται συλλογή Just-in-Time (JIT). Συνδυάζει τα καλύτερα από την ερμηνεία και τη συλλογή.

Ενώ χρησιμοποιεί την ερμηνεία ως βασική μέθοδο, το V8 μπορεί να εντοπίσει συναρτήσεις που χρησιμοποιούνται πιο συχνά από άλλες και να τις μεταγλωττίσει χρησιμοποιώντας πληροφορίες τύπου από προηγούμενες εκτελέσεις.

Ωστόσο, υπάρχει πιθανότητα να αλλάξει ο τύπος. Πρέπει να απο-βελτιστοποιήσουμε τον μεταγλωττισμένο κώδικα και να αντικαταστήσουμε την ερμηνεία (μετά από αυτό, μπορούμε να μεταγλωττίσουμε τη συνάρτηση αφού λάβουμε νέα σχόλια τύπου)

Ας εξερευνήσουμε κάθε μέρος της συλλογής JIT με περισσότερες λεπτομέρειες.

Διερμηνέας

Το V8 χρησιμοποιεί διερμηνέα που ονομάζεται Ignition. Αρχικά, παίρνει ένα αφηρημένο συντακτικό δέντρο και δημιουργεί κώδικα byte.

Οι οδηγίες κώδικα byte έχουν επίσης μεταδεδομένα, όπως θέσεις γραμμής προέλευσης για μελλοντικό εντοπισμό σφαλμάτων. Γενικά, οι οδηγίες κώδικα byte αντιστοιχούν στις αφαιρέσεις JS.

Τώρα ας πάρουμε το παράδειγμά μας και να δημιουργήσουμε κώδικα byte για αυτό μη αυτόματα:

LdaSmi #1 // write 1 to accumulator Star r0 // read to r0 (bar) from accumulator Ldar r0 // write from r0 (bar) to accumulator Return // returns accumulator

Ignition has something called an accumulator — a place where you can store/read values.

The accumulator avoids the need for pushing and popping the top of the stack. It’s also an implicit argument for many byte codes and typically holds the result of the operation. Return implicitly returns the accumulator.

You can check out all the available byte code in the corresponding source code. If you’re interested in how other JS concepts (like loops and async/await) are presented in byte code, I find it useful to read through these test expectations.

Execution

After the generation, Ignition will interpret the instructions using a table of handlers keyed by the byte code. For each byte code, Ignition can look up corresponding handler functions and execute them with the provided arguments.

As we mentioned before, the execution stage also provides the type feedback about the code. Let’s figure out how it’s collected and managed.

First, we should discuss how JavaScript objects can be represented in memory. In a naive approach, we can create a dictionary for each object and link it to the memory.

However, we usually have a lot of objects with the same structure, so it would not be efficient to store lots of duplicated dictionaries.

To solve this issue, V8 separates the object's structure from the values itself with Object Shapes (or Maps internally) and a vector of values in memory.

For example, we create an object literal:

let c = { x: 3 } let d = { x: 5 } c.y = 4

In the first line, it will produce a shape Map[c] that has the property x with an offset 0.

In the second line, V8 will reuse the same shape for a new variable.

After the third line, it will create a new shape Map[c1] for property y with an offset 1 and create a link to the previous shape Map[c] .

In the example above, you can see that each object can have a link to the object shape where for each property name, V8 can find an offset for the value in memory.

Object shapes are essentially linked lists. So if you write c.x, V8 will go to the head of the list, find y there, move to the connected shape, and finally it gets x and reads the offset from it. Then it’ll go to the memory vector and return the first element from it.

As you can imagine, in a big web app you’ll see a huge number of connected shapes. At the same time, it takes linear time to search through the linked list, making property lookups a really expensive operation.

To solve this problem in V8, you can use the Inline Cache (IC).It memorizes information on where to find properties on objects to reduce the number of lookups.

You can think about it as a listening site in your code: it tracks all CALL, STORE, and LOAD events within a function and records all shapes passing by.

The data structure for keeping IC is called Feedback Vector. It’s just an array to keep all ICs for the function.

function load(a) { return a.key; }

For the function above, the feedback vector will look like this:

[{ slot: 0, icType: LOAD, value: UNINIT }]

It’s a simple function with only one IC that has a type of LOAD and value of UNINIT. This means it’s uninitialized, and we don’t know what will happen next.

Let’s call this function with different arguments and see how Inline Cache will change.

let first = { key: 'first' } // shape A let fast = { key: 'fast' } // the same shape A let slow = { foo: 'slow' } // new shape B load(first) load(fast) load(slow)

After the first call of the load function, our inline cache will get an updated value:

[{ slot: 0, icType: LOAD, value: MONO(A) }]

That value now becomes monomorphic, which means this cache can only resolve to shape A.

After the second call, V8 will check the IC's value and it'll see that it’s monomorphic and has the same shape as the fast variable. So it will quickly return offset and resolve it.

The third time, the shape is different from the stored one. So V8 will manually resolve it and update the value to a polymorphic state with an array of two possible shapes.

[{ slot: 0, icType: LOAD, value: POLY[A,B] }]

Now every time we call this function, V8 needs to check not only one shape but iterate over several possibilities.

For the faster code, you can initialize objects with the same type and not change their structure too much.

Note: You can keep this in mind, but don’t do it if it leads to code duplication or less expressive code.

Inline caches also keep track of how often they're called to decide if it’s a good candidate for optimizing the compiler — Turbofan.

Compiler

Ignition only gets us so far. If a function gets hot enough, it will be optimized in the compiler, Turbofan, to make it faster.

Turbofan takes byte code from Ignition and type feedback (the Feedback Vector) for the function, applies a set of reductions based on it, and produces machine code.

As we saw before, type feedback doesn’t guarantee that it won’t change in the future.

For example, Turbofan optimized code based on the assumption that some addition always adds integers.

But what would happen if it received a string? This process is called deoptimization. We throw away optimized code, go back to interpreted code, resume execution, and update type feedback.

Summary

In this article, we discussed JS engine implementation and the exact steps of how JavaScript is executed.

To summarize, let’s have a look at the compilation pipeline from the top.

We’ll go over it step by step:

  1. It all starts with getting JavaScript code from the network.
  2. V8 parses the source code and turns it into an Abstract Syntax Tree (AST).
  3. Based on that AST, the Ignition interpreter can start to do its thing and produce bytecode.
  4. Σε αυτό το σημείο, ο κινητήρας αρχίζει να τρέχει τον κώδικα και να συλλέγει σχόλια τύπου.
  5. Για να γίνει πιο γρήγορη, ο κωδικός byte μπορεί να σταλεί στον βελτιστοποιητή μεταγλωττιστή μαζί με τα δεδομένα ανατροφοδότησης. Ο βελτιστοποιητής μεταγλωττιστής κάνει συγκεκριμένες παραδοχές βάσει αυτού και στη συνέχεια παράγει εξαιρετικά βελτιστοποιημένο κώδικα μηχανής.
  6. Εάν, σε κάποιο σημείο, μία από τις παραδοχές αποδειχθεί λανθασμένη, ο μεταγλωττιστής βελτιστοποίησης απενεργοποιεί και επιστρέφει στον διερμηνέα.

Αυτό είναι! Εάν έχετε απορίες σχετικά με ένα συγκεκριμένο στάδιο ή θέλετε να μάθετε περισσότερες λεπτομέρειες σχετικά με αυτό, μπορείτε να βρείτε τον πηγαίο κώδικα ή να με χτυπήσετε στο Twitter.

Περαιτέρω ανάγνωση

  • Βίντεο "Η ζωή ενός σεναρίου" από την Google
  • Ένα μάθημα συντριβής σε μεταγλωττιστές JIT από το Mozilla
  • Ωραία εξήγηση του Inline Caches στο V8
  • Υπέροχη βουτιά σε σχήματα αντικειμένων