fundo

Padrões da Web são um conjunto de regras que o navegador implementa. Eles definem e descrevem aspectos do Rede mundial de computadores.

W3C é uma comunidade internacional que desenvolve padrões abertos para a web. Eles garantem que todos sigam as mesmas diretrizes e não tenham que oferecer suporte a dezenas de ambientes completamente diferentes.

Um navegador moderno é um software bastante complicado com uma base de código de dezenas de milhões de linhas de código. Portanto, ele está dividido em vários módulos responsáveis ​​por diferentes lógicas.

E duas das partes mais importantes de um navegador são o mecanismo JavaScript e um mecanismo de renderização.

Piscar é um mecanismo de renderização responsável por todo o pipeline de renderização, incluindo árvores DOM, estilos, eventos e integração V8. Ele analisa a árvore DOM, resolve estilos e determina a geometria visual de todos os elementos.

Enquanto monitora continuamente as mudanças dinâmicas por meio de quadros de animação, o Blink pinta o conteúdo em sua tela. O mecanismo JS é uma grande parte do navegador - mas ainda não entramos nesses detalhes.

JavaScript Engine 101

O mecanismo JavaScript executa e compila JavaScript em código de máquina nativo. Todos os principais navegadores desenvolveram seu próprio mecanismo JS: o Chrome do Google usa V8, o Safari usa JavaScriptCore e o Firefox usa SpiderMonkey.

Trabalharemos particularmente com o V8 por causa de seu uso em Node.js e Electron, mas outros engines são construídos da mesma maneira.

Cada etapa incluirá um link para o código responsável por ela, para que você possa se familiarizar com a base de código e continuar a pesquisa além deste artigo.

Vamos trabalhar com um espelho do V8 no GitHub pois fornece uma IU conveniente e bem conhecida para navegar na base de código.

Preparando o código fonte

A primeira coisa que o V8 precisa fazer é baixar o código-fonte. Isso pode ser feito por meio de uma rede, cache ou service workers.

Depois que o código é recebido, precisamos alterá-lo de uma forma que o compilador possa entender. Esse processo é chamado de análise e consiste em duas partes: o scanner e o próprio analisador.

O scanner pega o arquivo JS e o converte na lista de tokens conhecidos. Há uma lista de todos os tokens JS em o arquivo keywords.txt.

o analisador pega e cria um Árvore de sintaxe abstrata (AST): uma representação em árvore do código-fonte. Cada nó da árvore denota uma construção que ocorre no código.

Vamos dar uma olhada em um exemplo simples:

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

Este código produzirá a seguinte estrutura de árvore:

ast tree
Exemplo de árvore AST

Você pode executar este código executando uma travessia de pré-ordem (raiz, esquerda, direita):

  1. Defina a foo função.
  2. Declare o bar variável.
  3. Atribuir 1 para bar.
  4. Retorna bar fora da função.

Você também verá VariableProxy – um elemento que conecta a variável abstrata a um lugar na memória. O processo de resolução VariableProxy é chamado Análise de Escopo.

Em nosso exemplo, o resultado do processo seria tudo VariableProxyestá apontando para o mesmo bar variável.

O paradigma Just-in-Time (JIT)

Geralmente, para que seu código seja executado, a linguagem de programação precisa ser transformada em código de máquina. Existem várias abordagens de como e quando essa transformação pode acontecer.

A maneira mais comum de transformar o código é executando a compilação antecipada. Funciona exatamente como parece: o código é transformado em código de máquina antes da execução de seu programa durante a fase de compilação.

Essa abordagem é usada por muitas linguagens de programação, como C ++, Java e outras.

Do outro lado da tabela, temos a interpretação: cada linha do código será executada em tempo de execução. Essa abordagem geralmente é adotada por linguagens com tipos dinâmicos, como JavaScript e Python, porque é impossível saber o tipo exato antes da execução.

