Em 18 de janeiro, a versão da plataforma
Node.js. 11.7.0 foi anunciada . Entre as mudanças notáveis nesta versão, pode-se notar a conclusão da categoria do módulo experimental worker_threads, que apareceu no
Node.js. 10.5.0 . Agora a bandeira --experimental-worker não é necessária para usá-la. Este módulo, desde a sua criação, permaneceu bastante estável e, portanto, a
decisão foi tomada, refletida no Node.js. 11.7.0.

O autor do material, cuja tradução estamos publicando, se oferece para discutir os recursos do módulo worker_threads, em particular, ele quer falar sobre por que esse módulo é necessário e como o multithreading é implementado no JavaScript e no Node.js por razões históricas. Aqui, falaremos sobre quais problemas estão associados à criação de aplicativos JS multithread, sobre as formas existentes de resolvê-los e sobre o futuro do processamento paralelo de dados usando os chamados "threads de trabalho", que são chamados de "threads de trabalho" ou apenas "trabalhadores".
Vida em um mundo de thread único
O JavaScript foi concebido como uma linguagem de programação de thread único que roda em um navegador. “Encadeamento único” significa que, no mesmo processo (nos navegadores modernos, estamos falando de guias separadas do navegador), apenas um conjunto de instruções pode ser executado por vez.
Isso simplifica o desenvolvimento de aplicativos, facilita o trabalho dos programadores. O JavaScript era originalmente uma linguagem adequada apenas para adicionar alguns recursos interativos às páginas da Web, por exemplo, algo como validação de formulário. Entre as tarefas para as quais o JS foi projetado, não havia nada particularmente complicado exigindo multithreading.
Ryan Dahl , criador do Node.js, viu uma oportunidade interessante nessa restrição de idioma. Ele queria implementar uma plataforma de servidor baseada em um subsistema de E / S assíncrona. Isso significava que o programador não precisava trabalhar com threads, o que simplifica bastante o desenvolvimento para uma plataforma semelhante. Ao desenvolver programas projetados para execução paralela de código, podem surgir problemas muito difíceis de resolver. Por exemplo, se vários threads tentarem acessar a mesma área de memória, isso poderá levar ao chamado "estado de corrida do processo" que interrompe o programa. Tais erros são difíceis de reproduzir e corrigir.
A plataforma Node.js é de thread único?
Os aplicativos Node.js. têm um thread? Sim, de certa forma é. De fato, o Node.js permite executar determinadas ações em paralelo, mas para isso, o programador não precisa criar threads ou sincronizá-las. A plataforma Node.js e o sistema operacional executam operações de entrada / saída paralelas por seus próprios meios e, quando chega a hora do processamento de dados usando nosso código JavaScript, ele funciona no modo de thread único.
Em outras palavras, tudo, exceto nosso código JS, funciona em paralelo. Em blocos síncronos de código JavaScript, os comandos são sempre executados um de cada vez, na ordem em que são apresentados no código-fonte:
let flag = false function doSomething() { flag = true
Tudo isso é ótimo - se todo o nosso código estiver ocupado com E / S assíncrona. O programa consiste em pequenos blocos de código síncrono que operam rapidamente em dados, por exemplo, enviados para arquivos e fluxos. O código dos fragmentos do programa é tão rápido que não impede a execução do código de seus outros fragmentos. Muito mais tempo do que a execução do código leva para aguardar os resultados de E / S assíncrona. Considere um pequeno exemplo:
db.findOne('SELECT ... LIMIT 1', function(err, result) { if (err) return console.error(err) console.log(result) }) console.log('Running query') setTimeout(function() { console.log('Hey there') }, 1000)
É possível que a consulta ao banco de dados mostrada aqui demore cerca de um minuto, mas a mensagem de
Running query
será enviada ao console imediatamente após o início dessa consulta. Nesse caso, a mensagem
Hey there
será exibida um segundo após a execução da solicitação, independentemente de sua execução ter sido concluída ou não. Nosso aplicativo Node.js simplesmente chama a função que inicia a solicitação, enquanto a execução de seu outro código não está bloqueada. Depois que a solicitação for concluída, o aplicativo será informado sobre isso usando a função de retorno de chamada e receberá uma resposta a essa solicitação.
Tarefas intensivas da CPU
O que acontece se, por meio do JavaScript, precisamos fazer computação pesada? Por exemplo - para processar um grande conjunto de dados armazenados na memória? Isso pode levar ao fato de que o programa conterá um fragmento de código síncrono, cuja execução leva muito tempo e bloqueia a execução de outro código. Imagine que esses cálculos demorem 10 segundos. Se estivermos falando de um servidor da Web que processa uma determinada solicitação, isso significa que ele não poderá processar outras solicitações por pelo menos 10 segundos. Este é um grande problema. De fato, cálculos maiores que 100 milissegundos já podem causar esse problema.
O JavaScript e a plataforma Node.js. não foram originalmente projetados para resolver tarefas que usam intensamente os recursos do processador. No caso de JS em execução no navegador, executar essas tarefas significa "freios" na interface do usuário. No Node.js, isso pode limitar a capacidade de solicitar que a plataforma execute novas tarefas de E / S assíncronas e a capacidade de responder a eventos associados à sua conclusão.
Vamos voltar ao nosso exemplo anterior. Imagine que, em resposta a uma consulta ao banco de dados, vários milhares de registros criptografados entraram, os quais, no código JS síncrono, devem ser descriptografados:
db.findAll('SELECT ...', function(err, results) { if (err) return console.error(err)
Os resultados, após recebê-los, estão na função de retorno de chamada. Depois disso, até o final de seu processamento, nenhum outro código JS pode ser executado. Normalmente, como já mencionado, a carga no sistema criada por esse código é mínima, ele executa rapidamente as tarefas atribuídas a ele. Mas, neste caso, o programa recebeu os resultados da consulta, que têm uma quantidade considerável, e ainda precisamos processá-los. Algo assim pode demorar alguns segundos. Se estivermos falando de um servidor com o qual muitos usuários trabalham, isso significa que eles poderão continuar trabalhando somente após a conclusão de uma operação que consome muitos recursos.
Por que o JavaScript nunca terá tópicos?
Dado o exposto acima, pode parecer que, para solucionar problemas pesados de computação no Node.js., você precisa adicionar um novo módulo que permita criar threads e gerenciá-los. Como você pode passar sem algo assim? É muito triste que aqueles que usam uma plataforma madura de servidor, como o Node.js., não tenham os meios para resolver belamente os problemas associados ao processamento de grandes quantidades de dados.
Tudo isso é verdade, mas se você adicionar a capacidade de trabalhar com fluxos em JavaScript, isso levará a uma alteração na própria natureza desse idioma. Em JS, você não pode adicionar a capacidade de trabalhar com threads, por exemplo, na forma de um novo conjunto de classes ou funções. Para fazer isso, você precisa alterar o próprio idioma. Em idiomas que suportam multithreading, o conceito de sincronização é amplamente usado. Por exemplo, em Java, mesmo
alguns tipos numéricos não são atômicos. Isso significa que, se os mecanismos de sincronização não forem usados para trabalhar com eles de diferentes threads, tudo isso poderá resultar, por exemplo, depois de alguns threads simultaneamente tentarem alterar o valor da mesma variável, vários bytes dessa variável serão definidos como um fluxo, e alguns outros. Como resultado, essa variável conterá algo incompatível com a operação normal do programa.
Solução primitiva para o problema: iteração do loop de eventos
O Node.js não executará o próximo bloco de código na fila de eventos até que o bloco anterior seja concluído. Isso significa que, para resolver nosso problema, podemos dividi-lo em partes representadas por fragmentos de código síncrono e, em seguida, usar uma construção do formulário
setImmediate(callback)
para planejar a execução desses fragmentos. O código especificado pela função de
callback
nesta construção será executado após a conclusão das tarefas da iteração atual (tick) do loop de eventos. Depois disso, o mesmo design é usado para enfileirar o próximo lote de cálculos. Isso permite não bloquear o ciclo de eventos e, ao mesmo tempo, resolver problemas volumétricos.
Imagine que temos uma grande matriz que precisa ser processada, enquanto o processamento de cada elemento dessa matriz exige cálculos complexos:
const arr = [] for (const item of arr) {
Como já mencionado, se decidirmos processar toda a matriz em uma chamada, isso levará muito tempo e impedirá a execução de outro código de aplicativo. Portanto, vamos dividir essa grande tarefa em partes e usar a construção
setImmediate(callback)
:
const crypto = require('crypto') const arr = new Array(200).fill('something') function processChunk() { if (arr.length === 0) {
Agora, de uma só vez, processamos dez elementos da matriz, depois dos quais, usando
setImmediate()
, planejamos o próximo lote de cálculos. E isso significa que, se você precisar executar mais código no programa, ele poderá ser executado entre operações no processamento de fragmentos da matriz. Para isso, aqui, no final do exemplo, há um código que usa
setInterval()
.
Como você pode ver, esse código parece muito mais complicado do que sua versão original. E, muitas vezes, o algoritmo pode ser muito mais complexo que o nosso, o que significa que, quando implementado, não será fácil dividir os cálculos em pedaços e entender onde, para alcançar o equilíbrio certo, você precisa definir
setImmediate()
, planejando a próxima peça de cálculo. Além disso, agora o código acabou sendo assíncrono e, se nosso projeto depender de bibliotecas de terceiros, talvez não possamos dividir o processo de resolver uma tarefa difícil em partes.
Processos em segundo plano
Talvez a abordagem acima com
setImmediate()
funcione bem em casos simples, mas está longe de ser ideal. Além disso, os threads não são usados aqui (por razões óbvias) e também não pretendemos alterar o idioma para isso. É possível fazer processamento de dados paralelo sem usar threads? Sim, é possível, e para isso precisamos de algum tipo de mecanismo para o processamento de dados em segundo plano. Trata-se de iniciar uma determinada tarefa, passar dados para ela e, para que essa tarefa, sem interferir no código principal, use tudo o que for necessário, gaste tanto tempo no trabalho quanto necessário e, em seguida, retorne os resultados para código principal. Precisamos de algo semelhante ao seguinte snippet de código:
A realidade é que no Node.js você pode usar processos em segundo plano. O ponto é que é possível criar uma bifurcação do processo e implementar o esquema de trabalho acima descrito usando o mecanismo de mensagens entre os processos filho e pai. O processo principal pode interagir com o processo descendente, enviando eventos para ele e recebendo-os dele. A memória compartilhada não é usada com essa abordagem. Todos os dados trocados por processos são "clonados", ou seja, quando são feitas alterações em uma instância desses dados por um processo, essas alterações não são visíveis para outro processo. Isso é semelhante a uma solicitação HTTP - quando um cliente envia para o servidor, o servidor recebe apenas uma cópia dele. Se os processos não usam memória compartilhada, isso significa que, com sua operação simultânea, é impossível criar um "estado de corrida" e que não precisamos nos sobrecarregar com o trabalho com threads. Parece que nosso problema foi resolvido.
É verdade que na realidade não é assim. Sim - diante de nós está uma das soluções para a tarefa de realizar cálculos intensivos, mas, novamente, é imperfeita. Criar uma bifurcação de um processo é uma operação que consome muitos recursos. Leva tempo para concluir. De fato, estamos falando sobre a criação de uma nova máquina virtual do zero e sobre o aumento da quantidade de memória consumida pelo programa, devido ao fato de que os processos não usam memória compartilhada. Diante do exposto, é apropriado perguntar se é possível, após a conclusão de uma tarefa, reutilizar a bifurcação do processo. Você pode dar uma resposta positiva a essa pergunta, mas aqui é necessário lembrar que está planejado transferir a bifurcação do processo para várias tarefas com muitos recursos que serão executadas nele de forma síncrona. Dois problemas podem ser vistos aqui:
- Embora, com essa abordagem, o processo principal não seja bloqueado, o processo descendente pode executar as tarefas transferidas para ele apenas sequencialmente. Se tivermos duas tarefas, uma das quais leva 10 segundos e a segunda leva 1 segundo, e vamos concluí-las nessa ordem, é improvável que gostemos da necessidade de aguardar a conclusão da primeira antes da segunda. Como estamos criando garfos de processo, gostaríamos de usar os recursos do sistema operacional para planejar tarefas e usar os recursos de computação de todos os núcleos do nosso processador. Precisamos de algo que se assemelhe a trabalhar em um computador para uma pessoa que ouve música e viaja através de páginas da web. Para fazer isso, você pode criar dois processos de fork e organizar a execução paralela de tarefas com a ajuda deles.
- Além disso, se uma das tarefas levar ao final do processo com um erro, todas as tarefas enviadas para esse processo serão não processadas.
Para resolver esses problemas, precisamos de vários processos de fork, não um, mas teremos que limitar seu número, pois cada um deles utiliza recursos do sistema e leva tempo para criar cada um deles. Como resultado, seguindo o padrão de sistemas que suportam conexões com o banco de dados, precisamos de algo como um conjunto de processos prontos para uso. O sistema de gerenciamento de pool de processos, após o recebimento de novas tarefas, utilizará processos livres para executá-los e, quando um determinado processo lidar com a tarefa, poderá atribuir um novo a ele. Há um sentimento de que esse esquema de trabalho não é fácil de implementar e, de fato, é. Usaremos o pacote
worker-farm para implementar este esquema:
Módulo Worker_threads
Então, nosso problema foi resolvido? Sim, podemos dizer que está resolvido, mas com essa abordagem, é necessária muito mais memória do que seria necessário se tivéssemos uma solução multithread à nossa disposição. Os threads consomem muito menos recursos em comparação com os garfos do processo. É por isso que o módulo
worker_threads
apareceu no
worker_threads
Os segmentos de trabalho são executados em um contexto isolado. Eles trocam informações com o processo principal usando mensagens. Isso nos salva do problema de “condição de corrida” ao qual estão sujeitos os ambientes multiencadeados. Ao mesmo tempo, os fluxos de trabalho existem no mesmo processo que o programa principal, ou seja, com essa abordagem, em comparação com o uso de garfos de processo, muito menos memória é usada.
Além disso, trabalhando com trabalhadores, você pode usar a memória compartilhada. Portanto, especificamente para esse fim, objetos do tipo
SharedArrayBuffer
são
SharedArrayBuffer
. Eles devem ser usados apenas nos casos em que o programa precisa executar um processamento complexo de grandes quantidades de dados. Eles permitem salvar os recursos necessários para serializar e desserializar dados ao organizar a troca de dados entre trabalhadores e o programa principal por meio de mensagens.
Trabalhador Trabalhador Fluxos
Se você usar a plataforma Node.js. antes da versão 11.7.0, para ativar o trabalho com o módulo
worker_threads
, será necessário usar o
--experimental-worker
ao iniciar o
--experimental-worker
Além disso, vale lembrar que a criação de um trabalhador (bem como a criação de um fluxo em qualquer idioma), embora exija muito menos recursos do que a criação de um fork de um processo, também cria uma certa carga no sistema. Talvez no seu caso, mesmo essa carga possa ser demais. Nesses casos, a documentação recomenda a criação de um pool de trabalhadores. Se você precisar disso, é claro que poderá criar sua própria implementação desse mecanismo, mas talvez deva procurar algo adequado no registro do NPM.
Considere um exemplo de trabalho com threads de trabalho. Teremos um arquivo principal,
index.js
, no qual criaremos um thread de trabalho e transmitiremos alguns dados para processamento. A API correspondente é baseada em eventos, mas vou usar uma promessa aqui que resolve quando a primeira mensagem do trabalhador chegar:
Como você pode ver, o uso do mecanismo de fluxo de fluxo de trabalho é bastante simples. Ou seja, ao criar um trabalhador, você precisa passar o caminho para o arquivo com o código e os dados do
Worker
para o designer do
Worker
. Lembre-se de que esses dados são clonados, não armazenados na memória compartilhada. Após iniciar o trabalhador, esperamos uma mensagem dele, ouvindo o evento da
message
.
Acima, ao criar um objeto do tipo
Worker
, passamos ao construtor o nome do arquivo com o código do worker -
service.js
. Aqui está o código para este arquivo:
const { workerData, parentPort } = require('worker_threads')
Há duas coisas que nos interessam no código do trabalhador. Primeiro, precisamos dos dados transmitidos pelo aplicativo principal. No nosso caso, eles são representados pela variável
workerData
. Em segundo lugar, precisamos de um mecanismo para transmitir informações para a aplicação principal. Esse mecanismo é representado pelo objeto
parentPort
, que possui o método
postMessage()
, usando o qual passamos os resultados do processamento de dados para o aplicativo principal. É assim que tudo funciona.
Aqui está um exemplo muito simples, mas usando os mesmos mecanismos, você pode construir estruturas muito mais complexas. Por exemplo, de um fluxo de trabalho, você pode enviar muitas mensagens para o fluxo principal que carregam informações sobre o estado do processamento de dados, caso nosso aplicativo precise de um mecanismo semelhante. Mesmo do trabalhador, os resultados do processamento de dados podem ser retornados em partes. Por exemplo, algo assim pode ser útil em uma situação em que um trabalhador está ocupado, por exemplo, processando milhares de imagens e você, sem esperar que todas sejam processadas, deseja notificar o aplicativo principal sobre a conclusão do processamento de cada uma delas.
Detalhes sobre o módulo
worker_threads
podem ser encontrados
aqui .
Trabalhadores da Web
Você pode ter ouvido falar de trabalhadores da web. Eles são projetados para uso em um ambiente cliente, essa tecnologia existe há muito tempo e possui um
bom suporte para navegadores modernos. A API para trabalhar com trabalhadores da Web é diferente do que o módulo Node.js
worker_threads
nos fornece, trata-se das diferenças nos ambientes em que eles trabalham. No entanto, essas tecnologias podem resolver problemas semelhantes. Por exemplo, os trabalhadores da Web podem ser usados em aplicativos clientes para executar criptografia e descriptografia de dados, sua compactação e descompactação. Com a ajuda deles, você pode processar imagens, implementar sistemas de visão computacional (por exemplo, estamos falando de reconhecimento de rosto) e resolver outros problemas semelhantes em um navegador.
Sumário
worker_threads
— Node.js. , , . , , , « ». , ? ,
worker_threads
, Node.js
worker-farm ,
worker_threads
, Node.js .
Caros leitores! Node.js-?
