Em nosso artigo anterior, discutimos a última parte relacionada ao JavaScript e engines JavaScript. Agora estamos chegando ao fundo do Node.js, é aqui que as coisas ficam complicadas. Começamos a falar sobre Javascript, que é o conceito de nível mais alto que temos, e entramos em alguns conceitos como: call stack, event loop, heap, filas e assim por diante...
A questão é: nada disso é realmente implementado em JS, isso faz parte do engine. Portanto, o JavaScript é basicamente uma linguagem de tipos dinâmicos que é completamente interpretada, tudo o que executamos em JavaScript é passado para o engine, que interage com seu ambiente e gera o bytecode necessário para a máquina executar nosso programa.
E esse engine é chamado de V8.
O que é o V8?
O V8 é o engine open-source de JavaScript e WebAssembly de alto desempenho do Google. Ele foi escrito em C++ e usado tanto no Chrome quanto em ambientes similares ao Chrome, e no Node.js. O V8 possui a implementação completa para ECMAScript e WebAssembly. Mas ele não depende de um navegador, na verdade, o V8 pode ser executado de forma independente e incorporado a qualquer aplicativo C++.
Overview
O V8 foi projetado inicialmente para aumentar o desempenho da execução do JavaScript em navegadores web - é por isso que o Chrome teve uma enorme diferença de velocidade em comparação com outros navegadores da época. Para alcançar esse desempenho aprimorado, o V8 faz algo diferente do que apenas interpretar o código JavaScript, ele converte esse código em um código de máquina mais eficiente. Ele compila JS para código da máquina em tempo de execução, implementando o que é chamado de compilador JIT (Just In Time).
Atualmente, a maioria dos engines funciona da mesma maneira, a maior diferença entre o V8 e os outros é que ele não produz nenhum código intermediário. Ele executa seu código pela primeira vez usando um primeiro compilador não otimizado chamado Ignition, compila o código diretamente para como deve ser lido; depois de algumas execuções, outro compilador (o compilador JIT) recebe muitas informações sobre como, seu código se comporta na maioria dos casos e recompila o código, otimizando a maneira como está sendo executado naquele momento. Isso é basicamente o que significa "compilar um código em tempo de execução".
Diferentemente de outras linguagens como C++, que usa a compilação AoT (Ahead Of Time), que significa que primeiro compilamos, geramos um executável e, em seguida, executamos. Não há tarefa de compilação no Node.js.
O V8 também usa muitas threads diferentes para ficar mais rápido:
- A thread principal é aquele que busca, compila e executa o código JS
- Outra thread é usada para a otimização, para que a thread principal continue a execução enquanto outra está otimizando o código que está em execução no momento
- Uma terceira thread é usada apenas para criação de perfil, que informa ao runtime quais métodos precisam de otimização
- Algumas outras threads para lidar com garbage collection
Abstract Syntax Trees
O primeiro passo em todos os pipelines de compilação de quase todas as linguagens existentes no mercado é gerar o que é chamado de AST (Abstract Syntax Tree). Uma árvore de sintaxe abstrata é uma representação em árvore da estrutura sintática de um determinado código-fonte em uma forma abstrata, o que significa que, em teoria, poderia ser traduzido para qualquer outro idioma. Cada nó da árvore indica uma construção de linguagem que ocorre no código fonte.
Vamos recapitular nosso código:
const fs = require('fs')
const path = require('path')
const filePath = path.resolve(`../myDir/myFile.md`)
// Parseamos um buffer para string
function callback (data) {
return data.toString()
}
// Transformamos em promise
const readFileAsync = (filePath) => {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (err, data) => {
if (err) return reject(err)
return resolve(callback(data))
})
})
}
(function start () {
readFileAsync(filePath)
.then()
.catch(console.error)
})()
Este é um exemplo de AST (ou parte dele) do nosso código readFile
no formato JSON gerado por uma ferramenta chamada esprima:
{
"type": "Program", // O tipo da nossa AST
"body": [ // O corpo do nosso programa, um índice por linha
{
"type": "VariableDeclaration", // Começamos com uma declaração de variável
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier", // Essa variável é um identificador
"name": "fs" // chamado 'fs'
},
"init": { // Igualamos essa variável a alguma coisa
"type": "CallExpression", // Esta alguma coisa é uma expressão de chamada para uma função
"callee": {
"type": "Identifier", // Que é um identificador
"name": "require" // chamada 'require'
},
"arguments": [ // E nós passamos alguns argumentos para essa função
{
"type": "Literal", // O primeiro deles é um tipo literal (uma string, número e coisas do tipo...)
"value": "fs", // com o valor: 'fs'
"raw": "'fs'"
}
]
}
}
],
"kind": "const" // Por último, falamos que nossa declaração de variável é do tipo 'const'
}
]
}
Então, como podemos ver no JSON, temos uma chave de abertura chamada type
, que indica que nosso código é um Program
e temos seu body
. A chave body
é um array de objetos na qual todo índice representa uma única linha de código. A primeira linha de código que temos é const fs = require ('fs')
, então esse é o primeiro índice do nosso array. Neste primeiro objeto, temos uma chave type
indicando que o que estamos fazendo é uma declaração de variável e as declarações (já que podemos fazerconst a, b = 2
, a chave declarations
é um array, uma para cada variável) para esta variável específica fs
. Temos um tipo
chamadoVariableDeclarator
que identifica que estamos declarando um novo identificador chamado fs
.
Depois disso, estamos inicializando nossa variável, essa é a chave init
, que engloba tudo a partir do sinal =
. A chave init
é outro objeto que define que estamos chamando uma função chamadarequire
e passando um parâmetro literal do valor fs
. Então, basicamente, todo esse JSON define uma única linha do nosso código.
ASTs são a base para todo compilador, pois permite que o compilador transforme uma representação de nível superior (o código) em uma representação de nível inferior (uma árvore), retirando todas as informações inúteis que colocamos em nosso código, como comentários. Além disso, os ASTs permitem que nós, meros programadores, alteremos nosso código, é basicamente isso que o intellisense ou qualquer outro helper de código faz: analisa o AST e, com base no que você escreveu até agora, sugere mais código que pode vir depois do que já está escrito.
Os ASTs também podem ser usados para substituir ou alterar o código rapidamente, por exemplo, podemos substituir todas as instâncias de let
por const
apenas pesquisando as chaves kind
dentro de VariableDeclaration
.
Se os ASTs nos permitem identificar melhorias de performance e analisar nosso código, o mesmo ocorre com os compiladores. Um compilador é basicamente isso: um analisador, otimizador e gerador de código que pode ser executado por uma máquina.
Conclusão
Este é o começo de nossas conversas sobre o V8 e como ele funciona! Nós estaremos falando sobre bytecodes e muitas outras coisas legais! Portanto, fiquem atentos para os próximos capítulos :D
Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!