Bagaimana JavaScript Berfungsi: Di ​​Bawah Enjin V8

Hari ini kita akan melihat di bawah enjin V8 JavaScript dan mengetahui bagaimana sebenarnya JavaScript dilaksanakan.

Dalam artikel sebelumnya, kami mengetahui bagaimana penyemak imbas disusun dan mendapat gambaran keseluruhan Chromium peringkat tinggi. Mari merakam sedikit supaya kita bersedia menyelam di sini.

Latar belakang

Piawaian Web adalah sekumpulan peraturan yang dilaksanakan oleh penyemak imbas. Mereka menentukan dan menerangkan aspek World Wide Web.

W3C adalah komuniti antarabangsa yang mengembangkan standard terbuka untuk Web. Mereka memastikan bahawa setiap orang mengikuti garis panduan yang sama dan tidak perlu menyokong puluhan persekitaran yang sama sekali berbeza.

Penyemak imbas moden adalah perisian yang rumit dengan pangkalan data berpuluh-puluh juta baris kod. Oleh itu, ia terbahagi kepada banyak modul yang bertanggungjawab untuk logik yang berbeza.

Dan dua bahagian penyemak imbas yang paling penting adalah mesin JavaScript dan mesin rendering.

Blink adalah mesin rendering yang bertanggung jawab untuk keseluruhan saluran rendering termasuk pohon DOM, gaya, acara, dan integrasi V8. Ia menguraikan pokok DOM, menyelesaikan gaya, dan menentukan geometri visual semua elemen.

Walaupun terus memantau perubahan dinamis melalui bingkai animasi, Blink melukis konten di layar anda. Enjin JS adalah sebahagian besar penyemak imbas - tetapi kita belum mengetahui butirannya.

Enjin JavaScript 101

Mesin JavaScript menjalankan dan menyusun JavaScript ke dalam kod mesin asli. Setiap penyemak imbas utama telah mengembangkan mesin JS sendiri: Chrome Google menggunakan V8, Safari menggunakan JavaScriptCore, dan Firefox menggunakan SpiderMonkey.

Kami akan bekerja terutamanya dengan V8 kerana penggunaannya di Node.js dan Electron, tetapi enjin lain dibina dengan cara yang sama.

Setiap langkah akan merangkumi pautan ke kod yang bertanggungjawab untuknya, sehingga anda dapat membiasakan pangkalan kode dan meneruskan penyelidikan di luar artikel ini.

Kami akan menggunakan cermin V8 di GitHub kerana ia menyediakan UI yang mudah dan terkenal untuk menavigasi pangkalan data.

Menyiapkan kod sumber

Perkara pertama yang perlu dilakukan V8 ialah memuat turun kod sumber. Ini dapat dilakukan melalui rangkaian, cache, atau pekerja perkhidmatan.

Setelah kod diterima, kita perlu mengubahnya dengan cara yang dapat difahami oleh penyusun. Proses ini disebut parsing dan terdiri daripada dua bahagian: pengimbas dan penghurai itu sendiri.

Pengimbas mengambil fail JS dan menukarnya ke senarai token yang diketahui. Terdapat senarai semua token JS dalam fail Keywordss.txt.

Pengurai mengambilnya dan membuat Abstrak Syntax Tree (AST): perwakilan pokok kod sumber. Setiap simpul pokok menunjukkan konstruk yang berlaku dalam kod.

Mari lihat contoh ringkas:

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

Kod ini akan menghasilkan struktur pokok berikut:

Anda boleh melaksanakan kod ini dengan melakukan traversal preorder (root, kiri, kanan):

  1. Tentukan foofungsi.
  2. Menyatakan barpemboleh ubah.
  3. Tugaskan 1kepada bar.
  4. Kembali barkeluar dari fungsi.

Anda juga akan melihat VariableProxy- elemen yang menghubungkan pemboleh ubah abstrak ke tempat dalam ingatan. Proses penyelesaian VariableProxydisebut Analisis Skop .

Dalam contoh kita, hasil dari proses tersebut akan VariableProxymenunjukkan barpemboleh ubah yang sama .

Paradigma Just-in-Time (JIT)

Secara amnya, agar kod anda dapat dilaksanakan, bahasa pengaturcaraan perlu diubah menjadi kod mesin. Terdapat beberapa pendekatan bagaimana dan kapan transformasi ini dapat berlaku.

Cara yang paling biasa untuk mengubah kod adalah dengan melakukan penyusunan lebih awal. Ia berfungsi sebagaimana mestinya: kod tersebut diubah menjadi kod mesin sebelum pelaksanaan program anda semasa peringkat penyusunan.

Pendekatan ini digunakan oleh banyak bahasa pengaturcaraan seperti C ++, Java, dan lain-lain.

Di sisi lain jadual, kami mempunyai tafsiran: setiap baris kod akan dijalankan pada waktu runtime. Pendekatan ini biasanya diambil oleh bahasa yang ditaip secara dinamik seperti JavaScript dan Python kerana mustahil untuk mengetahui jenis yang tepat sebelum dijalankan.

Kerana penyusunan sebelumnya dapat menilai semua kod itu bersama-sama, ia dapat memberikan pengoptimuman yang lebih baik dan akhirnya menghasilkan kod yang lebih berprestasi. Tafsiran, di sisi lain, lebih mudah dilaksanakan, tetapi biasanya lebih lambat daripada pilihan yang disusun.

Untuk mengubah kod dengan lebih pantas dan berkesan untuk bahasa dinamik, pendekatan baru dibuat yang disebut kompilasi Just-in-Time (JIT). Ia menggabungkan yang terbaik dari tafsiran dan penyusunan.

Walaupun menggunakan tafsiran sebagai kaedah asas, V8 dapat mengesan fungsi yang digunakan lebih kerap daripada yang lain dan menyusunnya menggunakan maklumat jenis dari pelaksanaan sebelumnya.

Namun, ada kemungkinan jenisnya mungkin berubah. Kita perlu mengoptimumkan kod yang dikompilasi dan mundur kepada tafsiran (selepas itu, kita dapat menyusun semula fungsi setelah mendapat maklum balas jenis baru).

Mari kaji setiap bahagian penyusunan JIT dengan lebih terperinci.

Jurubahasa

V8 menggunakan jurubahasa yang dipanggil Ignition. Pada mulanya, ia memerlukan pokok sintaks abstrak dan menghasilkan kod bait.

Arahan kod bait juga mempunyai metadata, seperti kedudukan baris sumber untuk penyahpepijatan masa depan. Secara amnya, arahan kod bait sesuai dengan abstraksi JS.

Sekarang mari kita ambil contoh dan menghasilkan kod bait untuknya secara manual:

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

Pencucuhan mempunyai sesuatu yang disebut penumpuk - tempat di mana anda boleh menyimpan / membaca nilai.

Penumpuk mengelakkan keperluan untuk mendorong dan membuka bahagian atas timbunan. Ini juga merupakan argumen tersirat untuk banyak kod bait dan biasanya menghasilkan hasil operasi. Return secara implisit mengembalikan penumpuk.

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