Node.js mutilthreading? Sim é possível, conheça os Worker Threads

Fala Dev! Tudo em riba?

Você já teve aquela sensação de frustração quando sua aplicação Node.js simplesmente “trava” durante uma tarefa pesada? É famoso Event Loop bloqueado. Fazendo uma analogia, imagine que você é o único garçom em um restaurante movimentado, consegue atender bem quando são pedidos simples, mas quando um cliente pede aquele prato mega elaborado, todos os demais ficam esperando pois o preparo do prato desse cliente demora um pouco mais que o preparo dos pratos dos demais clientes.

É exatamente isso que acontece com o Node.js, ele é fenomenal para operações de I/O (leitura de arquivos, requisições de rede), mas quando encontra tarefas que demandam muito processamento da CPU, o Event Loop fica bloqueado e sua aplicação fica “congelada”.

No entanto, é possível evitar esse tipo de problema, direcionando o preparo desse prato elaborado para um “chefe”, um “trabalhador” que tem como função executar o preparo de tais tipos de pratos e liberar a thread principal, ou o “chefe” principal, para continuar executando os demais preparos. Para isso entenderemos o conceito de Worker Threads.

Mas Afinal o Que São os Worker Threads?

Os Worker Threads foram introduzidos no Node.js versão 10.5.0 como uma feature experimental e se tornaram estáveis na versão 12. É um módulo nativo que permite gerar vários threads de execução em um único processo.

Por baixo dos pano, cada Worker Thread criado é executado em seu próprio ambiente V8 isolado, evitando bloqueios no Event Loop que gerencia a thread principal. Apesar de serem executado em ambientes isolados, os Worker Threads podem compartilhar recursos com a thread principal como memória, por exemplo.

Quando Utilizar os Worker Threads?

Antes de mergulharmos nos Worker Threads, é crucial entender quando eles são realmente necessários. Nem toda tarefa precisa de uma thread separada, apenas tarefas que demando uso intenso de CPU. Vejamos algumas tarefas que demandando um uso maior de CPU e outras que não demandando tanta CPU:

  • Tarefas que demandam maior uso de CPU:
    • Processamento de imagens (redimensionamento, filtros);
    • Cálculos matemáticos complexos (Fibonacci, criptografia);
    • Compressão/descompressão de arquivos;
    • Análise de dados grandes (estatísticas, machine learning);
    • Renderização de vídeo;
    • Parsing de arquivos JSON/XML gigantes;
    • Operações de hash criptográficas;
  • Tarefas que demandam pouco uso de CPU:
    • Leitura de arquivos do sistema;
    • Consultas ao banco de dados;
    • Requisições HTTP/API;
    • Upload/download de arquivos;
    • Operações de rede;
    • Leitura de diretórios;
    • Stream de dados;

Resumidamente, se uma tarefa faz a CPU “suar”, use Worker Threads. Se ela apenas espera por dados externos, o Node.js assíncrono já resolve.

Worker Threads vs. Outras Alternativas

Você pode estar se perguntando: “Por que não usar child_process ou cluster?” Ótima pergunta! Vamos esclarecer citando características de cada um:

  • Compartilhamento de memória:
    • Worker Threads: sim (via SharedArrayBuffer);
    • Child Process: não;
  • Overhead:
    • Worker Threads: baixo;
    • Child Process: Alto (spawna novo processo);
  • Caso de uso:
    • Worker Threads: tarefas que demanda alto uso de CPU;
    • Child Process: tarefas de I/O e ferramentas CLI;
  • Comunicação:
    • Worker Threads: message passing, SharedArrayBuffer;
    • Child Process: IPC ou stdout/stderr;

Mão na Massa: Consumindo endpoints de uma API sem utilizar Worker Thread

Suponhamos que você tenha uma API com os seguintes endpoints:

  • GET /fibonacci/:number: retorna o valor de Fibonacci calculado com base no valor informado em :number;
  • GET /health: retorna um “ok”, utilizado para verificar se a aplicação está executando como esperado;

A ideia aqui é demonstrar que, ao requisitar o endpoint de calculo do Fibonacci e de health ao mesmo tempo, notaremos que enquanto o processamento do endpoint de Fibonacci não terminar, a requisição do endpoint de health, cujo o código a ser processado é muito simples, vai ficar aguardando até o calculo do Fibonacci ser concluído.

