Skip to main content

Multithreading em Node.js com worker threads: tutorial sobre worker_threads

Escrito por
Headshot of James Walker

James Walker

blog feature multithreading

25 de outubro de 2022

0 minutos de leitura

O Node.js oferece um ambiente de execução JavaScript com uma única thread, que impede que o código execute várias operações em paralelo. Se sua aplicação costuma usar execução síncrona, operações demoradas podem causar bloqueios.

No entanto, o próprio Node.js é uma aplicação multithread. Isso fica evidente quando você usa um dos métodos assíncronos da biblioteca padrão para realizar operações de E/S, como ler um arquivo ou fazer uma solicitação de rede. Essas tarefas são delegadas a um pool separado de threads, que o Node cria e mantém usando a biblioteca C libuv. Embora pareça multithreading, funções assíncronas ainda podem bloquear o loop de eventos principal.

Mas e se você quiser criar suas próprias threads independentes para sua aplicação Node.js? O multithreading pode melhorar significativamente o desempenho de tarefas que exigem muito da CPU, permitindo executar diferentes operações em paralelo. Embora o Node.js não ofereça multithreading de verdade, é possível criar algo semelhante com o módulo worker_threads. Neste artigo, explicamos como ele funciona e mostramos como usá-lo em algumas aplicações do mundo real.

O que são worker threads?

O módulo worker_threads implementa uma forma de threading que permite adicionar paralelismo à sua aplicação. O código executado em uma worker thread roda em um processo filho separado, evitando bloquear a aplicação principal.

Worker threads não são threads reais no sentido tradicional. São processos distintos, o que significa que não podem acessar diretamente o contexto de execução dos processos pai. A comunicação entre as worker threads e sua aplicação é feita por um sistema de mensagens baseado em eventos.

Embora as worker threads não transformem o Node.js em uma linguagem verdadeiramente multithread, essa diferença é, na prática, apenas teórica em muitos cenários do mundo real. Elas oferecem um mecanismo conveniente para executar várias “threads” de processamento ao mesmo tempo, permitindo tirar tarefas intensivas do loop principal.

Casos de uso de worker threads

Você pode usar worker threads em qualquer situação que envolva operações que exigem muito da CPU. Elas não são indicadas para acelerar tarefas de E/S, devido à sobrecarga associada a cada thread. Os utilitários de E/S assíncrona integrados ao Node.js são mais rápidos e eficientes para tarefas de sistema de arquivos e rede.

Há muitas situações em que as worker threads podem ajudar. Veja alguns casos de uso especialmente comuns em que elas podem ser úteis:

  • Redimensionamento de imagens: Redimensionar imagens grandes pode levar vários segundos, e a demora aumenta rapidamente quando você precisa gerar vários tamanhos. Isso é comum em aplicações que convertem fotos enviadas para o sistema em miniaturas, além de versões pequenas e grandes. Você pode usar três worker threads para começar a gerar todos os tamanhos ao mesmo tempo e reduzir o tempo total do processo.

  • Compressão de vídeo: A compressão de vídeo é uma das tarefas computacionais mais exigentes. As worker threads podem acelerá-la processando vários quadros em paralelo e, em seguida, enviando os resultados de volta à thread principal.

  • Criptografia de arquivos e outras operações criptográficas: As operações criptográficas são complexas por natureza. Criptografar e descriptografar arquivos, gerar chaves secretas e verificar assinaturas podem causar atrasos perceptíveis em um programa quando essas tarefas são executadas na thread principal.

  • Ordenação e busca em grandes volumes de dados: Filtrar e ordenar dados exige muitas iterações para comparar cada valor. É possível acelerar essas tarefas usando worker threads para analisar vários conjuntos de dados em paralelo.

  • Operações matemáticas complexas: Cálculos matemáticos — como gerar números primos, fatorar números grandes e fazer análises de dados complexas — exigem muito da CPU. Executar parte do trabalho em uma thread separada libera o loop principal para outras tarefas.

Em todas essas operações, a lentidão ocorre porque a CPU passa muito tempo executando código, em vez de ler dados do disco ou da rede. Como são tarefas iterativas, aumentar o número de etapas executadas em paralelo é a melhor forma de melhorar o desempenho. As worker threads permitem fazer isso.

Usando o módulo worker threads

As worker threads são criadas importando o módulo worker_threads do Node e chamando o construtor new Worker(). Um novo processo é iniciado para executar um arquivo JavaScript especificado por você. É possível trocar mensagens com a worker para receber notificações sobre eventos, dados prontos para processamento ou erros. Se você já usou Web Workers em um navegador, o conceito de worker thread será familiar.

Veja o exemplo mais simples de execução de um arquivo JavaScript em uma worker thread:

const {
    Worker,
    isMainThread,
    parentPort
    workerData
} = require("worker_threads");

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: "hello"});
    worker.on("message", msg => console.log(`Worker message received: ${msg}`));
    worker.on("error", err => console.error(error));
    worker.on("exit", code => console.log(`Worker exited with code ${code}.`));
}
else {
    const data = workerData;
    parentPort.postMessage(`You said \"${data}\".`);
}

