Comparando uma promessa do Node.js

Publicados: 2022-03-11

Estamos vivendo em um admirável mundo novo. Um mundo cheio de JavaScript. Nos últimos anos, o JavaScript dominou a web, conquistando toda a indústria. Após a introdução do Node.js, a comunidade JavaScript foi capaz de utilizar a simplicidade e a dinamicidade da linguagem para ser a única linguagem para fazer tudo, lidando com o lado do servidor, o lado do cliente e até ousou e reivindicou uma posição para aprendizado de máquina. Mas o JavaScript mudou drasticamente como linguagem nos últimos anos. Novos conceitos foram introduzidos que nunca existiam antes, como funções de seta e promessas.

Ah, promessas. Todo o conceito de promessa e retorno de chamada não fazia muito sentido para mim quando comecei a aprender Node.js. Eu estava acostumado com a forma procedural de executar código, mas com o tempo entendi por que isso era importante.

Isso nos leva à pergunta: por que os retornos de chamada e as promessas foram introduzidos de qualquer maneira? Por que não podemos simplesmente escrever código executado sequencialmente em JavaScript?

Bem, tecnicamente você pode. Mas você deveria?

Neste artigo, darei uma breve introdução sobre JavaScript e seu tempo de execução e, mais importante, testarei a crença generalizada na comunidade JavaScript de que o código síncrono é inferior em desempenho e, em certo sentido, simplesmente mal, e nunca deve ser ser usado. Esse mito é realmente verdade?

Antes de começar, este artigo pressupõe que você já esteja familiarizado com as promessas em JavaScript, no entanto, se não estiver ou precisar de uma atualização, consulte Promessas de JavaScript: um tutorial com exemplos

NB Este artigo foi testado em um ambiente Node.js, não em um ambiente JavaScript puro. Executando o Node.js versão 10.14.2. Todos os benchmarks e sintaxes dependerão muito do Node.js. Os testes foram executados em um MacBook Pro 2018 com um processador Intel i5 de 8ª geração Quad-Core com velocidade de clock base de 2,3 GHz.

O ciclo de eventos

Promessa de benchmarking do Node.js: ilustração do loop de eventos do Node.js

O problema de escrever JavaScript é que a linguagem em si é single threaded. Isso significa que você não pode executar mais de um único procedimento por vez, ao contrário de outras linguagens, como Go ou Ruby, que têm a capacidade de gerar threads e executar vários procedimentos ao mesmo tempo, seja em threads de kernel ou em threads de processo .

Para executar o código, o JavaScript depende de um procedimento chamado loop de eventos, que é composto de vários estágios. O processo JavaScript passa por cada etapa e, no final, começa tudo de novo. Você pode ler mais sobre os detalhes no guia oficial do node.js aqui.

Mas o JavaScript tem algo na manga para combater o problema de bloqueio. retornos de chamada de E/S.

A maioria dos casos de uso da vida real que exigem que criemos um thread é o fato de que estamos solicitando alguma ação pela qual a linguagem não é responsável - por exemplo, solicitar uma busca de alguns dados do banco de dados. Em linguagens multithread, a thread que criou a solicitação simplesmente trava ou espera pela resposta do banco de dados. Isso é apenas um desperdício de recursos. Também sobrecarrega o desenvolvedor ao escolher o número correto de threads em um pool de threads. Isso é para evitar vazamentos de memória e a alocação de muitos recursos quando o aplicativo está em alta demanda.

O JavaScript se destaca em uma coisa mais do que em qualquer outro fator, manipulação de operações de E/S. JavaScript permite que você chame uma operação de E/S, como solicitar dados de um banco de dados, ler um arquivo na memória, gravar um arquivo em disco, executar um comando shell, etc. Quando a operação for concluída, você executa um retorno de chamada. Ou no caso de promessas, você resolve a promessa com o resultado ou a rejeita com um erro.

A comunidade de JavaScript sempre nos aconselha a nunca usar código síncrono ao fazer operações de E/S. A razão bem conhecida para isso é que NÃO queremos impedir que nosso código execute outras tarefas. Como é single-thread, se tivermos um pedaço de código que lê um arquivo de forma síncrona, o código bloqueará todo o processo até que a leitura seja concluída. Em vez disso, se dependermos de código assíncrono, podemos fazer várias operações de E/S e lidar com a resposta de cada operação individualmente quando estiver concluída. Nada de bloqueio.

Mas certamente em um ambiente onde não nos importamos em lidar com muitos processos, usar código síncrono e assíncrono não faz diferença alguma, certo?

Referência

