Kuinka JavaScript toimii: V8-moottorin kotelon alla

Tänään tarkastelemme JavaScriptin V8-moottorin konepellin alla ja selvitämme, kuinka JavaScript suoritetaan tarkalleen.

Edellisessä artikkelissa opimme, kuinka selain on rakennettu, ja saimme korkean tason yleiskatsauksen Chromiumista. Kerrataan vähän, jotta olemme valmiita sukeltamaan tänne.

Tausta

Verkkostandardit ovat joukko sääntöjä, jotka selain toteuttaa. He määrittelevät ja kuvaavat Internetin näkökohtia.

W3C on kansainvälinen yhteisö, joka kehittää avoimia verkkostandardeja. He varmistavat, että kaikki noudattavat samoja ohjeita eikä heidän tarvitse tukea kymmeniä täysin erilaisia ​​ympäristöjä.

Moderni selain on melko monimutkainen ohjelmisto, jonka koodipohja on kymmeniä miljoonia koodiriviä. Joten se on jaettu moniin moduuleihin, jotka vastaavat erilaisesta logiikasta.

Selaimen kaksi tärkeintä osaa ovat JavaScript-moottori ja renderointimoottori.

Blink on renderointimoottori, joka vastaa koko renderöintiputkesta, mukaan lukien DOM-puut, tyylit, tapahtumat ja V8-integraatio. Se jäsentää DOM-puun, ratkaisee tyylit ja määrittää kaikkien elementtien visuaalisen geometrian.

Kun Blink seuraa jatkuvasti dynaamisia muutoksia animaatiokehysten kautta, Blink maalaa ruudun sisällön. JS-moottori on iso osa selainta - mutta emme ole vielä perehtyneet näihin yksityiskohtiin.

JavaScript-moottori 101

JavaScript-moottori suorittaa ja kokoaa JavaScript natiivikoneen koodiksi. Jokainen suuri selain on kehittänyt oman JS-moottorinsa: Googlen Chrome käyttää V8-versiota, Safari käyttää JavaScriptCorea ja Firefox SpiderMonkeyä.

Työskentelemme erityisesti V8: n kanssa sen käytön vuoksi Node.js: ssä ja Electronissa, mutta muut moottorit rakennetaan samalla tavalla.

Jokainen vaihe sisältää linkin siitä vastaavaan koodiin, jotta voit tutustua koodipohjaan ja jatkaa tutkimusta tämän artikkelin ulkopuolella.

Työskentelemme V8-peilin kanssa GitHubissa, koska se tarjoaa kätevän ja tunnetun käyttöliittymän koodikannan navigointiin.

Lähdekoodin valmistelu

Ensimmäinen asia, jonka V8: n on tehtävä, on ladata lähdekoodi. Tämä voidaan tehdä verkon, välimuistin tai huoltotyöntekijöiden kautta.

Kun koodi on vastaanotettu, meidän on vaihdettava se kääntäjän ymmärrettävällä tavalla. Tätä prosessia kutsutaan jäsentämiseksi ja se koostuu kahdesta osasta: skannerista ja itse jäsentimestä.

Skanneri vie JS-tiedoston ja muuntaa sen tunnettujen tunnusten luetteloksi. Keyword.txt-tiedostossa on luettelo kaikista JS-tunnuksista.

Jäsennys poimii sen ja luo abstraktin syntaksipuun (AST): lähdekoodin puuesityksen. Jokainen puun solmu merkitsee koodissa esiintyvää rakennetta.

Katsotaanpa yksinkertainen esimerkki:

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

Tämä koodi tuottaa seuraavan puurakenteen:

Voit suorittaa tämän koodin suorittamalla ennakkotilauksen (root, left, right):

  1. Määritä footoiminto.
  2. Ilmoita barmuuttuja.
  3. Määritä 1kohteeseen bar.
  4. Palaa barpois toiminnosta.