Como a compilação antecipada pode avaliar todo o código em conjunto, ela pode fornecer melhor otimização e, eventualmente, produzir código com melhor desempenho. A interpretação, por outro lado, é mais simples de implementar, mas geralmente é mais lenta do que a opção compilada.

Para transformar o código de forma mais rápida e eficaz em linguagens dinâmicas, foi criada uma nova abordagem chamada compilação Just-in-Time (JIT). Combina o melhor da interpretação e compilação.

Enquanto usa a interpretação como método básico, o V8 pode detectar funções que são usadas com mais frequência do que outras e compilá-las usando informações de tipo de execuções anteriores.

No entanto, há uma chance de que o tipo mude. Precisamos desotimizar o código compilado e retornar à interpretação (depois disso, podemos recompilar a função após obter um novo tipo de feedback).

Vamos explorar cada parte da compilação JIT com mais detalhes.

Intérprete

V8 usa um intérprete chamado Ignição. Inicialmente, ele pega uma árvore de sintaxe abstrata e gera o código de bytes.

As instruções de código de byte também têm metadados, como posições de linha de origem para depuração futura. Geralmente, as instruções do código de byte correspondem às abstrações JS.

Agora, vamos pegar nosso exemplo e gerar código de bytes para ele manualmente:

LdaSmi #1 // write 1 to accumulatorStar r0   // read to r0 (bar) from accumulator Ldar r0   // write from r0 (bar) to accumulatorReturn    // returns accumulator

O Ignition tem algo chamado de acumulador – um lugar onde você pode armazenar / ler valores.

O acumulador evita a necessidade de empurrar e estourar o topo da pilha. É também um argumento implícito para muitos códigos de byte e normalmente contém o resultado da operação. Return retorna implicitamente o acumulador.

Você pode verificar todos os códigos de byte disponíveis no código-fonte correspondente. Se você estiver interessado em como outros conceitos JS (como loops e async / await) são apresentados no código de bytes, acho útil ler estes expectativas de teste.

Execução

Após a geração, o Ignition interpretará as instruções usando uma tabela de manipuladores codificados pelo código de byte. Para cada código de byte, o Ignition pode pesquisar funções de manipulador correspondentes e executá-las com os argumentos fornecidos.

Como mencionamos antes, o estágio de execução também fornece o feedback de tipo sobre o código. Vamos descobrir como ele é coletado e gerenciado.

Primeiro, devemos discutir como os objetos JavaScript podem ser representados na memória. Em uma abordagem ingênua, podemos criar um dicionário para cada objeto e vinculá-lo à memória.

naive object
A primeira abordagem para manter o objeto

No entanto, geralmente temos muitos objetos com a mesma estrutura, então não seria eficiente armazenar muitos dicionários duplicados.

Para resolver esse problema, o V8 separa a estrutura do objeto dos próprios valores com Formas de Objeto (ou Mapas internamente) e um vetor de valores na memória.

Por exemplo, criamos um literal de objeto:

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

Na primeira linha, ele produzirá uma forma Map[c] que tem a propriedade x com um deslocamento 0.

Na segunda linha, o V8 reutilizará a mesma forma para uma nova variável.

Após a terceira linha, ele criará uma nova forma Map[c1] para propriedade y com um deslocamento 1 e cria um link para a forma anterior Map[c] .

object shapes 1
Exemplo de formas de objetos

No exemplo acima, você pode ver que cada objeto pode ter um link para a forma do objeto onde, para cada nome de propriedade, o V8 pode encontrar um deslocamento para o valor na memória.

As formas do objeto são essencialmente listas vinculadas. Então, se você escrever c.x, V8 irá para o topo da lista, encontre y lá, vá para a forma conectada e, finalmente, x e lê o deslocamento a partir dele. Em seguida, ele irá para o vetor de memória e retornará o primeiro elemento dele.

Como você pode imaginar, em um grande aplicativo da web, você verá um grande número de formas conectadas. Ao mesmo tempo, leva um tempo linear para pesquisar na lista vinculada, tornando as pesquisas de propriedades uma operação muito cara.

