Explorando os internos do Node.js

Publicados: 2022-03-10
Resumo rápido ↬ Node.js é uma ferramenta interessante para desenvolvedores web. Com seu alto nível de simultaneidade, tornou-se um dos principais candidatos para as pessoas que escolhem ferramentas para usar no desenvolvimento web. Neste artigo, aprenderemos sobre o que compõe o Node.js, daremos uma definição significativa, entenderemos como as partes internas do Node.js interagem entre si e exploraremos o repositório do projeto para Node.js no GitHub.

Desde a introdução do Node.js por Ryan Dahl no European JSConf em 8 de novembro de 2009, ele tem sido amplamente utilizado na indústria de tecnologia. Empresas como Netflix, Uber e LinkedIn dão credibilidade à afirmação de que o Node.js pode suportar uma grande quantidade de tráfego e simultaneidade.

Armados com conhecimento básico, desenvolvedores iniciantes e intermediários do Node.js lutam com muitas coisas: “É apenas um tempo de execução!” “Tem loops de eventos!” “Node.js é single-thread como JavaScript!”

Embora algumas dessas afirmações sejam verdadeiras, vamos nos aprofundar no tempo de execução do Node.js, entender como ele executa JavaScript, ver se ele realmente é single-thread e, finalmente, entender melhor a interconexão entre suas dependências principais, V8 e libuv .

Pré-requisitos

  • Conhecimento básico de JavaScript
  • Familiaridade com a semântica do Node.js ( require , fs )

O que é Node.js?

Pode ser tentador supor o que muitas pessoas acreditam sobre o Node.js, a definição mais comum é que é um tempo de execução para a linguagem JavaScript . Para considerar isso, devemos entender o que levou a essa conclusão.

O Node.js é frequentemente descrito como uma combinação de C++ e JavaScript. A parte C++ consiste em ligações que executam código de baixo nível que possibilita o acesso ao hardware conectado ao computador. A parte JavaScript usa JavaScript como seu código-fonte e o executa em um interpretador popular da linguagem, chamado de mecanismo V8.

Com esse entendimento, poderíamos descrever o Node.js como uma ferramenta exclusiva que combina JavaScript e C++ para executar programas fora do ambiente do navegador.

Mas podemos realmente chamá-lo de tempo de execução? Para determinar isso, vamos definir o que é um tempo de execução.

Em uma de suas respostas no StackOverflow, DJNA define um ambiente de tempo de execução como “tudo o que você precisa para executar um programa, mas nenhuma ferramenta para alterá-lo”. De acordo com essa definição, podemos dizer com confiança que tudo o que está acontecendo enquanto executamos nosso código (em qualquer linguagem) está sendo executado em um ambiente de tempo de execução.

Outras linguagens têm seu próprio ambiente de tempo de execução. Para Java, é o Java Runtime Environment (JRE). Para .NET, é o Common Language Runtime (CLR). Para Erlang, é BEAM.

No entanto, alguns desses runtimes têm outras linguagens que dependem deles. Por exemplo, Java tem Kotlin, uma linguagem de programação que compila para um código que um JRE possa entender. Erlang tem Elixir. E sabemos que existem muitas variantes para o desenvolvimento .NET, todas executadas no CLR, conhecido como .NET Framework.

Agora entendemos que um tempo de execução é um ambiente fornecido para que um programa possa ser executado com êxito e sabemos que a V8 e um host de bibliotecas C++ possibilitam a execução de um aplicativo Node.js. O próprio Node.js é o tempo de execução real que une tudo para tornar essas bibliotecas uma entidade e entende apenas uma linguagem — JavaScript — independentemente de com o que o Node.js é construído.

Mais depois do salto! Continue lendo abaixo ↓

Estrutura interna do Node.js

Quando tentamos executar um programa Node.js (como index.js ) a partir de nossa linha de comando usando o comando node index.js , estamos chamando o runtime do Node.js. Esse tempo de execução, como mencionado, consiste em duas dependências independentes, V8 e libuv.

