Como Usar Multithreading no Node.js

O autor selecionou Open Sourcing Mental Illness para receber uma doação como parte do programa Write for Donations.

Introdução

O Node.js executa código JavaScript em uma única thread, o que significa que seu código só pode fazer uma tarefa por vez. No entanto, o Node.js em si é multithread e fornece threads ocultas através da biblioteca libuv, que lida com operações de I/O como leitura de arquivos de disco ou solicitações de rede. Através do uso de threads ocultas, o Node.js fornece métodos assíncronos que permitem que seu código faça solicitações de I/O sem bloquear a thread principal.

Embora o Node.js tenha threads ocultas, você não pode usá-las para descarregar tarefas intensivas em CPU, como cálculos complexos, redimensionamento de imagens ou compressão de vídeo. Como o JavaScript é de thread única, quando uma tarefa intensiva em CPU é executada, ela bloqueia a thread principal e nenhum outro código é executado até que a tarefa seja concluída. Sem usar outras threads, a única maneira de acelerar uma tarefa vinculada à CPU é aumentar a velocidade do processador.

No entanto, nos últimos anos, as CPUs não têm ficado mais rápidas. Em vez disso, os computadores estão sendo enviados com núcleos extras, e agora é mais comum os computadores terem 8 ou mais núcleos. Apesar dessa tendência, seu código não aproveitará os núcleos extras do seu computador para acelerar as tarefas ligadas à CPU ou evitar a quebra da thread principal porque o JavaScript é single-threaded.

Para remediar isso, o Node.js introduziu o módulo worker-threads, que permite que você crie threads e execute várias tarefas JavaScript em paralelo. Uma vez que uma thread conclui uma tarefa, ela envia uma mensagem para a thread principal que contém o resultado da operação para que possa ser usado com outras partes do código. A vantagem de usar threads de trabalhador é que as tarefas ligadas à CPU não bloqueiam a thread principal e você pode dividir e distribuir uma tarefa para vários trabalhadores para otimizá-la.

Neste tutorial, você criará um aplicativo Node.js com uma tarefa intensiva de CPU que bloqueia a thread principal. Em seguida, você usará o módulo worker-threads para descarregar a tarefa intensiva de CPU para outra thread para evitar o bloqueio da thread principal. Por fim, você dividirá a tarefa ligada à CPU e terá quatro threads trabalhando nela em paralelo para acelerar a tarefa.

Pré-requisitos

Para concluir este tutorial, você precisará:

Configurando o Projeto e Instalando Dependências

Nesta etapa, você irá criar o diretório do projeto, inicializar o npm e instalar todas as dependências necessárias.

Para começar, crie e mova-se para o diretório do projeto:

  1. mkdir multi-threading_demo
  2. cd multi-threading_demo

O comando mkdir cria um diretório e o comando cd muda o diretório de trabalho para o recém-criado.

Em seguida, inicialize o diretório do projeto com o npm usando o comando npm init:

  1. npm init -y

A opção -y aceita todas as opções padrão.

Quando o comando é executado, a saída será semelhante a isso:

Wrote to /home/sammy/multi-threading_demo/package.json:

