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 realizar uma tarefa por vez. No entanto, o Node.js em si é multithread e fornece threads ocultas por meio da biblioteca libuv
, que lida com operações de I/O, como leitura de arquivos em 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 o uso de 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 vendidos 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 tarefas ligadas à CPU ou evitar interromper o thread principal, porque o JavaScript é single-threaded.
Para remediar isso, o Node.js introduziu o módulo worker-threads
, que permite criar threads e executar várias tarefas em JavaScript em paralelo. Uma vez que uma thread conclui uma tarefa, ela envia uma mensagem para o 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 tarefas ligadas à CPU não bloqueiam o 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 para a CPU que bloqueia o thread principal. Em seguida, você usará o módulo worker-threads
para descarregar a tarefa intensiva para a CPU para outro thread, evitando bloquear o thread principal. Finalmente, 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á:
-
Um sistema multi-core com quatro ou mais núcleos. Ainda é possível seguir o tutorial dos Passos 1 a 6 em um sistema de núcleo duplo. No entanto, o Passo 7 requer quatro núcleos para ver as melhorias de desempenho.
-
Um ambiente de desenvolvimento Node.js. Se estiver no Ubuntu 22.04, instale a versão mais recente do Node.js seguindo o passo 3 de Como Instalar o Node.js no Ubuntu 22.04. Se estiver em outro sistema operacional, consulte Como Instalar o Node.js e Criar um Ambiente de Desenvolvimento Local.
-
Um bom entendimento do loop de eventos, callbacks e promessas em JavaScript, que você pode encontrar em nosso tutorial, Entendendo o Loop de Eventos, Callbacks, Promessas e Async/Await em JavaScript.
-
Conhecimento básico sobre como usar o framework web Express. Confira nosso guia, Como Começar com Node.js e Express.
Configurando o Projeto e Instalando Dependências
Neste passo, você 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:
- mkdir multi-threading_demo
- cd multi-threading_demo
O comando mkdir
cria um diretório e o comando cd
altera o diretório de trabalho para o recém-criado.
Em seguida, inicialize o diretório do projeto com npm usando o comando npm init
:
- npm init -y
A opção -y
aceita todas as opções padrão.
Quando o comando é executado, a saída será semelhante a esta:
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 express
, um framework web Node.js:
- npm install express
Você usará o Express para criar uma aplicação de servidor que possui endpoints bloqueadores e não bloqueadores.
O Node.js vem 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.
Compreensão de Processos e Threads
Antes de começar a escrever tarefas vinculadas à CPU e descarregá-las para 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 computacional de núcleo único ou multi-núcleo.
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 nano
, ou o editor de texto de sua preferência, crie e abra o arquivo process.js
:
- nano process.js
No seu arquivo process.js
, insira o seguinte código:
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 um array 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 rasa do array a partir do índice 2 em diante. Ao fazer isso, você ignora 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 do array fatiado e armazená-lo na variável process_name
.
Após isso, você define um loop while
e passa uma condição true
para fazer o loop rodar indefinidamente. Dentro do loop, a variável count
é incrementada por 1
a cada iteração. Em seguida, há uma declaraçã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
, em seguida, pressione Y
para salvar as alterações.
Execute o programa usando o comando node
:
- 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 atribuído a ele pelo sistema operacional. 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 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:
- ps |grep node
O comando ps
lista todos os processos associados ao usuário atual no sistema. O operador de pipe |
passa toda a saída do ps
para o grep
para filtrar os processos e listar apenas os processos Node.
A execução do comando produzirá uma saída semelhante ao seguinte:
Output7754 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 três processos adicionais com argumentos diferentes e colocá-los em segundo plano:
- node process.js B & node process.js C & node process.js D &
No comando, você criou três instâncias adicionais do programa process.js
. O símbolo &
coloca cada processo em segundo plano.
Ao executar o comando, a saída será semelhante ao seguinte (embora a ordem possa variar):
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 tem conhecimento de qualquer outro processo em execução: o processo D
não tem conhecimento 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 ao criar os três processos. Ao executar o comando, os argumentos dos processos estavam na ordem de B
, C
e D
. Mas agora, a ordem é D
, B
e C
. A razão é que o sistema operacional possui algoritmos de agendamento que decidem qual processo executar 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 D
é executado por um tempo limitado, então seu estado é salvo em algum lugar e o sistema operacional agenda o processo B
para ser executado por um tempo limitado, e assim por diante. Isso acontece de ida e volta até que todas as tarefas tenham sido concluídas. Pela saída, pode parecer que cada processo foi executado até a conclusão, mas na realidade, o agendador do sistema operacional está constantemente alternando entre eles.
Em um sistema multi-core – supondo que você tenha quatro núcleos – o sistema operacional agenda cada processo para ser executado em cada núcleo ao mesmo tempo. Isso é conhecido como paralelismo. No entanto, se você criar mais quatro processos (totalizando oito), cada núcleo executará dois processos simultaneamente até que sejam concluídos.
Threads
As threads são como processos: elas 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 umas com as outras por meio de passagem de mensagens ou compartilhamento de dados na memória do processo. Isso as torna leves em comparação com os processos, já que iniciar uma thread não solicita mais memória do sistema operacional.
Quanto à execução das 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 executar diretamente na CPU única. Em um sistema de vários núcleos, o sistema operacional agenda as threads em todos os núcleos e executa 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 em execução atualmente com o comando kill
:
- 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ê executa outro comando mais tarde.
Agora que você conhece a diferença entre um processo e uma thread, você trabalhará com threads ocultas do Node.js na próxima seção.
Compreendendo as Threads Ocultas no Node.js
O Node.js fornece threads adicionais, por isso é considerado multithread. Nesta seção, você examinará as threads ocultas no Node.js, que ajudam a tornar as operações de I/O não bloqueantes.
Conforme mencionado na introdução, o JavaScript é monofásico, 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 realiza uma operação de I/O 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 a um processo Node.js. Com essas threads, as operações de E/S são tratadas separadamente e, quando são concluídas, o loop de eventos adiciona o retorno de chamada associado à tarefa de E/S em uma fila de microtarefas. Quando a pilha de chamadas na thread principal está limpa, o retorno de chamada é inserido na pilha de chamadas e então é executado. Para deixar isso claro, o retorno de chamada associado à tarefa de E/S específica 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 E/S é concluída, 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 todo processo Node.js possui sete threads, execute novamente o arquivo process.js
e coloque-o em segundo plano:
- 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:
- top -H -p 9933
-H
instrui o top
a exibir as threads em um processo. A flag -p
instrui o top
a monitorar apenas a atividade no ID do processo fornecido.
Ao executar o comando, sua saída será semelhante ao seguinte:
Outputtop - 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 pode ver na saída, o processo Node.js tem um total de sete threads: uma thread principal para executar JavaScript, quatro threads do Node.js e duas threads do V8.
Conforme discutido anteriormente, as quatro threads do Node.js são usadas para operações de I/O a fim de torná-las não bloqueantes. Elas funcionam bem para essa tarefa, e criar threads manualmente para operações de I/O pode até piorar o desempenho do seu aplicativo. O mesmo não pode ser dito sobre tarefas ligadas à CPU. Uma tarefa ligada à CPU não faz uso de threads extras disponíveis no processo e bloqueia a thread principal.
Agora pressione q
para sair do top
e encerre o processo Node com o seguinte comando:
- kill -9 9933
Agora que você sabe sobre as threads em um processo Node.js, você irá escrever uma tarefa ligada à CPU na próxima seção e observar como ela afeta a thread principal.
Criando uma Tarefa Ligada à CPU sem Threads de Trabalhador
Nesta seção, você irá construir um aplicativo Express que possui uma rota não bloqueante e uma rota bloqueante que executa uma tarefa ligada à CPU.
Primeiro, abra o arquivo index.js
no seu editor preferido:
- nano index.js
No seu arquivo index.js
, adicione o seguinte código para criar um servidor básico:
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 contém o número da porta em que o servidor deve escutar.
Seguindo isso, você utiliza app.get('/non-blocking')
para definir a rota para as requisições GET
devem ser enviadas. Finalmente, 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 em CPU:
...
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 em 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
. Esta tarefa é executada na CPU e levará alguns segundos para ser concluída.
Neste ponto, seu arquivo index.js
terá a seguinte aparência:
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, então inicie o servidor com o seguinte comando:
- node index.js
Ao executar o comando, você verá uma saída semelhante à seguinte:
OutputApp listening on port 3000
Isso mostra que o servidor está em execução e pronto para servir.
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 estiver a seguir o tutorial num servidor remoto, pode utilizar o encaminhamento de portas para testar a aplicação no navegador.
Enquanto o servidor Express estiver em execução, abra outro terminal no seu computador local e introduza o seguinte comando:
- ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip
Ao ligar-se ao servidor, aceda a http://localhost:3000/non-blocking
no navegador web do seu computador local. Mantenha o segundo terminal aberto durante o resto deste tutorial.
Em seguida, abra um novo separador e visite http://localhost:3000/blocking
. Enquanto a página carrega, abra rapidamente mais dois separadores e visite novamente http://localhost:3000/non-blocking
. Verá que não receberá uma resposta instantânea, e as páginas continuarão a tentar carregar. Só depois da rota /blocking
terminar de carregar e devolver uma resposta o resultado é 20000000000
é que o restante das rotas devolverá uma resposta.
A razão pela qual todas as rotas /non-blocking
não funcionam enquanto a rota /blocking
está a carregar é devido ao ciclo for
ligado à CPU, que bloqueia a thread principal. Quando a thread principal está bloqueada, o Node.js não consegue atender a quaisquer pedidos até que a tarefa ligada à CPU tenha terminado. Portanto, se a sua aplicação tiver milhares de pedidos GET
simultâneos para a rota /non-blocking
, basta uma visita à rota /blocking
para tornar todas as rotas da aplicação não responsivas.
Como você pode ver, bloquear a thread principal pode prejudicar a experiência do usuário com o aplicativo. Para resolver esse problema, você precisará transferir a tarefa intensiva em CPU para outra thread, para que a thread principal possa continuar lidando 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
. O motivo pelo qual o servidor é interrompido é que o Node.js não é atualizado automaticamente quando novas alterações são feitas no arquivo.
Agora que você entende o impacto negativo que uma tarefa intensiva em CPU pode ter em seu aplicativo, você tentará evitar bloquear a thread principal usando promessas.
Transferindo uma Tarefa Intensiva em CPU Usando Promessas
Com frequência, quando os desenvolvedores aprendem sobre o efeito de bloqueio das tarefas intensivas em CPU, eles recorrem às promessas para tornar o código não bloqueante. Esse instinto deriva do conhecimento de usar métodos de E/S baseados em promessas não bloqueantes, como readFile()
e writeFile()
. Mas, como você aprendeu, as operações de E/S fazem uso de threads ocultas do Node.js, o que não ocorre com tarefas intensivas em CPU. No entanto, nesta seção, você irá envolver a tarefa intensiva em CPU em uma promessa como tentativa de torná-la não bloqueante. Isso não funcionará, mas ajudará você a ver o valor de usar threads de worker, o que você fará na próxima seção.
Abra novamente o arquivo index.js
no seu editor:
- nano index.js
No seu arquivo `index.js`, remova o código destacado que contém a tarefa intensiva de CPU:
...
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:
...
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 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`:
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.
O seu código completo agora ficará assim:
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:
- node index.js
No seu navegador da web, visite `http://localhost:3000/blocking` e, enquanto ele carrega, recarregue rapidamente as guias `http://localhost:3000/non-blocking`. Como você vai notar, as rotas `non-blocking` ainda são afetadas e todas elas esperarão pela rota `/blocking` terminar de carregar. Porque as rotas ainda são afetadas, as promessas não fazem o código JavaScript executar em paralelo e não podem ser usadas para tornar tarefas vinculadas à CPU não bloqueadoras.
Com isso, pare o servidor de aplicativos com CTRL+C
.
Agora que você sabe que as promessas não fornecem nenhum mecanismo para tornar as tarefas vinculadas à CPU não bloqueantes, você usará o módulo worker-threads
do Node.js para descarregar uma tarefa vinculada à CPU em uma thread separada.
Descarregando uma Tarefa Vinculada à CPU com o Módulo worker-threads
Nesta seção, você descarregará 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 à thread principal. Assim que a tarefa for concluída, a thread do trabalhador 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
:
- nproc
Output4
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:
- 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:
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, nesta etapa, você excluirá esta função de index.js
.
Seguindo isso, adicione o código destacado abaixo:
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 o index.js
no seu editor de texto:
- nano index.js
Como você já tem a tarefa vinculada à CPU em worker.js
, remova o código destacado de 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:
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 de Worker
usando a palavra-chave new
seguida por uma chamada a 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 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 vinculada à 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 ao usuário.
Seu arquivo completo agora parecerá o seguinte:
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:
- node index.js
Visite a guia http://localhost:3000/blocking
novamente no seu navegador da web. Antes de terminar de carregar, atualize todas as guias http://localhost:3000/non-blocking
. Você deve notar agora que elas estão carregando instantaneamente sem esperar pela rota /blocking
terminar de carregar. Isso ocorre porque a tarefa vinculada à CPU é 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 worker, você usará quatro threads de worker para melhorar o desempenho da tarefa intensiva em CPU.
Optimizar 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 elas 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, você precisará dividir as tarefas. Como a tarefa envolve fazer loop 20 bilhões de vezes, você dividirá 20 bilhões pelo número de threads que deseja usar. Neste caso, é 4
. Calcular 20_000_000_000 / 4
resultará em 5_000_000_000
. Portanto, cada thread fará loop de 0
a 5_000_000_000
e incrementará counter
em 1
. Quando cada thread terminar, enviará uma mensagem para a thread principal contendo o resultado. Assim 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 você quisesse redimensionar 800 imagens em um diretório, poderia criar um array contendo todos os caminhos dos arquivos de imagem. Em seguida, divida 800
por 4
(a contagem de threads) e faça com que cada thread trabalhe em um intervalo. A thread um redimensionará imagens do índice do array 0
ao 199
, a thread dois do índice 200
ao 399
, e assim por diante.
Primeiro, verifique se você tem quatro ou mais núcleos:
- nproc
Output4
Faça uma cópia do arquivo worker.js
usando o comando cp
:
- cp worker.js four_workers.js
Os arquivos atuais index.js
e worker.js
serão deixados intactos para que você possa executá-los novamente para comparar seu desempenho com as alterações nesta seção mais tarde.
Em seguida, abra o arquivo four_workers.js
no seu editor de texto:
- nano four_workers.js
No seu arquivo four_workers.js
, adicione o código destacado para importar o objeto workerData
:
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, você extrai o objeto WorkerData
, que conterá os dados passados do thread principal quando o thread for inicializado (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, depois copie o arquivo index.js
:
- cp index.js index_four_workers.js
Abra o arquivo index_four_workers.js
no seu editor:
- nano index_four_workers.js
No seu arquivo index_four_workers.js
, adicione o código destacado para criar uma instância de thread:
...
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, você define a constante THREAD_COUNT
contendo o número de threads que deseja criar. Mais tarde, quando tiver mais núcleos em seu servidor, a escalabilidade envolverá a alteração do valor de THREAD_COUNT
para o número de threads que deseja usar.
A seguir, a função createWorker()
cria e retorna uma promessa. Dentro do retorno da promessa, você inicializa uma nova thread passando a classe Worker
como primeiro argumento e o caminho do arquivo four_workers.js
como segundo argumento. Em seguida, você passa um objeto como segundo argumento. Depois, atribui ao objeto a propriedade workerData
que possui outro objeto como seu valor. Por fim, atribui ao objeto a propriedade thread_count
, cujo valor é o número de threads na constante THREAD_COUNT
. O objeto workerData
é aquele ao qual você fez referência no arquivo workers.js
anteriormente.
Para garantir que a promessa seja resolvida ou gere um erro, adicione as seguintes linhas destacadas:
...
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 trabalhador 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 criar novas threads.
Mas primeiro, remova o código destacado a seguir, pois você já definiu essa funcionalidade na função createWorker()
:
...
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 excluído, adicione o seguinte código para inicializar quatro novas threads de trabalho:
...
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 matriz 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. Em seguida, você adiciona o objeto de promessa retornado pela função na matriz workerPromises
usando o método push
do JavaScript. Quando o loop termina, o workerPromises
terá quatro objetos de promessa, cada um retornado chamando a função createWorker()
quatro vezes.
Agora, adicione o seguinte código destacado abaixo para esperar que as promessas sejam resolvidas e retornar uma resposta ao usuário:
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 matriz workerPromises
contém promessas retornadas chamando 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()
espera que todas as promessas na matriz sejam 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 trabalhadores, você os adiciona todos juntos obtendo cada valor de thread_results
usando a sintaxe de notação de colchetes. Uma vez adicionados, você retorna o valor total para a página.
Seu arquivo completo deve parecer assim:
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, execute primeiro index.js
para medir seu tempo de resposta.
- node index.js
Em seguida, abra um novo terminal no seu computador local e digite o seguinte comando curl
, que mede quanto tempo leva para obter uma resposta da rota /blocking
:
- time curl --get http://localhost:3000/blocking
O comando time
mede quanto tempo o comando curl
leva para ser 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:
Outputreal 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
:
- node index_four_workers.js
Visite novamente a rota /blocking
no seu segundo terminal:
- time curl --get http://localhost:3000/blocking
Você verá uma saída consistente com o seguinte:
Outputreal 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 carregamento em aproximadamente 70%.
Você otimizou com sucesso a tarefa vinculada à CPU usando quatro threads de trabalhador. 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 carregamento.
Conclusão
Neste artigo, você construiu um aplicativo Node com uma tarefa vinculada à CPU que bloqueia a thread principal. Você então tentou tornar a tarefa não bloqueadora usando promessas, o que não foi bem-sucedido. Depois disso, você utilizou o módulo worker_threads
para transferir a tarefa vinculada à CPU para outra thread, tornando-a 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 trabalhadores 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 quiser continuar aprendendo Node.js, confira a série de tutoriais Como Programar em Node.js.
Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js