Worker threads no Node.js

Worker threads no Node.js

A seguir irei falar sobre as worker threads no Node.js, um assunto que surgiu a partir de uma conversa entre desenvolvedores em um de nossos encontros semanais aqui na ZRP, onde debatíamos sobre as diferenças de algumas linguagens e como cada uma lidava com seus processos internos.

Introdução

Ter uma aplicação confiável, escalável e com ótimo desempenho, é a receita para uma aplicação bem sucedida. Otimizar o desempenho da aplicação diminuindo o consumo de recursos como CPU e memória, são importantes para a redução de custos, pois geralmente somos cobrados por consumo em ambientes cloud. Além disso, também aumenta a satisfação do usuário final.

O que são worker threads?

As worker threads foram introduzidas em abril de 2018, na versão 10 do Node.js, e elas são bastante úteis quando precisamos fazer um uso intensivo de CPU, ou seja, quando temos uma tarefa muito pesada para ser executada, podemos utilizar as worker threads para nos ajudar no desempenho.

Mas como as workers threads no ajudam no desempenho?

Por padrão, o javascript é síncrono e single threaded, isso significa que todas as instruções são executadas em uma única linha do tempo, ou seja, apenas um comando é processado por vez.

Utilizando as worker threads, conseguimos manipular esse padrão de cada comando ser executado em sequência, para ser executado em paralelo, conseguindo deixar o node multi threaded e assíncrono.

O seguinte diagrama mostra como se fosse uma linha do tempo, no momento em que duas instruções são dadas a um programa.

Diagrama Single Thread vs Multi Thread

Perceba que no modelo single thread, uma instrução é executada após a outra, já no modelo multi thread, cada instrução é executada em uma thread separada, diminuindo a linha do tempo pela metade, já que a segunda instrução não precisa esperar a primeira finalizar.

Apresentação do problema

Vamos criar uma aplicação Node.js, onde o objetivo é criar uma API com duas rotas.

  1. A primeira rota irá devolver um hello com o nome passado na requisição.
  2. A segunda rota irá se chamar fibonacci, e poderá receber um número na requisição, e nosso objetivo é encontrar a sequência fibonacci deste número.

Vou assumir que você saiba como iniciar um projeto node (tenha o node instalado, editor de código fonte, etc), então começarei pelo arquivo index.js, é por ele que nossa aplicação começará.

const express = require("express");
const app = express();
const port = 3000;

function calculateFibonacci(num) {
  if (num === 0 || num === 1) return num;

  return calculateFibonacci(num - 1) + calculateFibonacci(num - 2);
}

app.get("/hello/:name", (req, res) => {
  res.send(`<h1>Hello ${req.params.name}</h1>`);
});

app.get("/fibonacci/:number", (req, res) => {
  const { number } = req.params;
  const result = calculateFibonacci(+number);

  res.json({ result });
});

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

Estou utilizando o express para criar o web server, mas você pode utilizar qualquer outro de sua preferência, fastify e koa são exemplos.

A rota hello não tem nenhum segredo, somente devolve um Hello com o nome passado como parâmetro.

Vamos nos atentar a rota fibonacci, ela recebe um número na requisição, e passa esse número para a função calcuteFibonacci, e perceba como essa função está recursiva, a função irá se chamar quantas vezes necessário, até encontrar o número de sua sequência fibonacci.

Poderíamos refatorar esta função? É claro, mas o objetivo aqui é forçar um processamento pesado para o Node, então para nosso exemplo está mais que bom.

Com o código de nosso projeto pronto, vamos iniciá-lo e começar com os testes.

Batendo na rota http://localhost:3000/hello/:name, podemos ver que nossa primeira rota está como esperado:

Vou testar a rota de fibonacci passando o número 12.

Ok, a sequência fibonacci de 12 é 144, então está correto.

Agora vamos para o teste que nos interessa. Vou requisitar a rota fibonacci passando o número 55, e dado nossa função recursiva, espero que demore um certo tempo até finalizar o cálculo, sendo assim, o navegador deve ficar esperando o resultado chegar.

Enquanto o cálculo está sendo feito, vou chamar novamente a rota de hello, e ver o que ela nos retorna.

0:00
/0:09