{
  "name": "multi-threading_demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Em seguida, instale o express, um framework web Node.js:

  1. npm install express

Você utilizará o Express para criar uma aplicação de servidor com endpoints bloqueadores e não bloqueadores.

O Node.js é fornecido com o módulo worker-threads por padrão, então você não precisa instalá-lo.

Agora você instalou os pacotes necessários. Em seguida, você aprenderá mais sobre processos e threads e como eles são executados em um computador.

Compreendendo Processos e Threads

Antes de começar a escrever tarefas ligadas à CPU e a delegá-las a threads separadas, você primeiro precisa entender o que são processos e threads, e as diferenças entre eles. Mais importante ainda, você revisará como os processos e threads são executados em um sistema de computador de um ou vários núcleos.

Processo

A process is a running program in the operating system. It has its own memory and cannot see nor access the memory of other running programs. It also has an instruction pointer, which indicates the instruction currently being executed in a program. Only one task can be executed at a time.

Para entender isso, você criará um programa Node.js com um loop infinito para que ele não saia quando for executado.

Usando o nano, ou o seu editor de texto preferido, crie e abra o arquivo process.js:

  1. nano process.js

No seu arquivo process.js, insira o seguinte código:

multi-threading_demo/process.js
const process_name = process.argv.slice(2)[0];

count = 0;
while (true) {
  count++;
  if (count == 2000 || count == 4000) {
    console.log(`${process_name}: ${count}`);
  }
}

Na primeira linha, a propriedade process.argv retorna uma matriz contendo os argumentos da linha de comando do programa. Em seguida, você anexa o método slice() do JavaScript com um argumento de 2 para fazer uma cópia superficial da matriz a partir do índice 2 em diante. Fazendo isso, você pula os dois primeiros argumentos, que são o caminho do Node.js e o nome do arquivo do programa. Em seguida, você usa a sintaxe de notação de colchetes para recuperar o primeiro argumento da matriz fatiada e armazená-lo na variável process_name.

Depois disso, você define um loop while e passa uma condição true para fazê-lo rodar infinitamente. Dentro do loop, a variável count é incrementada por 1 a cada iteração. Seguindo isso, há uma instrução if que verifica se count é igual a 2000 ou 4000. Se a condição for verdadeira, o método console.log() registra uma mensagem no terminal.

Salve e feche seu arquivo usando CTRL+X, depois pressione Y para salvar as alterações.

Execute o programa usando o comando node:

  1. node process.js A &

A is a command-line argument that is passed to the program and stored in the process_name variable. The & at end the allows the Node program to run in the background, which lets you enter more commands in the shell.

Ao executar o programa, você verá uma saída semelhante à seguinte:

Output
[1] 7754 A: 2000 A: 4000

O número 7754 é um ID de processo que o sistema operacional atribuiu a ele. A: 2000 e A: 4000 são as saídas do programa.

Ao executar um programa usando o comando node, você cria um processo. O sistema operacional aloca memória para o programa, localiza o executável do programa no disco do seu computador e carrega o programa na memória. Em seguida, ele atribui a ele um ID de processo e começa a executar o programa. Nesse ponto, seu programa agora se tornou um processo.

Quando o processo está em execução, seu ID de processo é adicionado à lista de processos do sistema operacional e pode ser visto com ferramentas como htop, top ou ps. As ferramentas fornecem mais detalhes sobre os processos, bem como opções para interrompê-los ou priorizá-los.

Para obter um resumo rápido de um processo Node, pressione ENTER no seu terminal para obter o prompt de volta. Em seguida, execute o comando ps para ver os processos Node:

  1. ps |grep node

O comando ps lista todos os processos associados ao usuário atual no sistema. O operador de pipe | para passar toda a saída do ps para o filtro grep filtra os processos para listar apenas os processos Node.

Executar o comando produzirá uma saída semelhante à seguinte:

Output
7754 pts/0 00:21:49 node

Você pode criar inúmeros processos a partir de um único programa. Por exemplo, use o seguinte comando para criar mais três processos com argumentos diferentes e colocá-los em segundo plano:

  1. node process.js B & node process.js C & node process.js D &

No comando, você criou mais três instâncias do programa process.js. O símbolo & coloca cada processo em segundo plano.

Ao executar o comando, a saída será semelhante à seguinte (embora a ordem possa ser diferente):

Output
[2] 7821 [3] 7822 [4] 7823 D: 2000 D: 4000 B: 2000 B: 4000 C: 2000 C: 4000

Como você pode ver na saída, cada processo registrou o nome do processo no terminal quando a contagem atingiu 2000 e 4000. Cada processo não está ciente de nenhum outro processo em execução: o processo D não está ciente do processo C, e vice-versa. Qualquer coisa que aconteça em qualquer um dos processos não afetará outros processos Node.js.

Se você examinar a saída de perto, verá que a ordem da saída não é a mesma ordem que você teve quando criou os três processos. Ao executar o comando, os argumentos dos processos estavam na ordem de \code{B}, \code{C} e \code{D}. Mas agora, a ordem é \code{D}, \code{B} e \code{C}. A razão é que o sistema operacional possui algoritmos de agendamento que decidem qual processo será executado na CPU em um determinado momento.

Em uma máquina de núcleo único, os processos são executados concorrentemente. Ou seja, o sistema operacional alterna entre os processos em intervalos regulares. Por exemplo, o processo \code{D} é executado por um tempo limitado, então seu estado é salvo em algum lugar e o SO agenda o processo \code{B} para ser executado por um tempo limitado, e assim por diante. Isso acontece de forma alternada até que todas as tarefas tenham sido concluídas. A partir da saída, pode parecer que cada processo foi executado até o final, mas na realidade, o agendador do SO está constantemente alternando entre eles.

Em um sistema de vários núcleos — supondo que você tenha quatro núcleos —, o SO agenda cada processo para ser executado em cada núcleo ao mesmo tempo. Isso é conhecido como paralelismo. No entanto, se você criar quatro processos adicionais (totalizando oito), cada núcleo executará dois processos simultaneamente até que sejam concluídos.

Threads

Threads são como processos: eles têm seu próprio ponteiro de instrução e podem executar uma tarefa JavaScript de cada vez. Ao contrário dos processos, as threads não têm sua própria memória. Em vez disso, residem na memória de um processo. Quando você cria um processo, ele pode ter várias threads criadas com o módulo worker_threads executando código JavaScript em paralelo. Além disso, as threads podem se comunicar entre si por meio de passagem de mensagem ou compartilhando dados na memória do processo. Isso os torna leves em comparação com os processos, já que criar uma thread não solicita mais memória do sistema operacional.

Quando se trata da execução de threads, elas têm comportamento semelhante ao dos processos. Se você tiver várias threads em execução em um sistema de núcleo único, o sistema operacional alternará entre elas em intervalos regulares, dando a cada thread a chance de ser executada diretamente na CPU única. Em um sistema de vários núcleos, o sistema operacional agendará as threads em todos os núcleos e executará o código JavaScript ao mesmo tempo. Se você acabar criando mais threads do que há núcleos disponíveis, cada núcleo executará várias threads simultaneamente.

Com isso, pressione ENTER e, em seguida, pare todos os processos Node atualmente em execução com o comando kill:

  1. sudo kill -9 `pgrep node`

pgrep retorna os IDs de processo de todos os quatro processos Node para o comando kill. A opção -9 instrui o kill a enviar um sinal SIGKILL.

Ao executar o comando, você verá uma saída semelhante à seguinte:

Output
[1] Killed node process.js A [2] Killed node process.js B [3] Killed node process.js C [4] Killed node process.js D

Às vezes, a saída pode ser atrasada e aparecer quando você executar outro comando mais tarde.

Agora que você sabe a diferença entre um processo e uma thread, você trabalhará com threads ocultas do Node.js na próxima seção.

Compreendendo Threads Ocultas no Node.js

O Node.js fornece threads extras, é por isso que é considerado multithreaded. Nesta seção, você examinará as threads ocultas no Node.js, que ajudam a tornar as operações de E/S não bloqueantes.

Como mencionado na introdução, o JavaScript é single-threaded e todo o código JavaScript é executado em uma única thread. Isso inclui o código-fonte do seu programa e bibliotecas de terceiros que você inclui em seu programa. Quando um programa faz uma operação de E/S para ler um arquivo ou uma solicitação de rede, isso bloqueia a thread principal.

No entanto, o Node.js implementa a biblioteca `libuv`, que fornece quatro threads extras para um processo Node.js. Com essas threads, as operações de I/O são tratadas separadamente e, quando são concluídas, o loop de eventos adiciona o retorno de chamada associado à tarefa de I/O em uma fila de microtarefas. Quando a pilha de chamadas na thread principal está limpa, o retorno de chamada é empurrado para a pilha de chamadas e então é executado. Para deixar isso claro, o retorno de chamada associado à tarefa de I/O dada não é executado em paralelo; no entanto, a própria tarefa de ler um arquivo ou uma solicitação de rede acontece em paralelo com a ajuda das threads. Uma vez que a tarefa de I/O termina, o retorno de chamada é executado na thread principal.

Além dessas quatro threads, o motor `V8` também fornece duas threads para lidar com coisas como coleta de lixo automática. Isso eleva o número total de threads em um processo para sete: uma thread principal, quatro threads do Node.js e duas threads do V8.

Para confirmar que cada processo Node.js tem sete threads, execute o arquivo `process.js` novamente e coloque-o em segundo plano:

  1. node process.js A &

O terminal registrará o ID do processo, bem como a saída do programa:

Output
[1] 9933 A: 2000 A: 4000

Observe o ID do processo em algum lugar e pressione ENTER para que você possa usar o prompt novamente.

Para ver as threads, execute o comando `top` e passe o ID do processo exibido na saída:

  1. top -H -p 9933

O `-H` instrui o `top` a exibir threads em um processo. A bandeira `-p` instrui o `top` a monitorar apenas a atividade no ID do processo fornecido.

Ao executar o comando, a saída será semelhante ao seguinte:

Output
top - 09:21:11 up 15:00, 1 user, load average: 0.99, 0.60, 0.26 Threads: 7 total, 1 running, 6 sleeping, 0 stopped, 0 zombie %Cpu(s): 24.8 us, 0.3 sy, 0.0 ni, 75.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st MiB Mem : 7951.2 total, 6756.1 free, 248.4 used, 946.7 buff/cache MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7457.4 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 9933 node-us+ 20 0 597936 51864 33956 R 99.9 0.6 4:19.64 node 9934 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node 9935 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.84 node 9936 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node 9937 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.93 node 9938 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node 9939 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node

Como você pode ver na saída, o processo Node.js tem um total de sete threads: uma thread principal para executar o JavaScript, quatro threads Node.js e duas threads V8.

Conforme discutido anteriormente, as quatro threads Node.js são usadas para operações de E/S para torná-las não bloqueantes. Elas funcionam bem para essa tarefa e criar threads por conta própria para operações de E/S pode até piorar o desempenho do seu aplicativo. O mesmo não pode ser dito sobre tarefas vinculadas à CPU. Uma tarefa vinculada à CPU não faz uso de nenhuma thread extra disponível no processo e bloqueia a thread principal.

Agora pressione q para sair do top e interromper o processo Node com o seguinte comando:

  1. kill -9 9933

Agora que você sabe sobre as threads em um processo Node.js, você escreverá uma tarefa vinculada à CPU na próxima seção e observará como ela afeta a thread principal.

Criando uma Tarefa Vinculada à CPU sem Threads de Trabalhador

Nesta seção, você criará um aplicativo Express que tem uma rota não bloqueante e uma rota bloqueante que executa uma tarefa vinculada à CPU.

Primeiro, abra o arquivo index.js no seu editor preferido:

  1. nano index.js

No seu arquivo index.js, adicione o seguinte código para criar um servidor básico:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

No bloco de código a seguir, você cria um servidor HTTP usando o Express. Na primeira linha, você importa o módulo express. Em seguida, você define a variável app para armazenar uma instância do Express. Depois disso, você define a variável port, que mantém o número da porta em que o servidor deve escutar.

Seguindo isso, você usa app.get('/non-blocking') para definir a rota para qual as solicitações GET devem ser enviadas. Por fim, você invoca o método app.listen() para instruir o servidor a começar a escutar na porta 3000.

Em seguida, defina outra rota, /blocking/, que conterá uma tarefa intensiva de CPU:

multi-threading_demo/index.js
...
app.get("/blocking", async (req, res) => {
  let counter = 0;
  for (let i = 0; i < 20_000_000_000; i++) {
    counter++;
  }
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Você define a rota /blocking usando app.get("/blocking"), que recebe um retorno de chamada assíncrono prefixado com a palavra-chave async como segundo argumento, que executa uma tarefa intensiva de CPU. Dentro do retorno de chamada, você cria um loop for que itera 20 bilhões de vezes e durante cada iteração, ele incrementa a variável counter em 1. Essa tarefa é executada na CPU e levará alguns segundos para ser concluída.

Neste ponto, seu arquivo index.js agora terá a seguinte aparência:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

app.get("/blocking", async (req, res) => {
  let counter = 0;
  for (let i = 0; i < 20_000_000_000; i++) {
    counter++;
  }
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Salve e saia do seu arquivo, e então inicie o servidor com o seguinte comando:

  1. node index.js

Quando você executar o comando, verá uma saída semelhante à seguinte:

Output
App listening on port 3000

Isso mostra que o servidor está em execução e pronto para atender.

Agora, visite http://localhost:3000/non-blocking em seu navegador preferido. Você verá uma resposta instantânea com a mensagem Esta página é não-bloqueante.

Nota: Se você estiver seguindo o tutorial em um servidor remoto, pode usar o encaminhamento de porta para testar o aplicativo no navegador.

Enquanto o servidor Express ainda estiver em execução, abra outro terminal em seu computador local e digite o seguinte comando:

  1. ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip

Ao se conectar ao servidor, navegue até http://localhost:3000/non-blocking no navegador da web do seu computador local. Mantenha o segundo terminal aberto durante o restante deste tutorial.

Em seguida, abra uma nova guia e visite http://localhost:3000/blocking. À medida que a página carrega, abra rapidamente mais duas guias e visite http://localhost:3000/non-blocking novamente. Você verá que não receberá uma resposta instantânea, e as páginas continuarão tentando carregar. Somente após o carregamento da rota /blocking e retornar uma resposta result is 20000000000 é que o restante das rotas retornará uma resposta.

A razão pela qual todas as rotas /non-blocking não funcionam conforme a rota /blocking carrega é por causa do loop for vinculado à CPU, que bloqueia o thread principal. Quando o thread principal está bloqueado, o Node.js não pode atender a nenhuma solicitação até que a tarefa vinculada à CPU seja concluída. Portanto, se o seu aplicativo tiver milhares de solicitações GET simultâneas para a rota /non-blocking, uma única visita à rota /blocking é suficiente para tornar todas as rotas do aplicativo não responsivas.

Como você pode ver, bloquear a thread principal pode prejudicar a experiência do usuário com o seu aplicativo. Para resolver esse problema, será necessário transferir a tarefa vinculada à CPU para outra thread, para que a thread principal possa continuar a lidar com outras solicitações HTTP.

Com isso, pare o servidor pressionando CTRL+C. Você iniciará o servidor novamente na próxima seção após fazer mais alterações no arquivo index.js. A razão pela qual o servidor é interrompido é que o Node.js não atualiza automaticamente quando são feitas novas alterações no arquivo.

Agora que você compreende o impacto negativo que uma tarefa intensiva em CPU pode ter em sua aplicação, você tentará evitar bloquear a thread principal usando promessas.

Transferindo uma Tarefa Vinculada à CPU Usando Promessas

Frequentemente, quando os desenvolvedores percebem o efeito de bloqueio causado por tarefas vinculadas à CPU, recorrem às promessas para tornar o código não bloqueante. Esse instinto decorre do conhecimento do uso de métodos de I/O baseados em promessas e não bloqueantes, como readFile() e writeFile(). Mas, como você aprendeu, as operações de I/O fazem uso de threads ocultas do Node.js, o que as tarefas vinculadas à CPU não fazem. No entanto, nesta seção, você envolverá a tarefa vinculada à CPU em uma promessa na tentativa de torná-la não bloqueante. Isso não funcionará, mas ajudará você a ver o valor do uso de threads de trabalho, o que você fará na próxima seção.

Abra novamente o arquivo index.js no seu editor:

  1. nano index.js

No seu arquivo `index.js`, remova o código destacado contendo a tarefa intensiva em CPU.

multi-threading_demo/index.js
...
app.get("/blocking", async (req, res) => {
  let counter = 0;
  for (let i = 0; i < 20_000_000_000; i++) {
    counter++;
  }
  res.status(200).send(`result is ${counter}`);
});
...

Em seguida, adicione o seguinte código destacado contendo uma função que retorna uma promessa:

multi-threading_demo/index.js
...
function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 20_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

app.get("/blocking", async (req, res) => {
  res.status(200).send(`result is ${counter}`);
}

A função `calculateCount()` agora contém os cálculos que você tinha na função de manipulador `/blocking`. A função retorna uma promessa, que é inicializada com a sintaxe `new Promise`. A promessa recebe um retorno de chamada com os parâmetros `resolve` e `reject`, que lidam com sucesso ou falha. Quando o loop `for` termina de ser executado, a promessa é resolvida com o valor na variável `counter`.

Em seguida, chame a função `calculateCount()` na função de manipulador `/blocking/` no arquivo `index.js`:

multi-threading_demo/index.js
app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

Aqui você chama a função `calculateCount()` com a palavra-chave `await` prefixada para aguardar a resolução da promessa. Uma vez que a promessa é resolvida, a variável `counter` é definida com o valor resolvido.

Seu código completo agora terá a seguinte aparência:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 20_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Salve e saia do seu arquivo, então inicie o servidor novamente:

  1. node index.js

No seu navegador da web, visite `http://localhost:3000/blocking` e, enquanto carrega, recarregue rapidamente as guias `http://localhost:3000/non-blocking`. Como você notará, as rotas `non-blocking` ainda são afetadas e todas elas aguardarão a conclusão do carregamento da rota `/blocking`. Como as rotas ainda são afetadas, as promessas não fazem com que o código JavaScript seja executado em paralelo e não podem ser usadas para tornar tarefas ligadas à CPU não bloqueantes.

Com isso, pare o servidor de aplicativos com CTRL+C.

Agora que você sabe que promessas não fornecem nenhum mecanismo para tornar as tarefas dependentes da CPU não bloqueantes, você usará o módulo worker-threads do Node.js para transferir uma tarefa dependente da CPU para uma thread separada.

Transferindo uma Tarefa Dependente da CPU com o Módulo worker-threads

Nesta seção, você transferirá uma tarefa intensiva para a CPU para outra thread usando o módulo worker-threads para evitar o bloqueio da thread principal. Para fazer isso, você criará um arquivo worker.js que conterá a tarefa intensiva para a CPU. No arquivo index.js, você usará o módulo worker-threads para inicializar a thread e iniciar a tarefa no arquivo worker.js para ser executada em paralelo com a thread principal. Assim que a tarefa for concluída, a thread trabalhadora enviará uma mensagem contendo o resultado de volta para a thread principal.

Para começar, verifique se você tem 2 ou mais núcleos usando o comando nproc:

  1. nproc
Output
4

Se mostrar dois ou mais núcleos, você pode prosseguir com esta etapa.

Em seguida, crie e abra o arquivo worker.js no seu editor de texto:

  1. nano worker.js

No seu arquivo worker.js, adicione o seguinte código para importar o módulo worker-threads e realizar a tarefa intensiva para a CPU:

multi-threading_demo/worker.js
const { parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
  counter++;
}

A primeira linha carrega o módulo worker_threads e extrai a classe parentPort. A classe fornece métodos que você pode usar para enviar mensagens para a thread principal. Em seguida, você tem a tarefa intensiva em CPU que está atualmente na função calculateCount() no arquivo index.js. Mais tarde neste passo, você excluirá esta função de index.js.

Seguindo isso, adicione o código destacado abaixo:

multi-threading_demo/worker.js
const { parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
  counter++;
}

parentPort.postMessage(counter);

Aqui você invoca o método postMessage() da classe parentPort, que envia uma mensagem para a thread principal contendo o resultado da tarefa vinculada à CPU armazenada na variável counter.

Salve e saia do seu arquivo. Abra index.js no seu editor de texto:

  1. nano index.js

Como você já tem a tarefa vinculada à CPU em worker.js, remova o código destacado de index.js:

multi-threading_demo/index.js
const express = require("express");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

function calculateCount() {
  return new Promise((resolve, reject) => {
    let counter = 0;
    for (let i = 0; i < 20_000_000_000; i++) {
      counter++;
    }
    resolve(counter);
  });
}

app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Em seguida, no retorno de chamada app.get("/blocking"), adicione o seguinte código para inicializar a thread:

multi-threading_demo/index.js
const express = require("express");
const { Worker } = require("worker_threads");
...
app.get("/blocking", async (req, res) => {
  const worker = new Worker("./worker.js");
  worker.on("message", (data) => {
    res.status(200).send(`result is ${data}`);
  });
  worker.on("error", (msg) => {
    res.status(404).send(`An error occurred: ${msg}`);
  });
});
...

Primeiro, você importa o módulo worker_threads e desempacota a classe Worker. Dentro do retorno de chamada app.get("/blocking"), você cria uma instância do Worker usando a palavra-chave new seguida por uma chamada para Worker com o caminho do arquivo worker.js como argumento. Isso cria uma nova thread e o código no arquivo worker.js começa a ser executado na thread em outro núcleo.

Seguindo isso, você anexa um evento à instância do worker usando o método on("message") para ouvir o evento de mensagem. Quando a mensagem é recebida contendo o resultado do arquivo worker.js, ela é passada como parâmetro para o retorno de chamada do método, que retorna uma resposta ao usuário contendo o resultado da tarefa de CPU.

Em seguida, você anexa outro evento à instância do worker usando o método on("error") para ouvir o evento de erro. Se ocorrer um erro, o retorno de chamada retorna uma resposta 404 contendo a mensagem de erro de volta para o usuário.

Seu arquivo completo agora terá a seguinte aparência:

multi-threading_demo/index.js
const express = require("express");
const { Worker } = require("worker_threads");

const app = express();
const port = process.env.PORT || 3000;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

app.get("/blocking", async (req, res) => {
  const worker = new Worker("./worker.js");
  worker.on("message", (data) => {
    res.status(200).send(`result is ${data}`);
  });
  worker.on("error", (msg) => {
    res.status(404).send(`An error occurred: ${msg}`);
  });
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Salve e saia do seu arquivo, depois execute o servidor:

  1. node index.js

Acesse a guia http://localhost:3000/blocking novamente em seu navegador da web. Antes que termine de carregar, atualize todas as guias http://localhost:3000/non-blocking. Você deverá notar agora que elas estão carregando instantaneamente sem esperar a rota /blocking terminar de carregar. Isso ocorre porque a tarefa de CPU foi transferida para outra thread, e a thread principal lida com todas as solicitações recebidas.

Agora, pare seu servidor usando CTRL+C.

Agora que você pode tornar uma tarefa intensiva em CPU não bloqueante usando uma thread de trabalhador, você usará quatro threads de trabalhador para melhorar o desempenho da tarefa intensiva em CPU.

Otimizando uma Tarefa Intensiva em CPU Usando Quatro Threads de Trabalho

Nesta seção, você dividirá a tarefa intensiva em CPU entre quatro threads de trabalho para que possam concluir a tarefa mais rapidamente e reduzir o tempo de carregamento da rota /blocking.

Para ter mais threads de trabalho trabalhando na mesma tarefa, será necessário dividir as tarefas. Como a tarefa envolve um loop de 20 bilhões de vezes, você dividirá 20 bilhões pelo número de threads que deseja usar. Neste caso, são 4. Calcular 20_000_000_000 / 4 resultará em 5_000_000_000. Assim, cada thread realizará o loop de 0 a 5_000_000_000 e incrementará o counter em 1. Quando cada thread terminar, enviará uma mensagem para a thread principal contendo o resultado. Uma vez que a thread principal receber mensagens de todas as quatro threads separadamente, você combinará os resultados e enviará uma resposta ao usuário.

Você também pode usar a mesma abordagem se tiver uma tarefa que itera sobre grandes arrays. Por exemplo, se quisesse redimensionar 800 imagens em um diretório, pode criar um array contendo todos os caminhos dos arquivos de imagem. Em seguida, divida 800 por 4 (o número de threads) e faça com que cada thread trabalhe em uma faixa. A thread um redimensionará imagens do índice do array 0 até 199, a thread dois do índice 200 até 399, e assim por diante.

Primeiro, verifique se você possui quatro ou mais núcleos:

  1. nproc
Output
4

Faça uma cópia do arquivo worker.js usando o comando cp:

  1. cp worker.js four_workers.js

Os arquivos atuais index.js e worker.js permanecerão intactos para que você possa executá-los novamente e comparar seu desempenho com as alterações nesta seção posteriormente.

Em seguida, abra o arquivo four_workers.js no seu editor de texto:

  1. nano four_workers.js

No seu arquivo four_workers.js, adicione o código destacado para importar o objeto workerData:

multi-threading_demo/four_workers.js
const { workerData, parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000 / workerData.thread_count; i++) {
  counter++;
}

parentPort.postMessage(counter);

Primeiro, extraia o objeto WorkerData, que conterá os dados transmitidos pela thread principal quando a thread for inicializada (o que você fará em breve no arquivo index.js). O objeto tem uma propriedade thread_count que contém o número de threads, que é 4. Em seguida, no loop for, o valor 20_000_000_000 é dividido por 4, resultando em 5_000_000_000.

Salve e feche seu arquivo, em seguida, copie o arquivo index.js:

  1. cp index.js index_four_workers.js

Abra o arquivo index_four_workers.js no seu editor:

  1. nano index_four_workers.js

No seu arquivo index_four_workers.js, adicione o código destacado para criar uma instância de thread:

multi-threading_demo/index_four_workers.js
...
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
...
function createWorker() {
  return new Promise(function (resolve, reject) {
    const worker = new Worker("./four_workers.js", {
      workerData: { thread_count: THREAD_COUNT },
    });
  });
}

app.get("/blocking", async (req, res) => {
  ...
})
...

Primeiro, defina a constante THREAD_COUNT contendo o número de threads que você deseja criar. Mais tarde, quando tiver mais núcleos no seu servidor, a escala envolverá a alteração do valor de THREAD_COUNT para o número desejado de threads.

A seguir, a função createWorker() cria e retorna uma promessa. Dentro do retorno da promessa, você inicializa uma nova thread passando para a classe Worker o caminho do arquivo four_workers.js como primeiro argumento. Em seguida, você passa um objeto como segundo argumento. Depois, você atribui ao objeto a propriedade workerData, que tem outro objeto como seu valor. Por fim, você atribui ao objeto a propriedade thread_count, cujo valor é o número de threads na constante THREAD_COUNT. O objeto workerData é aquele que você referenciou anteriormente no arquivo workers.js.

Para garantir que a promessa seja resolvida ou lance um erro, adicione as seguintes linhas destacadas:

multi-threading_demo/index_four_workers.js
...
function createWorker() {
  return new Promise(function (resolve, reject) {
    const worker = new Worker("./four_workers.js", {
      workerData: { thread_count: THREAD_COUNT },
    });
    worker.on("message", (data) => {
      resolve(data);
    });
    worker.on("error", (msg) => {
      reject(`An error ocurred: ${msg}`);
    });
  });
}
...

Quando a thread do worker envia uma mensagem para a thread principal, a promessa é resolvida com os dados retornados. No entanto, se ocorrer um erro, a promessa retorna uma mensagem de erro.

Agora que você definiu a função que inicializa uma nova thread e retorna os dados da thread, você usará a função em app.get("/blocking") para iniciar novas threads.

Mas primeiro, remova o seguinte trecho de código destacado, pois você já definiu essa funcionalidade na função createWorker():

multi-threading_demo/index_four_workers.js
...
app.get("/blocking", async (req, res) => {
  const worker = new Worker("./worker.js");
  worker.on("message", (data) => {
    res.status(200).send(`result is ${data}`);
  });
  worker.on("error", (msg) => {
    res.status(404).send(`An error ocurred: ${msg}`);
  });
});
...

Com o código removido, adicione o seguinte código para inicializar quatro threads de trabalho:

multi-threading_demo/index_four_workers.js
...
app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }
});
...

Primeiro, você cria uma variável workerPromises, que contém uma array vazia. Em seguida, você itera tantas vezes quanto o valor em THREAD_COUNT, que é 4. Durante cada iteração, você chama a função createWorker() para criar uma nova thread. Então, você adiciona o objeto de promessa retornado pela função na array workerPromises usando o método push do JavaScript. Quando o loop termina, a variável workerPromises terá quatro objetos de promessa, cada um retornado pela chamada da função createWorker() quatro vezes.

Agora, adicione o seguinte código destacado abaixo para aguardar as promessas serem resolvidas e retornar uma resposta ao usuário:

multi-threading_demo/index_four_workers.js
app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }

  const thread_results = await Promise.all(workerPromises);
  const total =
    thread_results[0] +
    thread_results[1] +
    thread_results[2] +
    thread_results[3];
  res.status(200).send(`result is ${total}`);
});

Já que a array workerPromises contém promessas retornadas pela chamada de createWorker(), você prefixa o método Promise.all() com a sintaxe await e chama o método all() com workerPromises como seu argumento. O método Promise.all() aguarda todas as promessas na array serem resolvidas. Quando isso acontece, a variável thread_results contém os valores que as promessas resolveram. Como os cálculos foram divididos entre quatro workers, você os adiciona todos juntos obtendo cada valor de thread_results usando a sintaxe de notação de colchetes. Uma vez adicionado, você retorna o valor total para a página.

Seu arquivo completo agora deve parecer com isso:

multi-threading_demo/index_four_workers.js
const express = require("express");
const { Worker } = require("worker_threads");

const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;

app.get("/non-blocking/", (req, res) => {
  res.status(200).send("This page is non-blocking");
});

function createWorker() {
  return new Promise(function (resolve, reject) {
    const worker = new Worker("./four_workers.js", {
      workerData: { thread_count: THREAD_COUNT },
    });
    worker.on("message", (data) => {
      resolve(data);
    });
    worker.on("error", (msg) => {
      reject(`An error ocurred: ${msg}`);
    });
  });
}

app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }
  const thread_results = await Promise.all(workerPromises);
  const total =
    thread_results[0] +
    thread_results[1] +
    thread_results[2] +
    thread_results[3];
  res.status(200).send(`result is ${total}`);
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Salve e feche seu arquivo. Antes de executar este arquivo, primeiro execute index.js para medir seu tempo de resposta:

  1. node index.js

Em seguida, abra um novo terminal no seu computador local e insira o seguinte comando curl, que mede quanto tempo leva para obter uma resposta da rota /blocking:

  1. time curl --get http://localhost:3000/blocking

O comando time mede quanto tempo o comando curl é executado. O comando curl envia uma solicitação HTTP para a URL fornecida e a opção --get instrui o curl a fazer uma solicitação GET.

Quando o comando é executado, a saída será semelhante a esta:

Output
real 0m28.882s user 0m0.018s sys 0m0.000s

A saída destacada mostra que leva cerca de 28 segundos para obter uma resposta, o que pode variar no seu computador.

Em seguida, pare o servidor com CTRL+C e execute o arquivo index_four_workers.js:

  1. node index_four_workers.js

Visite novamente a rota /blocking no seu segundo terminal:

  1. time curl --get http://localhost:3000/blocking

Você verá uma saída consistente com o seguinte:

Output
real 0m8.491s user 0m0.011s sys 0m0.005s

A saída mostra que leva cerca de 8 segundos, o que significa que você reduziu o tempo de carga em aproximadamente 70%.

Você otimizou com sucesso a tarefa limitada pela CPU usando quatro threads de trabalho. Se você tiver uma máquina com mais de quatro núcleos, atualize o THREAD_COUNT para esse número e você reduzirá ainda mais o tempo de carga.

Conclusão

Neste artigo, você construiu um aplicativo Node com uma tarefa vinculada à CPU que bloqueia a thread principal. Em seguida, você tentou tornar a tarefa não bloqueadora usando promessas, o que não foi bem-sucedido. Depois disso, você usou o módulo worker_threads para descarregar a tarefa vinculada à CPU para outra thread e torná-la não bloqueadora. Finalmente, você utilizou o módulo worker_threads para criar quatro threads e acelerar a tarefa intensiva em CPU.

Como próximo passo, consulte a documentação sobre threads de trabalhador do Node.js para aprender mais sobre as opções. Além disso, você pode conferir a biblioteca piscina, que permite criar um pool de trabalhadores para suas tarefas intensivas em CPU. Se você deseja continuar aprendendo Node.js, veja a série de tutoriais Como Programar em Node.js.

Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js