Copie este trecho de código e salve-o em um arquivo chamado worker-demo.js no diretório de trabalho. Ao executar o código com o Node.js, você deverá ver a seguinte saída:

$ node worker-demo.js
Worker message received: You said "hello".
Worker exited with code 0.

Este código simples demonstra os fundamentos das worker threads. O código-fonte pode atuar tanto como thread principal quanto como worker thread. O módulo worker_threads disponibiliza a exportação isMainThread, que permite verificar se o código está sendo executado como thread principal.

Quando o arquivo é executado como thread principal — ou seja, quando você o inicia pelo terminal —, o primeiro ramo da instrução if é executado. Ele cria uma nova instância de Worker.

O primeiro parâmetro passado ao construtor Worker é o caminho do arquivo JavaScript a ser executado na worker. A variável global __filename pode ser usada neste exemplo, pois o mesmo arquivo contém o código da thread principal e da worker thread. O segundo parâmetro da thread Worker é um objeto de opções. Nesse objeto, a propriedade workerData permite passar valores para a worker thread.

O restante do código da thread principal configura listeners de eventos na instância de Worker. Eles permitem reagir a mensagens, erros e encerramentos de processo que ocorram na worker.

Quando o código de exemplo é executado por uma worker thread, a variável isMainThread terá o valor false e o ramo else será executado. Ele usa a exportação workerData do módulo worker_threads para acessar os dados recebidos da thread principal. A exportação parentPort oferece uma interface para a thread principal, enquanto a função postMessage() permite enviar dados de volta a ela, onde serão reportados como um evento message.

Isso significa que executar worker-demo.js resulta na seguinte sequência de ações:

  • Uma nova worker é criada e recebe hello como dado.

  • O código executado na worker acessa os dados e envia uma nova mensagem amigável de volta à thread principal.

  • O listener de eventos da thread principal recebe a mensagem enviada pela worker thread e a exibe no console.

  • A worker thread não tem mais código para executar e, por isso, é encerrada. A thread principal recebe uma notificação por meio do evento exit.

Agora você pode experimentar alguns exemplos práticos de worker threads.

Redimensionando imagens com worker threads

Este código converte uma imagem em três tamanhos diferentes em paralelo. É muito mais rápido do que processar cada tamanho em sequência.

Primeiro, crie o código que vai redimensionar a imagem. Salve-o em resize-worker.js:

const {parentPort, workerData} = require("worker_threads");
const sharp = require("sharp");

const {src, width, height} = workerData;
const [filename, ext] = src.split(".");

console.log(`Resizing ${src} to ${width}px wide`);

const resize = async () => {
await sharp(src)
.resize(width, height, {fit: "cover"})
.toFile(`${src}-${width}.${ext}`);
};

resize();

Crie o código da thread principal em um novo arquivo chamado resize-main.js:

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

const src = process.argv[2];

const sizes = [
{width: 1920},
{width: 1280},
{width: 640}
];

for (const size of sizes) {
const worker = new Worker(
    __dirname + "/resize-worker.js",
    {
        workerData: {
            src,
            ...size
        }
    }
);
}

Use o npm para instalar o módulo sharp, que oferece a função de redimensionamento de imagens:

$ npm install sharp

Em seguida, coloque uma imagem grande no diretório de trabalho e dê a ela o nome image.jpg. Você pode usar esta foto colorida de fios do Unsplash. Execute seu código com o seguinte comando:

$ node resize-main.js image.jpg

As três mensagens “Resizing” devem aparecer instantaneamente. A thread principal percorre os tamanhos solicitados, cria uma nova thread para cada um e aguarda a conclusão. Assim, os tamanhos são gerados em paralelo, sem bloquear a thread principal.

Redimensionando vídeos com worker threads

