Cómo funciona JavaScript: bajo el capó del motor V8

Hoy veremos bajo el capó del motor V8 de JavaScript y descubriremos cómo se ejecuta exactamente JavaScript.

En un artículo anterior, aprendimos cómo está estructurado el navegador y obtuvimos una descripción general de alto nivel de Chromium. Recapitulemos un poco para que estemos listos para sumergirnos aquí.

Antecedentes

Los estándares web son un conjunto de reglas que implementa el navegador. Definen y describen aspectos de la World Wide Web.

W3C es una comunidad internacional que desarrolla estándares abiertos para la Web. Se aseguran de que todos sigan las mismas pautas y no tengan que admitir docenas de entornos completamente diferentes.

Un navegador moderno es una pieza de software bastante complicada con una base de código de decenas de millones de líneas de código. Entonces está dividido en muchos módulos responsables de diferentes lógicas.

Y dos de las partes más importantes de un navegador son el motor JavaScript y un motor de renderizado.

Blink es un motor de renderizado que es responsable de todo el proceso de renderizado, incluidos árboles DOM, estilos, eventos e integración V8. Analiza el árbol DOM, resuelve estilos y determina la geometría visual de todos los elementos.

Mientras monitorea continuamente los cambios dinámicos a través de cuadros de animación, Blink pinta el contenido en su pantalla. El motor JS es una gran parte del navegador, pero aún no hemos entrado en esos detalles.

Motor de JavaScript 101

El motor de JavaScript ejecuta y compila JavaScript en código de máquina nativo. Todos los navegadores importantes han desarrollado su propio motor JS: Chrome de Google usa V8, Safari usa JavaScriptCore y Firefox usa SpiderMonkey.

Trabajaremos particularmente con V8 debido a su uso en Node.js y Electron, pero otros motores se construyen de la misma manera.

Cada paso incluirá un enlace al código responsable de él, para que pueda familiarizarse con el código base y continuar la investigación más allá de este artículo.

Trabajaremos con un espejo de V8 en GitHub, ya que proporciona una interfaz de usuario conveniente y conocida para navegar por el código base.

Preparando el código fuente

Lo primero que debe hacer V8 es descargar el código fuente. Esto se puede hacer a través de una red, caché o trabajadores del servicio.

Una vez que se recibe el código, debemos cambiarlo de una manera que el compilador pueda entender. Este proceso se llama análisis y consta de dos partes: el analizador y el analizador en sí.

El escáner toma el archivo JS y lo convierte en la lista de tokens conocidos. Hay una lista de todos los tokens JS en el archivo keywords.txt.

El analizador lo recoge y crea un árbol de sintaxis abstracta (AST): una representación de árbol del código fuente. Cada nodo del árbol denota una construcción que ocurre en el código.

Echemos un vistazo a un ejemplo simple:

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

Este código producirá la siguiente estructura de árbol:

Puede ejecutar este código ejecutando un recorrido de preorden (raíz, izquierda, derecha):

  1. Defina la foofunción.
  2. Declare la barvariable.
  3. Asignar 1a bar.
  4. Vuelve barfuera de la función.

También verá VariableProxy: un elemento que conecta la variable abstracta con un lugar en la memoria. El proceso de resolución VariableProxyse llama Análisis de alcance .

En nuestro ejemplo, el resultado del proceso sería todo VariableProxys apuntando a la misma barvariable.

El paradigma Just-in-Time (JIT)

Generalmente, para que su código se ejecute, el lenguaje de programación debe transformarse en código de máquina. Hay varios enfoques sobre cómo y cuándo puede ocurrir esta transformación.

La forma más común de transformar el código es realizar una compilación anticipada. Funciona exactamente como suena: el código se transforma en código máquina antes de la ejecución de su programa durante la etapa de compilación.

Este enfoque lo utilizan muchos lenguajes de programación como C ++, Java y otros.

En el otro lado de la tabla, tenemos la interpretación: cada línea del código se ejecutará en tiempo de ejecución. Este enfoque suele ser adoptado por lenguajes de tipo dinámico como JavaScript y Python porque es imposible saber el tipo exacto antes de la ejecución.

Debido a que la compilación anticipada puede evaluar todo el código en conjunto, puede proporcionar una mejor optimización y, finalmente, producir un código de mayor rendimiento. La interpretación, por otro lado, es más simple de implementar, pero generalmente es más lenta que la opción compilada.

Para transformar el código de forma más rápida y eficaz para lenguajes dinámicos, se creó un nuevo enfoque llamado compilación Just-in-Time (JIT). Combina lo mejor de la interpretación y la compilación.

Si bien utiliza la interpretación como método base, V8 puede detectar funciones que se utilizan con más frecuencia que otras y compilarlas utilizando información de tipo de ejecuciones anteriores.

Sin embargo, existe la posibilidad de que el tipo cambie. Necesitamos desoptimizar el código compilado y recurrir a la interpretación en su lugar (después de eso, podemos volver a compilar la función después de recibir comentarios de nuevos tipos).

Exploremos cada parte de la compilación JIT con más detalle.

Interprete

V8 utiliza un intérprete llamado Ignition. Inicialmente, toma un árbol de sintaxis abstracto y genera código de bytes.

Las instrucciones de código de bytes también tienen metadatos, como posiciones de línea de origen para futuras depuraciones. Generalmente, las instrucciones de código de bytes coinciden con las abstracciones de JS.

Ahora tomemos nuestro ejemplo y generemos un código de bytes para él manualmente:

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