Multithreading em Node.js com worker threads: vantagens e desvantagens
James Walker
27 de fevereiro de 2023
0 minutos de leituraO Node.js apresenta à sua aplicação um loop de eventos de thread única, que pode fazer operações intensivas de CPU bloquearem a thread principal e causarem atrasos. O módulo worker_threads resolve esse problema oferecendo um mecanismo para executar código em paralelo por meio de uma forma de threading.
Em um artigo anterior, você viu o que são worker threads, quais são seus casos de uso mais comuns e como adicioná-las ao seu projeto. Neste artigo, vamos analisar as armadilhas das worker threads e como elas diferem das implementações de multithreading de outras linguagens de programação. Também vamos conhecer cinco bibliotecas importantes que facilitam o uso do módulo worker_threads.
Armadilhas e pontos de atenção das worker threads
As vantagens das worker threads podem ser resumidas facilmente: elas são a única maneira de obter algo semelhante ao multithreading ao programar com Node.js. Operações intensivas de CPU, processamento em segundo plano e qualquer execução de código em paralelo — exceto I/O assíncrono — podem ser implementados usando worker threads.
No entanto, o módulo e o conceito que ele implementa têm algumas ressalvas. Você precisa conhecê-las antes de começar a implementar seus workers, pois há situações em que esse mecanismo não deve ser usado para paralelizar tarefas.
Worker threads não são threads de verdade
A primeira e mais importante limitação das worker threads é que elas não são threads no sentido convencional. Aplicações realmente multithread permitem executar várias threads ao mesmo tempo, compartilhando o mesmo estado por padrão. No Node.js, alterações na memória feitas em uma thread não ficam visíveis para as outras, e implementar código multithread exige um gerenciamento cuidadoso da memória para evitar condições de corrida.
As worker threads do Node.js operam independentemente do código JavaScript no processo principal. Elas funcionam iniciando uma instância isolada do runtime JavaScript V8 do Node.js. Esse novo runtime pode então ser usado para executar um arquivo JavaScript fora do loop de eventos principal.
Como o arquivo é executado dessa forma, não há compartilhamento implícito de memória entre o programa principal e a "thread" worker. Em vez disso, há um sistema de mensagens baseado em eventos para que os processos troquem valores.
Este código, que você analisou em detalhes na parte um, cria um processo worker que recebe um valor (hello) da thread principal e o envia de volta em outra forma (You said "hello".). A função postMessage() é usada para enviar dados entre a thread principal e a worker. As variáveis definidas de um lado não ficam visíveis do outro.
Há uma exceção a essa regra: você pode usar um SharedArrayBuffer para compartilhar memória diretamente entre as threads, alocando-a especificamente como memória compartilhada:
Ao salvar esse código em shared.js e executar o arquivo, você verá a seguinte saída:
Isso funciona porque a worker thread pode acessar a região de memória compartilhada criada pela thread principal. Quando a thread principal verifica os dados depois, ela vê as alterações feitas pela worker. Ainda assim, não há compartilhamento de estado de verdade, pois você precisa disponibilizar manualmente o array para a worker usando a opção de construtor workerData.
Iniciar muitas worker threads custa caro
Criar uma worker thread não é o mesmo que iniciar uma nova thread em uma linguagem multithread. Cada worker thread executa sua própria instância do mecanismo JavaScript V8; por isso, usar workers demais consome muitos recursos do seu host.
Embora os workers iniciem rapidamente, sempre há um custo associado. A operação é relativamente cara, o que torna as worker threads inadequadas para tarefas leves. O ideal é reservá-las para atividades intensivas de CPU que possam ser processadas em paralelo, quando o ganho de desempenho compensar com folga o custo de iniciar o processo.
Você pode reduzir essa ineficiência reutilizando um pool de worker threads, evitando o custo repetido de criar novas threads. Bibliotecas como Piscina e Poolifier abstraem a complexidade de gerenciar um pool de workers.
Usar worker threads para I/O é um desperdício
Por sua natureza, worker threads não são adequadas para tarefas de I/O. Você não precisa de worker threads para ler um arquivo ou buscar dados pela rede: o Node.js já oferece alternativas assíncronas melhores.
A documentação de worker_threads recomenda expressamente não usar o módulo nessas situações. Criar e manter o processo do worker, com seu próprio mecanismo V8, é bem menos eficiente do que usar as implementações de I/O assíncrono do Node.js. Se você implementar essas tarefas como worker threads, vai prejudicar o desempenho, desperdiçar recursos e escrever código redundante.
Depurar worker threads pode ser difícil
Pode ser difícil depurar worker threads em pool, pois nem sempre fica claro qual worker processou determinado evento e qual foi o efeito gerado. Tentar entender o que está acontecendo usando instruções console.log() é trabalhoso e propenso a erros.
Você pode obter informações de diagnóstico mais úteis adicionando um AsyncResource ao seu pool. Isso fornece rastreamentos completos da pilha assíncrona, que acompanham o que acontece dentro do pool e permitem ver toda a sequência de atividades que levou a determinado efeito.
Compartilhar memória usando um SharedArrayBuffer também abre espaço para problemas. Você precisa usar atomics ou implementar seu próprio sistema de gerenciamento de concorrência para evitar condições de corrida ao acessar e modificar a memória compartilhada. Se ocorrerem condições de corrida, elas podem causar sintomas estranhos na aplicação e geralmente são difíceis de identificar, especialmente quando envolvem memória usada em muitos lugares diferentes.
Principais bibliotecas de threading para Node.js
O módulo worker_threads integrado ao Node.js se concentra no básico: criar worker threads e trocar dados com elas. Estas são cinco bibliotecas populares que encapsulam o módulo para oferecer uma interface mais conveniente ou recursos de nível mais alto, como pools de threads.
Piscina
Piscina facilita o trabalho com pools de workers. Você pode criar suas próprias filas de tarefas, acompanhar a conclusão delas e cancelar uma tarefa executada em um worker se ela se mostrar desnecessária.
Veja um exemplo simples com Piscina. Salve este código em main.js:
Agora adicione este código a worker.js:
Instale o pacote Piscina com o seguinte comando:
Ao executar node main.js, você verá You said hello no terminal. Piscina oferece uma interface mais conveniente para a API de worker threads.
Bree
Bree é um agendador de tarefas para Node.js. Ele permite executar tarefas assíncronas em intervalos definidos. Você pode configurar limites de concorrência, suporte a novas tentativas e cancelamento para cada tarefa. Internamente, Bree usa worker threads para executar o código das tarefas fora do loop principal.
Instale Bree usando npm:
Agora crie um arquivo chamado bree-main.js com o seguinte código:
Adicione o seguinte código a jobs/bree-job.js:
Ao executar node bree-main.js, a hora será exibida imediatamente e, depois, a cada cinco segundos:
Poolifier
Poolifier é outra implementação de pool de workers. Ela permite gerenciar vários workers sem a complexidade de administrar o pool por conta própria. Os pools podem ser fixos, ou seja, conter um número definido de workers reutilizados, ou dinâmicos, com workers adicionados conforme necessário até atingir o limite configurado pelo usuário.
Você pode criar um pool simples para executar um arquivo específico em uma worker thread adicionando o seguinte código a main.js:
Defina o código que será executado no pool fixo adicionando o seguinte conteúdo a fixed-worker.js:
Agora adicione a dynamic-worker.js o código que será executado no pool dinâmico:
Instale o pacote poolifier pelo npm e, em seguida, execute main.js com o Node. Você deverá ver a saída a seguir enquanto os dois pools de threads iniciam e executam suas tarefas:
O processo continuará em execução até você encerrá-lo pressionando Ctrl+C. O Poolifier mantém os pools de threads disponíveis para receber novas tarefas, impedindo que o processo seja encerrado enquanto ainda houver pools ativos.
Worker threads versus outras linguagens de programação
Cada linguagem de programação implementa multithreading de um jeito. No caso do Node.js, isso é feito pelo sistema multiprocesso oferecido pelo módulo worker_threads. Veja o que algumas outras linguagens oferecem.
C/C++: Por serem linguagens de baixo nível, C e C++ oferecem multithreading de verdade por meio da biblioteca de threading POSIX pthreads. O C++ também tem um objeto thread no namespace padrão, que simplifica ainda mais a concorrência. Você é responsável por usar operações atômicas e mutexes para sincronizar a memória corretamente e evitar condições de corrida.
Java: Java também oferece multithreading de verdade usando a classe
Threadou a interfaceRunnable. Esses recursos são complementados por um conjunto abrangente de ferramentas de concorrência, que ajuda você a gerenciar as threads criadas.Python: Python tem uma biblioteca de threading, mas a implementação mais popular da linguagem, CPython, só consegue executar uma thread por vez. Isso significa que, embora o threading pareça estar disponível, ele não acelera código intensivo de CPU. Python também oferece o módulo multiprocessing, que adota uma abordagem semelhante à das worker threads do Node.js.
Ruby: A situação do Ruby é parecida com a do Python. A linguagem oferece suporte abrangente a threads, mas o MRI Ruby, sua implementação mais popular, só dá suporte a uma thread por vez. Já os interpretadores alternativos mais recentes, como JRuby e Rubinius, oferecem suporte real a multithreading.
Rust: Rust oferece suporte abrangente a multithreading. Com Rust, é fácil criar threads e compartilhar dados entre elas com baixo risco de erros. O design da linguagem torna impossíveis muitos bugs comuns de concorrência, o que faz dela uma ótima escolha para projetos que dependem muito de multithreading.
O threading em C/C++, Rust e Java será muito mais rápido do que o modelo de subprocessos do Node.js. Essas linguagens expõem threads de verdade, com estado compartilhado e todas as preocupações relacionadas ao gerenciamento de memória que isso traz. Linguagens interpretadas de nível mais alto, como Python, Ruby e Node.js, não oferecem implementações nativas de threads e recorrem a soluções mais pesadas, baseadas em subprocessos workers.
Conclusão
Worker threads permitem que desenvolvedores Node.js executem código em paralelo iniciando novos processos filhos. No entanto, isso não é multithreading de verdade: cada "thread" é um processo independente, sem acesso ao contexto do processo pai. Só é possível se comunicar entre threads por meio de memória compartilhada alocada e mensagens trocadas com um listener de eventos.
O módulo worker_threads continua sendo uma parte indispensável do ecossistema Node.js. Dentro dos limites impostos pelo JavaScript, uma linguagem síncrona e bloqueante, não há outra maneira de obter multithreading e processamento paralelo. Ainda assim, é importante conhecer as limitações das worker threads para decidir quando usá-las. Adicionar um worker na situação errada pode reduzir o desempenho da sua aplicação e aumentar o consumo de recursos.
Código extremamente intensivo de CPU, quando o desempenho é essencial, terá melhor desempenho com threads de verdade em outra linguagem de programação. Ainda assim, worker threads são suficientes para a maioria dos casos de uso do Node.js, como filas de tarefas em aplicações web ou processamento de vídeo em segundo plano no seu computador.