Este trecho de código demonstra como redimensionar um vídeo em uma worker thread com [FFmpeg. Isso libera a thread principal para continuar executando outras tarefas enquanto o redimensionamento, que exige muito da CPU, é concluído.

Salve o código da worker em um novo arquivo chamado video-worker.js:

const {parentPort} = require("worker_threads");
const ffmpeg = require("fluent-ffmpeg");

const resizeVideo = (src, size) => {
    const [filename, ext] = src.split(".");
    const output = `${__dirname}/${filename}-${size}.${ext}`;
    ffmpeg(`${__dirname}/${src}`)
    .size(size)
    .on("end", () => parentPort.postMessage({output, input: src, type: "done"}))
    .save(output);
};

parentPort.on("message", msg => {
    const {file, size} = msg;
    const [filename, ext] = file.split(".")[0];
    resizeVideo(file, size);
});

Agora, salve o código da thread principal em um novo arquivo chamado video-main.js:

const {StaticPool} = require("node-worker-threads-pool");

const pool = new StaticPool({
    size: 4,
    task: __dirname + "/video-worker.js"
});

const videoToResize = process.argv[2];
const videoTargetSize = process.argv[3];

const resize = async () => {
    await pool.exec({file: videoToResize, size: videoTargetSize}).then(() => {
    if (msg?.type === "done") {
    console.log(`Saved ${videoToResize} to ${msg.output}`);
    }
    });
};

resize();

Instale as dependências npm necessárias para o código:

$ npm install fluent-ffmpeg node-worker-threads-pool

O exemplo usa o popular codificador ffmpeg. O pacote fluent-ffmpeg é um wrapper do Node.js para as bibliotecas ffmpeg já existentes no seu sistema. Você precisará [instalar o ffmpeg antes de executar o código. A maioria dos ambientes Linux já inclui o ffmpeg, mas quem usa Windows ou Mac talvez precise instalá-lo manualmente.

Você pode verificar se o ffmpeg está disponível executando o comando:

$ ffmpeg
ffmpeg version 4.2.7-0ubuntu0.1 Copyright (c) 2000-2022 the FFmpeg developers

Se o número da versão aparecer, o ffmpeg já está instalado. Se você receber uma mensagem parecida com “command ‘ffmpeg’ not found”, siga as instruções no site do ffmpeg para baixar a biblioteca correta para seu sistema. O ffmpeg também está disponível nos repositórios de pacotes da maioria das distribuições Linux populares e no Homebrew para macOS:

$ brew install ffmpeg

Agora você pode experimentar redimensionar um arquivo de vídeo em segundo plano. Se não tiver um arquivo próprio, baixe este clipe do Pexels. Salve-o como video.mp4 no diretório de trabalho.

Em seguida, execute seu script para reduzir o clipe para 1280x720:

$ node video-main.js video.mp4 1280x720
Saved video.mp4 to /path-to-video-1280x720.mp4

Este exemplo funciona de forma um pouco diferente do anterior. Ele usa o conceito de “pool de workers” para aumentar a eficiência. Ao contrário do exemplo anterior, este não encerra o processo automaticamente quando o redimensionamento termina. A worker thread continua ativa e aguardando mensagens, então você precisará encerrá-la pressionando Ctrl+C no terminal.

Os pools de workers podem ajudar a reduzir o consumo de recursos. Como criar uma worker gera sobrecarga a cada vez, é recomendável iniciar uma quantidade fixa de threads e reutilizá-las sempre que possível. Bibliotecas como Poolifier, Piscina e outras semelhantes facilitam o uso de worker threads dessa forma. Elas gerenciam a criação de threads, até um limite definido pelo usuário, para atender a novas tarefas. As tarefas seguintes são então atribuídas às threads existentes conforme ficam disponíveis.

Usando workers para criptografar arquivos

Este exemplo mostra como combinar worker_threads com o módulo crypto do Node.js para criptografar um arquivo em uma thread separada.

Primeiro, salve o código da worker thread em um novo arquivo chamado encrypt-worker.js:

const {parentPort, workerData} = require("worker_threads");
const crypto = require("crypto");
const fs = require("fs");

const {file} = workerData;
const output = `${file}.encrypted`;

const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);

const cipher = crypto.createCipheriv("aes-256-ctr", Buffer.from(key), iv);
const readStream = fs.createReadStream(file);
const writeStream = fs.createWriteStream(output);
readStream.pipe(cipher).pipe(writeStream);
writeStream.on("close", () => parentPort.postMessage({key: key.toString("hex"), output, type: "done"}));

Copie este código da thread principal para um novo arquivo chamado encrypt-main.js:

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

// Perform some other tasks here

const fileToEncrypt = process.argv[2];

const worker = new Worker(
    __dirname + "/encrypt-worker.js",
    {
    workerData: {
    file: fileToEncrypt
    }
    }
);

worker.on("message", msg => {
    if (msg?.type === "done") {
    console.log(`File encrypted to ${msg.output}`);
    console.log(`The key is ${msg.key} - don't lose it!`);
    }
});

// Perform some other tasks that don't need to wait for the encryption here

Crie um arquivo de texto simples no diretório de trabalho para criptografar. Use o seguinte comando no terminal:

$ echo foobar > demo.txt

Agora execute seu código para criptografar o arquivo:

$ node encrypt-main.js demo.txt
File encrypted to demo.txt.encrypted
The key is 20eb1974c95553ff4f16638192da8b4cbfd780cec8544579f04cde181b78bd7c - don't lose it!

Neste exemplo, a worker thread envia uma mensagem para informar ao usuário o caminho do arquivo de saída e a chave secreta gerada.

Guia rápido de worker threads

O Node.js é um ambiente de execução com uma única thread. Isso se deve ao fato de o JavaScript ser uma linguagem síncrona e bloqueante, que executa tudo em uma thread. Os componentes assíncronos da biblioteca padrão do Node.js parecem não bloquear a execução porque usam um pool separado de threads.

O worker_threads módulo oferece um recurso semelhante para você incluir no próprio código. Não é multithreading de verdade, mas, em muitas situações, é suficiente para executar código em paralelo fora da thread principal.

Este artigo explicou o que são threads de trabalho, quando podem ser usadas e como começar a utilizá-las no seu projeto. O próximo artigo desta série vai explorar as vantagens e desvantagens das threads de trabalho e compará-las às implementações de multithreading encontradas em outras linguagens.