Vamos preparar o projeto para os testes. Em uma pasta em seu ambiente de desenvolvimento, crie a seguinte estrutura de pastas e arquivos:

Caso queira adiantar essa etapa, sugiro acessar meu repositório no Github e clonar essa estrutura.

Vamos instalar as dependências necessárias e crie o script para iniciar a aplicação conforme o package.json abaixo:

{
  "name": "worker-threads",
  "version": "1.0.0",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "dev": "node --experimental-strip-types --no-warnings --watch src/server.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "fastify": "^5.5.0"
  },
  "devDependencies": {
    "@types/node": "^24.3.0",
    "autocannon": "^8.0.0"
  }
}

Note que uma das dependências é o Autocannon. Nós utilizaremos ele para simular um teste de carga no qual serão feitas várias requisições simultâneas junto ao endpoint de calculo do Fibonacci para demonstrar o Event Loop bloqueado pelo cálculo. Para esse teste, o arquivo script.js deve conter o seguinte código:

import autocannon from 'autocannon'

autocannon({
  url: 'http://localhost:3333/fibonacci/40',
  connections: 5, 
  pipelining: 1, 
  duration: 20 
}, console.log);

E no arquivo server.ts, teremos o seguinte código:

E no arquivo server.ts, teremos o seguinte código:

import Fastify from 'fastify';

function fibonacci(n:number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const fastify = Fastify({
  logger: false
})

fastify.get('/health', async function (_request, reply) {
  reply.send({status: 'ok'})
})

fastify.get<{ Params: { number?: number } }>('/fibonacci/:number?', async function (request, reply) {
  const { number } = request.params;
  const result =  fibonacci(number || 10);
  console.log('Fibonacci result:', result);
  
  reply.send({result})
})

fastify.listen({ port: 3333 })

Nele, basicamente temos:

  • Importação do Fastify;
  • A função que executa o cálculo de Fibonacci;
  • A definição dos endpoints GET /health e GET /fibonacci/:number;

Vamos iniciar o serviço da nossa API através do npm.

npm run dev

Agora vamos executar o script de teste de carga que irá enviar requisições para o endpoint GET /fibonacci/:number e simultaneamente vamos tentar executar o endpoint GET /health.

Para executar o script de teste de carga, utilizaremos o seguinte comando:

node autocannon/script.js

Uma observação: o script de teste de carga acima está configurado para enviar até 5 requisições simultâneas durante 20 segundos.

A imagem abaixo ilustra a execução do script de teste de carga juntamente com uma requisição junto ao endpoint GET /health.

Podemos notar a resposta da requisição ao endpoint GET /health demorou 21.9 segundos para ser retornada.

Analisando o relatório gerado pelo Autocannon, notamos que foram enviadas 17 requisições ao endpoint GET /fibonacci/:number durante os 20 segundos de teste e apenas 6 retornaram sucesso dentro dos 20 segundos.

Sendo assim, foi possível notar que o processamento necessário para atender as requisições de calculo do Fibonacci impactou diretamente o tempo de retorno da requisição do endpoint de GET /health.

Adicionando o Worker Thread na brincadeira

Vamos agora melhorar nossa API delegando o processamento pesado para um worker. Se você lembra da imagem da estrutura de arquivos do projeto, nele continha um arquivo fibonacci-worker.ts. O conteúdo dele é o seguinte:

import { parentPort, workerData } from "worker_threads";

function fibonacci(n: number): number {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const result = fibonacci(workerData);
parentPort!.postMessage(result);

Explicando linha a linha:

  • Na primeira linha do arquivo importamos parentPost e workerData do módulo worker_threads;
  • Temos a função de calculo do Fibonacci, idêntica da utilizada anteriormente;
  • Na penúltima linha, guardamos o resultado do calculo na constante result. Repare que o parâmetro passado na função fibonacci é workerData, log saberemos de onde ele vem;
  • Por fim na última linha utilizamos parentPort.postMessage(result) para devolver o resultado do calculo para a thread principal;

Agora vamos precisar modificar um pouco nosso arquivo server.ts.

import Fastify from 'fastify';
import { Worker } from "worker_threads";

function runFibonacci(n: number): Promise<number> {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./src/fibonacci-worker.ts', {
      workerData: n,
    });

    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0)
        reject(new Error(`Worker stopped with exit code ${code}`));
    });
  });
}