Veja que enquanto o fibonacci está sendo processado, a rota hello também fica aguardando. Não é esse o resultado que gostaríamos, uma rota não deve influenciar na outra, a rota hello deve retornar instantaneamente, afinal, ela não processa nada, apenas devolve um HTML já pronto.

Lembra que no início deste artigo falei que o javascript por padrão é single threaded? Com esse teste conseguimos entender o que isso significa. Enquanto a rota de fibonacci estiver processando, ela está ocupando toda a thread principal, então novas requisições devem esperar sua vez de processar, para assim então devolver a resposta na requisição.

Resolução do problema utilizando worker threads

Agora que temos o problema, vamos refatorar nosso código, para que a rota de fibonacci não influencie na rota de hello. Para isso, irei usar as worker threads.

Para começar, irei criar um arquivo chamado fibonacciWorker.js na raiz do projeto, ele será responsável por guardar todo o processo pesado que inclui o calculo de fibonacci.

const { parentPort } = require("worker_threads");

function calculateFibonacci(num) {
  if (num === 0 || num === 1) return num;

  return calculateFibonacci(num - 1) + calculateFibonacci(num - 2);
}

parentPort.on("message", (number) => {
  const result = calculateFibonacci(number);
  parentPort.postMessage(result);
});

Movi a função calculateFibonacci para dentro deste arquivo, e importei o parentPort do modulo worker_threads do node.

Utilizo o parentPort.on() para dizer que assim que receber uma message, ela irá executar uma callback que chama a função calculateFibonacci . O resultado eu armazeno na variável result, e assim que finalizar, eu utilizo novamente o parentPort para postar uma mensagem com o resultado, utilizando o parentPort.postMessage().

Agora no index.js, a gente refatora a rota fibonacci para utilizar o nosso worker que acabamos de criar.

const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = 3000;

function calculateFibonacciAsync(number, callback) {
  const worker = new Worker("./src/fibonacciWorker.js");

  worker.on("message", (result) => {
    callback(result);
    worker.terminate();
  });

  worker.postMessage(number);
}

app.get("/hello/:name", (req, res) => {
  res.send(`<h1>Hello ${req.params.name}</h1>`);
});

app.get("/fibonacci/:number", (req, res) => {
  const { number } = req.params;

  calculateFibonacciAsync(+number, (result) => {
    res.json({ result });
  });
});

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

Aqui importei o Worker do módulo worker_threads, que será responsável por criar nosso worker, ou trabalhador na tradução literal, que nada mais é que um código que será executado para realizar um trabalho em específico, no nosso caso o cálculo de fibonacci.

Criei uma nova função chamada calculateFibonacciAsync, que recebe como parâmetro o número da sequencia fibonacci escolhida, e uma função callback, que será executada quando finalizar o código. Podemos notar na primeira linha desta função que estou criando o worker, utilizando new Worker(".src/fibonacciWorker.js") , e logo em seguida crio um worker.on("message"), para assim que o worker postar uma mensagem, eu executar a callback, e finalizar o worker. E no final da função eu posto uma mensagem para o worker começar seu trabalho.

Parece tudo lindo, mas será que funciona? Vamos para o teste!

0:00
/0:13

Funcionou! 🥳

E como? Simples, deixamos o calculo pesado processar de forma assíncrona, criando um worker separado da thread principal. Ou seja, modificamos o padrão do javascript ser síncrono e single threaded, para ser assíncrono e multi threaded.

Para ver o código completo, você pode conferir aqui neste repositório

https://github.com/eduardovilke/node-fib/

Conclusão

As worker threads se mostram uma ferramenta poderosa que nos ajuda em trabalhos assíncronos e paralelos. Ao longo deste artigo, exploramos os conceitos de worker threads, demonstramos um problema comum de alto processamento de uma tarefa, e como conseguimos resolvê-lo utilizando essa técnica.

Além disso, destacamos a importância de aplicações confiáveis e de ótimo desempenho, que podem nos ajudar diminuindo custo de cloud, e melhoram experiência para o usuário.

Além dos worker threads, o Node.js oferece outras ferramentas e técnicas semelhantes, que poderíamos ter utilizado para resolver esse mesmo problema, como o cluster module ou child process, ou até mesmo uma técnica de jobs assíncronos, utilizando bibliotecas para o node como BullQueue e Beequeue, mas isso fica para outro artigo.

Espero que você tenha gostado, e que tenha sido útil esse conhecimento, até a próxima.