Dependências principais do Node.js
Dependências principais do Node.js (visualização grande)

V8 é um projeto criado e mantido pelo Google. Ele pega o código-fonte JavaScript e o executa fora do ambiente do navegador. Quando executamos um programa por meio de um comando node , o código-fonte é passado pelo runtime do Node.js para a V8 para execução.

A biblioteca libuv contém código C++ que permite acesso de baixo nível ao sistema operacional. Funcionalidades como rede, gravação no sistema de arquivos e simultaneidade não são fornecidas por padrão na V8, que é a parte do Node.js que executa nosso código JavaScript. Com seu conjunto de bibliotecas, o libuv fornece esses utilitários e muito mais em um ambiente Node.js.

Node.js é a cola que mantém as duas bibliotecas juntas, tornando-se assim uma solução única. Durante a execução de um script, o Node.js entende para qual projeto passar o controle e quando.

APIs interessantes para programas do lado do servidor

Se estudarmos um pouco da história do JavaScript, saberemos que ele serve para adicionar alguma funcionalidade e interação a uma página no navegador. E no navegador, interagimos com os elementos do modelo de objeto do documento (DOM) que compõem a página. Para isso, existe um conjunto de APIs, chamadas coletivamente de API DOM.

O DOM existe apenas no navegador; é o que é analisado para renderizar uma página e é basicamente escrito na linguagem de marcação conhecida como HTML. Além disso, o navegador existe em uma janela, daí o objeto window , que atua como raiz para todos os objetos na página em um contexto JavaScript. Esse ambiente é chamado de ambiente do navegador e é um ambiente de tempo de execução para JavaScript.

APIs Node.js chamam libuv para algumas funções
APIs Node.js interagem com libuv (visualização grande)

Em um ambiente Node.js, não temos nada como uma página, nem um navegador — isso anula nosso conhecimento do objeto global window. O que temos é um conjunto de APIs que interagem com o sistema operacional para fornecer funcionalidades adicionais a um programa JavaScript. Essas APIs para Node.js ( fs , path , buffer , events , HTTP e assim por diante), como as temos, existem apenas para Node.js e são fornecidas pelo Node.js (um tempo de execução) para que possamos pode executar programas escritos para Node.js.

Experimento: Como fs.writeFile cria um novo arquivo

Se o V8 foi criado para executar JavaScript fora do navegador e se um ambiente Node.js não tiver o mesmo contexto ou ambiente de um navegador, como faríamos algo como acessar o sistema de arquivos ou criar um servidor HTTP?

Como exemplo, vamos pegar um aplicativo Node.js simples que grava um arquivo no sistema de arquivos no diretório atual:

 const fs = require("fs") fs.writeFile("./test.txt", "text");

Conforme mostrado, estamos tentando gravar um novo arquivo no sistema de arquivos. Esse recurso não está disponível na linguagem JavaScript; ele está disponível apenas em um ambiente Node.js. Como isso é executado?

Para entender isso, vamos fazer um tour pela base de código do Node.js.

Indo para o repositório GitHub para Node.js, vemos duas pastas principais, src e lib . A pasta lib tem o código JavaScript que fornece o bom conjunto de módulos que são incluídos por padrão em cada instalação do Node.js. A pasta src contém as bibliotecas C++ para libuv.

Se olharmos na pasta lib e passarmos pelo arquivo fs.js , veremos que está cheio de código JavaScript impressionante. Na linha 1880, veremos uma declaração de exports . Essa instrução exporta tudo o que podemos acessar importando o módulo fs , e podemos ver que ela exporta uma função chamada writeFile .

A busca pela function writeFile( (onde a função está definida) nos leva à linha 1303, onde vemos que a função está definida com quatro parâmetros:

 function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }

Nas linhas 1315 e 1324, vemos que uma única função, writeAll , é chamada após algumas verificações de validação. Encontramos esta função na linha 1278 no mesmo arquivo fs.js

 function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }

Também é interessante notar que este módulo está tentando chamar a si mesmo. Vemos isso na linha 1280, onde está chamando fs.write . Procurando a função de write , descobriremos um pouco de informação.

A função de write começa na linha 571 e executa cerca de 42 linhas. Vemos um padrão recorrente nesta função: a forma como ela chama uma função no módulo de binding , como visto nas linhas 594 e 612. Uma função no módulo de binding é chamada não apenas nesta função, mas em praticamente qualquer função exportada no arquivo de arquivo fs.js Algo deve ser muito especial nisso.

A variável de binding é declarada na linha 58, bem no topo do arquivo, e um clique nessa chamada de função revela algumas informações, com a ajuda do GitHub.

Declaração da variável de ligação
Declaração da variável de vinculação (visualização grande)

Essa função internalBinding é encontrada no módulo chamado loaders. A principal função do módulo loaders é carregar todas as bibliotecas libuv e conectá-las através do projeto V8 com o Node.js. Como ele faz isso é bastante mágico, mas para aprender mais podemos olhar de perto a função writeBuffer que é chamada pelo módulo fs .

Devemos olhar onde isso se conecta com o libuv e onde entra o V8. No topo do módulo de carregadores, uma boa documentação diz o seguinte:

 // This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.

O que aprendemos aqui é que para cada módulo chamado do objeto de binding na seção JavaScript do projeto Node.js, há um equivalente dele na seção C++, na pasta src .

Em nosso tour fs , vemos que o módulo que faz isso está localizado em node_file.cc . Cada função acessível através do módulo é definida no arquivo; por exemplo, temos o writeBuffer na linha 2258. A definição real desse método no arquivo C++ está na linha 1785. Além disso, a chamada para a parte do libuv que faz a gravação real no arquivo pode ser encontrada nas linhas 1809 e 1815, onde a função uv_fs_write é chamada de forma assíncrona.

O que ganhamos com esse entendimento?

Assim como muitos outros runtimes de linguagem interpretada, o runtime do Node.js pode ser hackeado. Com maior compreensão, poderíamos fazer coisas impossíveis com a distribuição padrão apenas examinando a fonte. Poderíamos adicionar bibliotecas para fazer alterações na forma como algumas funções são chamadas. Mas, acima de tudo, esse entendimento é uma base para uma exploração posterior.

O Node.js é de thread único?

Sentado no libuv e no V8, o Node.js tem acesso a algumas funcionalidades adicionais que um mecanismo JavaScript típico executado no navegador não possui.

Qualquer JavaScript executado em um navegador será executado em um único thread. Uma thread na execução de um programa é como uma caixa preta em cima da CPU na qual o programa está sendo executado. Em um contexto Node.js, algum código pode ser executado em quantas threads nossas máquinas podem carregar.

Para verificar essa afirmação específica, vamos explorar um snippet de código simples.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });

No trecho acima, estamos tentando criar um novo arquivo no disco no diretório atual. Para ver quanto tempo isso pode levar, adicionamos um pequeno benchmark para monitorar a hora de início do script, que nos dá a duração em milissegundos do script que está criando o arquivo.

Se executarmos o código acima, obteremos um resultado como este:

Resultado do tempo que leva para criar um único arquivo no Node.js
Tempo necessário para criar um único arquivo no Node.js (visualização grande)
 $ node ./test.js -> 1 Done: 0.003s

Isso é muito impressionante: apenas 0,003 segundos.

Mas vamos fazer algo realmente interessante. Primeiro vamos duplicar o código que gera o novo arquivo e atualizar o número na instrução de log para refletir suas posições:

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });

Se tentarmos executar esse código, obteremos algo que nos surpreenderá. Aqui está o meu resultado:

Resultado do tempo que leva para criar vários arquivos
Criando muitos arquivos de uma só vez (visualização grande)

Primeiro, vamos notar que os resultados não são consistentes. Em segundo lugar, vemos que o tempo aumentou. O que está acontecendo?

Tarefas de baixo nível são delegadas

Node.js é single-thread, como sabemos agora. Partes do Node.js são escritas em JavaScript e outras em C++. O Node.js usa os mesmos conceitos de loop de eventos e pilha de chamadas com os quais estamos familiarizados no ambiente do navegador, o que significa que as partes JavaScript do Node.js são de thread único. Mas a tarefa de baixo nível que requer falar com um sistema operacional não é single-thread.

Tarefas de baixo nível são delegadas ao sistema operacional por meio da libuv
Delegação de tarefas de baixo nível do Node.js (visualização grande)

Quando uma chamada é reconhecida pelo Node.js como destinada ao libuv, ele delega essa tarefa ao libuv. Em sua operação, a libuv requer threads para algumas de suas bibliotecas, daí o uso do pool de threads na execução de programas Node.js quando necessário.

Por padrão, o pool de encadeamentos Node.js fornecido pelo libuv possui quatro encadeamentos. Poderíamos aumentar ou reduzir esse pool de threads chamando process.env.UV_THREADPOOL_SIZE na parte superior de nosso script.

 // script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …

O que acontece com nosso programa de criação de arquivos

Parece que uma vez que invocamos o código para criar nosso arquivo, o Node.js atinge a parte libuv de seu código, que dedica uma thread para essa tarefa. Esta seção no libuv obtém algumas informações estatísticas sobre o disco antes de trabalhar no arquivo.

Essa verificação estatística pode demorar um pouco para ser concluída; portanto, o encadeamento é liberado para algumas outras tarefas até que a verificação estatística seja concluída. Quando a verificação é concluída, a seção libuv ocupa qualquer encadeamento disponível ou espera até que um encadeamento fique disponível para ele.

Temos apenas quatro chamadas e quatro encadeamentos, então há encadeamentos suficientes para todos. A única questão é quão rápido cada thread processará sua tarefa. Notaremos que o primeiro código a entrar no pool de threads retornará seu resultado primeiro e bloqueará todos os outros threads enquanto executa seu código.

Conclusão

Agora entendemos o que é o Node.js. Sabemos que é um tempo de execução. Definimos o que é um tempo de execução. E nos aprofundamos no que compõe o tempo de execução fornecido pelo Node.js.

Percorremos um longo caminho. E a partir de nosso pequeno tour pelo repositório Node.js no GitHub, podemos explorar qualquer API em que possamos estar interessados, seguindo o mesmo processo que fizemos aqui. Node.js é open source, então certamente podemos mergulhar na fonte, não podemos?

Embora tenhamos abordado vários dos níveis baixos do que acontece no tempo de execução do Node.js, não devemos presumir que sabemos tudo. Os recursos abaixo apontam algumas informações sobre as quais podemos construir nosso conhecimento:

  • Introdução ao Node.js
    Sendo um site oficial, o Node.dev explica o que é o Node.js, bem como seus gerenciadores de pacotes, e lista os frameworks web construídos sobre ele.
  • “JavaScript & Node.js”, O livro para iniciantes do Node
    Este livro de Manuel Kiessling faz um trabalho fantástico ao explicar o Node.js, depois de avisar que o JavaScript no navegador não é o mesmo do Node.js, embora ambos sejam escritos na mesma linguagem.
  • Iniciando o Node.js
    Este livro para iniciantes vai além de uma explicação do tempo de execução. Ele ensina sobre pacotes e fluxos e como criar um servidor web com o framework Express.
  • LibUV
    Esta é a documentação oficial do código C++ de suporte do runtime do Node.js.
  • V8
    Esta é a documentação oficial do mecanismo JavaScript que possibilita escrever Node.js com JavaScript.