O teste que vamos executar terá como objetivo nos fornecer benchmarks sobre a rapidez com que o código sincronizado e assíncrono é executado e se há uma diferença no desempenho.

Decidi escolher a leitura de um arquivo como a operação de E/S a ser testada.

Primeiro, escrevi uma função que escreverá um arquivo aleatório preenchido com bytes aleatórios gerados com o módulo Node.js Crypto.

 const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )

Este arquivo funcionaria como uma constante para nosso próximo passo que é ler o arquivo. Aqui está o código

 const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()

A execução do código anterior resultou nos seguintes resultados:

Corre # Sincronizar Assíncrono Relação Assíncrona/Sincronizada
1 0,278 ms 3,829 ms 13.773
2 0,335 ms 3,801 ms 11.346
3 0,403 ms 4,498 ms 11.161

Isso foi inesperado. Minhas expectativas iniciais eram de que eles deveriam levar o mesmo tempo. Bem, que tal adicionarmos outro arquivo e lermos 2 arquivos em vez de 1?

Eu repliquei o arquivo gerado test.txt e o chamei de test2.txt. Segue o código atualizado:

 function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }

Simplesmente adicionei outra leitura para cada uma delas, e em promessas, fiquei aguardando as promessas de leitura que deveriam estar rodando em paralelo. Estes foram os resultados:

Corre # Sincronizar Assíncrono Relação Assíncrona/Sincronizada
1 1,659 ms 6,895 ms 4.156
2 0,323 ms 4,048 ms 12.533
3 0,324 ms 4,017 ms 12.398
4 0,333 ms 4,271 ms 12.826

A primeira tem valores completamente diferentes das 3 execuções que se seguem. Meu palpite é que está relacionado ao compilador JavaScript JIT que otimiza o código em cada execução.

Então, as coisas não parecem tão boas para funções assíncronas. Talvez se tornarmos as coisas mais dinâmicas e talvez estressarmos um pouco mais o aplicativo, possamos obter um resultado diferente.

Então, meu próximo teste envolve escrever 100 arquivos diferentes e depois ler todos eles.

Primeiro, modifiquei o código para escrever 100 arquivos antes da execução do teste. Os arquivos são diferentes em cada execução, embora mantendo quase o mesmo tamanho, portanto, limpamos os arquivos antigos antes de cada execução.

Segue o código atualizado:

 let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }

E para limpeza e execução:

 let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()

E vamos correr.

Segue a tabela de resultados:

Corre # Sincronizar Assíncrono Relação Assíncrona/Sincronizada
1 4,999 ms 12,890 ms 2.579
2 5,077 ms 16,267 ms 3.204
3 5,241 ms 14,571 ms 2.780
4 5,086 ms 16,334 ms 3.213

Esses resultados começam a tirar uma conclusão aqui. Indica que com o aumento da demanda ou da simultaneidade, as promessas de overhead começam a fazer sentido. Para fins de elaboração, se estivermos executando um servidor da Web que deve executar centenas ou talvez milhares de solicitações por segundo por servidor, a execução de operações de E/S usando sincronização começará a perder seu benefício rapidamente.

Apenas por uma questão de experimentação, vamos ver se é realmente um problema com as próprias promessas ou é outra coisa. Para isso, escrevi uma função que irá calcular o tempo para resolver uma promessa que não faz absolutamente nada e outra que resolve 100 promessas vazias.

Aqui está o código:

 function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
Corre # Promessa única 100 promessas
1 1,651 ms 3,293 ms
2 0,758 ms 2,575 ms
3 0,814 ms 3,127 ms
4 0,788 ms 2,623 ms

Interessante. Parece que as promessas não são a principal causa do atraso, o que me faz adivinhar que a fonte do atraso são os threads do kernel fazendo a leitura real. Isso pode levar um pouco mais de experimentação para chegar a uma conclusão decisiva sobre a principal razão por trás do atraso.

Uma palavra final

Então você deve usar promessas ou não? Minha opinião seria a seguinte:

Se você estiver escrevendo um script que será executado em uma única máquina com um fluxo específico acionado por um pipeline ou um único usuário, use o código de sincronização. Se você estiver escrevendo um servidor da Web que será responsável por lidar com muito tráfego e solicitações, a sobrecarga que vem da execução assíncrona superará o desempenho do código de sincronização.

Você pode encontrar o código para todas as funções neste artigo no repositório.

A próxima etapa lógica em sua jornada de desenvolvedor JavaScript, a partir de promessas, é a sintaxe async/await. Se você quiser saber mais sobre isso e como chegamos aqui, consulte JavaScript Asynchronous: From Callback Hell to Async and Await .