Näet myös VariableProxy- elementin, joka yhdistää abstraktin muuttujan paikkaan muistissa. Ratkaisuprosessia VariableProxykutsutaan laajuusanalyysiksi .

Esimerkissämme prosessin tulos olisi kaikki VariableProxyosoittavat samaa barmuuttujaa.

Just-in-Time (JIT) -paradigma

Yleensä koodisi suorittamiseksi ohjelmointikieli on muutettava konekoodiksi. On olemassa useita lähestymistapoja siihen, miten ja milloin tämä muutos voi tapahtua.

Yleisin tapa muuttaa koodi on suorittaa ajoissa tapahtuva kääntäminen. Se toimii täsmälleen niin kuin miltä se kuulostaa: koodi muunnetaan konekoodiksi ennen ohjelman suorittamista kokoamisvaiheessa.

Tätä lähestymistapaa käyttävät monet ohjelmointikielet, kuten C ++, Java ja muut.

Taulukon toisella puolella on tulkinta: Koodin kukin rivi suoritetaan ajon aikana. Tätä lähestymistapaa käyttävät yleensä dynaamisesti kirjoitetut kielet, kuten JavaScript ja Python, koska tarkkaa tyyppiä on mahdotonta tietää ennen suoritusta.

Koska ennenaikainen kääntäminen voi arvioida kaikki koodit yhdessä, se voi tarjota paremman optimoinnin ja lopulta tuottaa enemmän suorituskykyistä koodia. Tulkinta on toisaalta yksinkertaisempi toteuttaa, mutta se on yleensä hitaampaa kuin käännetty vaihtoehto.

Koodin muuntamiseksi nopeammin ja tehokkaammin dynaamisille kielille luotiin uusi lähestymistapa nimeltä Just-in-Time (JIT) -kokoelma. Siinä yhdistyvät tulkinnan ja kokoamisen parhaat puolet.

Käyttäessään tulkintaa perusmenetelmänä V8 voi havaita muita useammin käytettyjä toimintoja ja koota ne käyttämällä aikaisempien suoritusten tyyppitietoja.

On kuitenkin mahdollista, että tyyppi voi muuttua. Meidän on sen sijaan poistettava käännetyn koodin optimointi ja tulkinnan tulkinta (sen jälkeen voimme kääntää funktion uudelleen saatuamme uuden tyyppisen palautteen).

Tutkitaan tarkemmin JIT-kokoelman kutakin osaa.

Tulkki

V8 käyttää tulkkia nimeltä Ignition. Aluksi se vie abstraktin syntaksipuun ja tuottaa tavukoodin.

Tavu-koodin ohjeissa on myös metatietoja, kuten lähderivin sijainnit tulevaa virheenkorjausta varten. Yleensä tavukoodin ohjeet vastaavat JS-abstrakteja.

Otetaan nyt esimerkkimme ja luodaan tavut koodi sille manuaalisesti:

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. Siinä vaiheessa moottori alkaa käyttää koodia ja kerätä tyyppipalautetta.
  5. Jotta se toimisi nopeammin, tavukoodi voidaan lähettää optimoivaan kääntäjään palautetietojen kanssa. Optimoiva kääntäjä tekee tiettyjä oletuksia sen perusteella ja tuottaa sitten erittäin optimoidun konekoodin.
  6. Jos jossakin vaiheessa jokin oletuksista osoittautuu virheelliseksi, optimoiva kääntäjä poistaa optimoinnin ja palaa tulkkiin.

Se siitä! Jos sinulla on kysyttävää tietystä vaiheesta tai haluat tietää lisätietoja siitä, voit sukeltaa lähdekoodiin tai osua minuun Twitterissä.

Lisälukemista

  • "Scriptin elämä" -video Googlelta
  • Kaatumiskurssi Mozillan JIT-kääntäjissä
  • Mukava selitys V8: n Inline-välimuisteista
  • Suuri sukellus Object Shapes