Kako JavaScript funkcionira: ispod poklopca motora V8

Danas ćemo pogledati ispod haube JavaScript V8 motora i shvatiti kako se točno izvršava JavaScript.

U prethodnom članku saznali smo kako je preglednik strukturiran i dobili smo pregled Chromiuma na visokoj razini. Sažmimo se malo pa ćemo biti spremni zaroniti ovdje.

Pozadina

Web standardi su skup pravila koja preglednik primjenjuje. Oni definiraju i opisuju aspekte World Wide Weba.

W3C je međunarodna zajednica koja razvija otvorene standarde za web. Oni osiguravaju da svi slijede iste smjernice i da ne moraju podržavati desetke potpuno različitih okruženja.

Suvremeni preglednik prilično je kompliciran softver s bazom kodova od desetaka milijuna redaka koda. Dakle, podijeljen je u puno modula odgovornih za drugačiju logiku.

A dva najvažnija dijela preglednika su JavaScript i mehanizam za prikazivanje.

Blink je mehanizam za prikazivanje koji je odgovoran za cjelokupni cjevovod prikazivanja, uključujući DOM stabla, stilove, događaje i integraciju V8. Analizira DOM stablo, rješava stilove i određuje vizualnu geometriju svih elemenata.

Dok neprestano nadgleda dinamičke promjene putem okvira za animaciju, Blink oslikava sadržaj na vašem zaslonu. JS motor velik je dio preglednika - ali još nismo ušli u te detalje.

JavaScript Engine 101

JavaScript mehanizam izvršava i kompajlira JavaScript u izvorni strojni kod. Svaki veći preglednik razvio je svoj JS engine: Googleov Chrome koristi V8, Safari JavaScriptCore, a Firefox SpiderMonkey.

Posebno ćemo surađivati ​​s V8 zbog njegove upotrebe u Node.js-u i Electronu, ali i ostali su motori napravljeni na isti način.

Svaki će korak sadržavati vezu do koda koji je za to odgovoran, tako da se možete upoznati s bazom koda i nastaviti istraživanje dalje od ovog članka.

Radit ćemo sa zrcalom V8 na GitHubu jer pruža prikladno i dobro poznato korisničko sučelje za navigaciju bazom koda.

Priprema izvornog koda

Prvo što V8 mora učiniti je preuzeti izvorni kod. To se može učiniti putem mreže, predmemorije ili uslužnih djelatnika.

Nakon primanja koda, trebamo ga promijeniti na način da ga prevodilac može razumjeti. Taj se postupak naziva raščlanjivanje i sastoji se od dva dijela: skenera i samog parsera.

Skener uzima JS datoteku i pretvara je na popis poznatih tokena. U datoteci keywords.txt nalazi se popis svih JS tokena.

Analizator ga uzima i stvara apstraktno sintaksno stablo (AST): prikaz stabla izvornog koda. Svaki čvor stabla označava konstrukciju koja se javlja u kodu.

Pogledajmo jednostavan primjer:

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

Ovaj će kôd proizvesti sljedeću strukturu stabla:

Ovaj kôd možete izvršiti izvršavanjem preusmjeravanja predbilježbe (korijen, lijevo, desno):

  1. Definirajte foofunkciju.
  2. Proglasite barvarijablu.
  3. Dodjela 1se bar.
  4. Povratak bariz funkcije.

Također ćete vidjeti VariableProxy- element koji povezuje apstraktnu varijablu s mjestom u memoriji. Proces rješavanja VariableProxynaziva se Analiza opsega .

U našem primjeru, rezultat postupka bio bi VariableProxys ukazivanjem na istu barvarijablu.

Paradigma Just-in-Time (JIT)

Općenito, da bi se vaš kôd izvršio, programski jezik treba transformirati u strojni kod. Postoji nekoliko pristupa kako i kada se ta transformacija može dogoditi.

Najčešći način transformacije koda je izvođenje kompilacije ispred vremena. Djeluje točno onako kako zvuči: kôd se pretvara u strojni kod prije izvođenja vašeg programa tijekom faze kompilacije.

Ovaj pristup koriste mnogi programski jezici kao što su C ++, Java i drugi.

S druge strane tablice imamo tumačenje: svaki redak koda izvršit će se za vrijeme izvođenja. Ovaj pristup obično koriste dinamički upisani jezici poput JavaScript i Python, jer je nemoguće znati točan tip prije izvršenja.

Budući da prevremena kompilacija može zajedno procijeniti sav kôd, može pružiti bolju optimizaciju i na kraju proizvesti učinkovitiji kôd. S druge strane, tumačenje je jednostavnije implementirati, ali obično je sporije od kompilirane opcije.

Da bi se kod brže i učinkovitije transformirao za dinamičke jezike, stvoren je novi pristup nazvan Just-in-Time (JIT) kompilacija. Kombinira najbolje iz interpretacije i kompilacije.

Dok koristi interpretaciju kao osnovnu metodu, V8 može otkriti funkcije koje se koriste češće od ostalih i sastaviti ih koristeći informacije o tipu iz prethodnih izvršavanja.

Međutim, postoji šansa da se vrsta promijeni. Umjesto toga moramo de-optimizirati kompajlirani kod i vratiti se na interpretaciju (nakon toga funkciju možemo ponovno kompilirati nakon što dobijemo povratnu informaciju o novom tipu).

Istražimo detaljnije svaki dio JIT-ove kompilacije.

Tumač

V8 koristi tumač zvan Paljenje. U početku je potrebno apstraktno stablo sintakse i generira bajtni kod.

Upute bajt koda također imaju metapodatke, poput položaja izvorne linije za buduće ispravljanje pogrešaka. Općenito, upute bajt koda odgovaraju JS apstrakcijama.

Uzmimo sada naš primjer i ručno generiramo bajt kôd za njega:

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. At that point, the engine starts running the code and collecting type feedback.
  5. To make it run faster, the byte code can be sent to the optimizing compiler along with feedback data. The optimizing compiler makes certain assumptions based on it and then produces highly-optimized machine code.
  6. If, at some point, one of the assumptions turns out to be incorrect, the optimizing compiler de-optimizes and goes back to the interpreter.

That’s it! If you have any questions about a specific stage or want to know more details about it, you can dive into source code or hit me up on Twitter.

Further reading

  • “Life of a script” video from Google
  • A crash course in JIT compilers from Mozilla
  • Nice explanation of Inline Caches in V8
  • Great dive in Object Shapes