Para resolver este problema no V8, você pode usar o Cache Inline (IC). Ele memoriza informações sobre onde encontrar propriedades em objetos para reduzir o número de pesquisas.

Você pode pensar nisso como um site de escuta em seu código: ele rastreia todos LIGAR, LOJA, e CARGA eventos dentro de uma função e registra todas as formas que passam.

A estrutura de dados para manter IC é chamada Vetor de feedback. É apenas uma matriz para manter todos os ICs para a função.

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

Para a função acima, o vetor de feedback terá a seguinte aparência:

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

É uma função simples com apenas um IC que tem um tipo de LOAD e valor de UNINIT. Isso significa que ele não foi inicializado e não sabemos o que acontecerá a seguir.

Vamos chamar essa função com argumentos diferentes e ver como o cache embutido mudará.

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

Após a primeira chamada do load função, nosso cache embutido obterá um valor atualizado:

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

Esse valor agora se torna monomórfico, o que significa que esse cache só pode resolver para a forma A.

Após a segunda chamada, o V8 verificará o valor do IC e verá que ele é monomórfico e tem o mesmo formato do fast variável. Portanto, ele retornará rapidamente o deslocamento e o resolverá.

Na terceira vez, a forma é diferente da armazenada. Portanto, o V8 o resolverá manualmente e atualizará o valor para um estado polimórfico com uma matriz de duas formas possíveis.

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

Agora, toda vez que chamamos essa função, o V8 precisa verificar não apenas uma forma, mas iterar várias possibilidades.

Para o código mais rápido, você pode inicializar objetos com o mesmo tipo e não alterar muito sua estrutura.

Observação: você pode manter isso em mente, mas não faça isso se isso resultar na duplicação do código ou em um código menos expressivo.

Caches embutidos também controlam quantas vezes são chamados para decidir se é um bom candidato para otimizar o compilador – Turbofan.

Compilador

A ignição só nos leva até certo ponto. Se uma função esquentar o suficiente, ela será otimizada no compilador, Turbofan, para torná-lo mais rápido.

O Turbofan pega o código de byte da Ignição e digita feedback (o Vetor de Feedback) para a função, aplica um conjunto de reduções com base nele e produz o código de máquina.

Como vimos antes, o feedback de tipo não garante que ele não mude no futuro.

Por exemplo, o código otimizado do Turbofan com base na suposição de que alguma adição sempre adiciona números inteiros.

Mas o que aconteceria se recebesse um barbante? Este processo é chamado desotimização. Jogamos fora o código otimizado, voltamos ao código interpretado, retomamos a execução e atualizamos o feedback do tipo.

Resumo

Neste artigo, discutimos a implementação do mecanismo JS e as etapas exatas de como o JavaScript é executado.

Para resumir, vamos dar uma olhada no pipeline de compilação do início.

v8 overview 2
Visão geral do V8

Examinaremos passo a passo:

  1. Tudo começa com a obtenção do código JavaScript da rede.
  2. O V8 analisa o código-fonte e o transforma em uma árvore de sintaxe abstrata (AST).
  3. Com base nesse AST, o interpretador do Ignition pode começar a fazer seu trabalho e a produzir bytecode.
  4. Nesse ponto, o mecanismo começa a executar o código e a coletar feedback de tipo.
  5. Para torná-lo executado mais rápido, o código de byte pode ser enviado para o compilador de otimização junto com os dados de feedback. O compilador de otimização faz certas suposições com base nele e, em seguida, produz um código de máquina altamente otimizado.
  6. Se, em algum ponto, uma das suposições estiver incorreta, o compilador de otimização desotimiza e volta para o interpretador.

É isso aí! Se você tiver alguma dúvida sobre um estágio específico ou quiser saber mais detalhes sobre ele, pode mergulhar no código-fonte ou entrar em contato comigo Twitter.

Leitura adicional