const fastify = Fastify({
  logger: false
})

fastify.get('/health', async function (_request, reply) {
  reply.send({status: 'ok'})
})


fastify.get<{ Params: { number?: number } }>('/fibonacci/:number?', async function (request, reply) {
  const { number } = request.params;
  const result = await runFibonacci(number || 10);
  
  console.log('Fibonacci result:', result);
  
  reply.send({result})
})

fastify.listen({ port: 3333 })

Vamos entender o que foi modificado:

  • Na segundo linha do arquivo, importamos Worker do módulo worker_threads;
  • A função fibonacci deu lugar a função runFibonacci que é assíncrona. Nela temos:
const worker = new Worker('./src/fibonacci-worker.ts', {
  workerData: n,
});
  • Basicamente criamos um Worker Thread executando new Worker, onde o primeiro parâmetro é o arquivo que será executado pelo worker que está sendo criado e o segundo é um objeto onde atribuimos o valor de workerData (lembra dele) com o valor recebido pela função runFibonacci;
  • Em seguida definimos os eventos:
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
  if (code !== 0)
    reject(new Error(`Worker stopped with exit code ${code}`));
});
  • Basicamente fica escutando o evento message e assim que retornar uma mensagem, resolve a promise;
  • Se retornar um erro, captura-o e rejeita a promise;
  • Por fim, no endpoint GET /fibonacci/:number trocamos a chamada da função fibonacci para runFibonacci;

Agora vamos executar novamente nosso servidor, executar o script de teste de carga e requisitar o endpoint GET /health simultaneamente.

Na imagem acima podemos notar uma melhora impressionante no tempo de resposta do endpoint GET /health, cujo o tempo de 21.9 segundos passou para 11 milissegundos. Isso mesmo, 11 milissegundos!

Analisando o relatório gerado pelo Autocannon, podemos notar também uma melhora incrível. No relatório anterior foi possível enviar 17 requisições das quais apenas 5 responderam dentro do tempo de 20 segundos do teste. Com o worker, foi possível enviar 35 requisições das quais 30 responderam dentro do período de teste. Isso mesmo, delegando o processamento para um worker, foi possível atender os dois endpoints com excelente performance.

Mais alguns motivos para usar os Worker Threads

Outros motivos que podem contribuir na decisão da utilização dos Worker Threads:

  • Reutilização: workers são reutilizados ao invés de criados/destruídos constantemente;
  • Controle: você define quantos workers rodar simultaneamente;
  • Escalabilidade: se adapta automaticamente ao número de núcleos da máquina;
  • Fila inteligente: distribui tarefas conforme workers ficam disponíveis;

Considerações Finais

Worker Threads são uma ferramenta poderosa que pode transformar drasticamente a performance de aplicações Node.js que lidam com tarefas de uso intensivo de CPU. Mas lembre-se: com grandes poderes, vêm grandes responsabilidades.

Use Worker Threads quando:

  • Suas tarefas realmente necessitem de uso intensivo de CPU;
  • Você precisa de processamento paralelo real;
  • Sua aplicação está ficando “travada” durante operações pesadas:

Evite Worker Threads quando:

  • Suas tarefas são principalmente I/O;
  • A operação é muito simples;
  • O overhead de criar workers é maior que o benefício:

A chave é sempre medir primeiro, otimizar depois. Use ferramentas de profiling para identificar gargalos reais antes de partir para Worker Threads. E quando usar, faça-o de forma responsável, gerencie workers adequadamente, trate erros, monitore performance e sempre limpe recursos.

Com esses conhecimentos, você está pronto para levar suas aplicações Node.js para o próximo nível de performance. Agora é só colocar a mão na massa e fazer esses workers trabalharem para você!

Se curtiu o assunto, no vídeo abaixo eu mostro em detalhes como utilizar os Worker Threads, trago alguns conceitos e executo alguns testes 👇

Fico por aqui e até a próxima!

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *