Não Bloqueie o Loop de Eventos (ou o Pool de Trabalhadores)
Você deve ler este guia?
Se você está escrevendo algo mais complicado do que um breve script de linha de comando, ler isto deve ajudá-lo a escrever aplicativos de maior desempenho e mais seguros.
Este documento foi escrito com servidores Node.js em mente, mas os conceitos se aplicam também a aplicativos Node.js complexos. Onde os detalhes específicos do sistema operacional variam, este documento é centrado no Linux.
Resumo
O Node.js executa código JavaScript no Loop de Eventos (inicialização e callbacks) e oferece um Pool de Trabalhadores para lidar com tarefas dispendiosas, como E/S de arquivos. O Node.js escala bem, às vezes melhor do que abordagens mais pesadas como o Apache. O segredo da escalabilidade do Node.js é que ele usa um pequeno número de threads para lidar com muitos clientes. Se o Node.js consegue se virar com menos threads, então ele pode gastar mais tempo e memória do seu sistema trabalhando nos clientes em vez de pagar custos de espaço e tempo para threads (memória, troca de contexto). Mas, como o Node.js tem apenas algumas threads, você deve estruturar seu aplicativo para usá-las com sabedoria.
Aqui está uma boa regra prática para manter seu servidor Node.js rápido: O Node.js é rápido quando o trabalho associado a cada cliente a qualquer momento é "pequeno".
Isso se aplica a callbacks no Loop de Eventos e tarefas no Pool de Trabalhadores.
Por que devo evitar bloquear o Loop de Eventos e o Pool de Trabalhadores?
O Node.js usa um pequeno número de threads para lidar com muitos clientes. No Node.js, existem dois tipos de threads: um Loop de Eventos (também conhecido como loop principal, thread principal, thread de evento, etc.) e um pool de k
Trabalhadores em um Pool de Trabalhadores (também conhecido como pool de threads).
Se uma thread está demorando muito para executar um callback (Loop de Eventos) ou uma tarefa (Trabalhador), chamamos isso de "bloqueado". Enquanto uma thread está bloqueada trabalhando em nome de um cliente, ela não pode lidar com solicitações de outros clientes. Isso fornece duas motivações para não bloquear o Loop de Eventos nem o Pool de Trabalhadores:
- Desempenho: Se você regularmente realiza atividades pesadas em qualquer tipo de thread, a taxa de transferência (solicitações/segundo) do seu servidor sofrerá.
- Segurança: Se for possível que, para determinada entrada, uma de suas threads possa bloquear, um cliente malicioso pode enviar essa "entrada maligna", bloquear suas threads e impedi-las de trabalhar em outros clientes. Isso seria um ataque de negação de serviço.
Uma rápida revisão do Node
Node.js usa a Arquitetura Orientada a Eventos: possui um Loop de Eventos para orquestração e um Pool de Trabalhadores para tarefas dispendiosas.
Que código roda no Loop de Eventos?
Ao iniciarem, as aplicações Node.js primeiro completam uma fase de inicialização, require
'ando módulos e registrando callbacks para eventos. As aplicações Node.js então entram no Loop de Eventos, respondendo a requisições de clientes recebidas executando o callback apropriado. Este callback executa sincronicamente e pode registrar requisições assíncronas para continuar o processamento após sua conclusão. Os callbacks para estas requisições assíncronas também serão executados no Loop de Eventos.
O Loop de Eventos também atenderá às requisições assíncronas não bloqueantes feitas por seus callbacks, por exemplo, I/O de rede.
Em resumo, o Loop de Eventos executa os callbacks JavaScript registrados para eventos e também é responsável por atender requisições assíncronas não bloqueantes como I/O de rede.
Que código roda no Pool de Trabalhadores?
O Pool de Trabalhadores do Node.js é implementado em libuv (docs), que expõe uma API geral de submissão de tarefas.
Node.js usa o Pool de Trabalhadores para lidar com tarefas "dispendiosas". Isso inclui I/O para o qual um sistema operacional não fornece uma versão não bloqueante, bem como tarefas particularmente intensivas em CPU.
Estas são as APIs de módulo Node.js que utilizam este Pool de Trabalhadores:
- Intensivo em I/O
- DNS:
dns.lookup()
,dns.lookupService()
. - [Sistema de Arquivos][/api/fs]: Todas as APIs de sistema de arquivos, exceto
fs.FSWatcher()
e aquelas que são explicitamente síncronas, usam o threadpool do libuv.
- DNS:
- Intensivo em CPU
- Criptografia:
crypto.pbkdf2()
,crypto.scrypt()
,crypto.randomBytes()
,crypto.randomFill()
,crypto.generateKeyPair()
. - Zlib: Todas as APIs zlib, exceto aquelas que são explicitamente síncronas, usam o threadpool do libuv.
- Criptografia:
Em muitas aplicações Node.js, estas APIs são as únicas fontes de tarefas para o Pool de Trabalhadores. Aplicações e módulos que usam um addon C++ podem submeter outras tarefas ao Pool de Trabalhadores.
Por questão de completude, observamos que quando você chama uma dessas APIs de um callback no Loop de Eventos, o Loop de Eventos paga alguns custos de configuração menores ao entrar nas ligações C++ do Node.js para essa API e submeter uma tarefa ao Pool de Trabalhadores. Esses custos são insignificantes em comparação com o custo geral da tarefa, razão pela qual o Loop de Eventos está descarregando-a. Ao submeter uma dessas tarefas ao Pool de Trabalhadores, o Node.js fornece um ponteiro para a função C++ correspondente nas ligações C++ do Node.js.
Como o Node.js decide qual código executar em seguida?
Abstratamente, o Loop de Eventos e o Pool de Trabalhadores mantêm filas para eventos pendentes e tarefas pendentes, respectivamente.
Na verdade, o Loop de Eventos não mantém uma fila. Em vez disso, ele possui uma coleção de descritores de arquivos que solicita ao sistema operacional para monitorar, usando um mecanismo como epoll (Linux), kqueue (OSX), portas de eventos (Solaris) ou IOCP (Windows). Esses descritores de arquivo correspondem a sockets de rede, quaisquer arquivos que ele esteja monitorando e assim por diante. Quando o sistema operacional diz que um desses descritores de arquivo está pronto, o Loop de Eventos o traduz para o evento apropriado e invoca o(s) callback(s) associado(s) a esse evento. Você pode aprender mais sobre esse processo aqui.
Em contraste, o Pool de Trabalhadores usa uma fila real cujas entradas são tarefas a serem processadas. Um Trabalhador retira uma tarefa desta fila e trabalha nela, e quando termina, o Trabalhador gera um evento "Pelo menos uma tarefa foi concluída" para o Loop de Eventos.
O que isso significa para o design do aplicativo?
Em um sistema de uma thread por cliente como o Apache, cada cliente pendente recebe sua própria thread. Se uma thread que está lidando com um cliente bloquear, o sistema operacional a interromperá e dará a outro cliente uma vez. O sistema operacional garante, portanto, que os clientes que exigem uma pequena quantidade de trabalho não sejam penalizados por clientes que exigem mais trabalho.
Como o Node.js lida com muitos clientes com poucas threads, se uma thread bloquear o tratamento da solicitação de um cliente, as solicitações de clientes pendentes podem não ter uma vez até que a thread termine seu callback ou tarefa. O tratamento justo dos clientes é, portanto, responsabilidade do seu aplicativo. Isso significa que você não deve fazer muito trabalho para nenhum cliente em nenhum callback ou tarefa único.
Isso é parte do motivo pelo qual o Node.js pode escalar bem, mas também significa que você é responsável por garantir um agendamento justo. As próximas seções falam sobre como garantir um agendamento justo para o Loop de Eventos e para o Pool de Trabalhadores.
Não bloqueie o Loop de Eventos
O Loop de Eventos observa cada nova conexão de cliente e orquestra a geração de uma resposta. Todas as solicitações de entrada e respostas de saída passam pelo Loop de Eventos. Isso significa que se o Loop de Eventos demorar muito em qualquer ponto, todos os clientes atuais e novos não terão uma vez.
Você deve garantir que nunca bloqueie o Loop de Eventos. Em outras palavras, cada um de seus callbacks JavaScript deve ser concluído rapidamente. Isso, é claro, também se aplica aos seus await
s, aos seus Promise.then
s e assim por diante.
Uma boa maneira de garantir isso é raciocinar sobre a "complexidade computacional" de seus callbacks. Se seu callback levar um número constante de etapas, não importa quais sejam seus argumentos, você sempre dará a cada cliente pendente uma vez justa. Se seu callback levar um número diferente de etapas dependendo de seus argumentos, você deve pensar sobre o quanto os argumentos podem ser longos.
Exemplo 1: Um callback de tempo constante.
app.get('/constant-time', (req, res) => {
res.sendStatus(200)
})
Exemplo 2: Um callback O(n)
. Este callback será executado rapidamente para n
pequeno e mais lentamente para n
grande.
app.get('/countToN', (req, res) => {
let n = req.query.n
// n iterações antes de dar a outra pessoa uma vez
for (let i = 0; i < n; i++) {
console.log(`Iter ${i}`)
}
res.sendStatus(200)
})
Exemplo 3: Um callback O(n^2)
. Este callback ainda será executado rapidamente para n
pequeno, mas para n
grande, ele será executado muito mais lentamente do que o exemplo O(n)
anterior.
app.get('/countToN2', (req, res) => {
let n = req.query.n
// n^2 iterações antes de dar a outra pessoa uma vez
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
console.log(`Iter ${i}.${j}`)
}
}
res.sendStatus(200)
})
O quão cuidadoso você deve ser?
O Node.js usa o mecanismo Google V8 para JavaScript, que é bastante rápido para muitas operações comuns. Exceções a esta regra são expressões regulares e operações JSON, discutidas abaixo.
No entanto, para tarefas complexas, você deve considerar a delimitação da entrada e rejeitar entradas que são muito longas. Dessa forma, mesmo que seu callback tenha uma complexidade grande, delimitando a entrada, você garante que o callback não possa levar mais do que o tempo do pior caso na entrada mais longa aceitável. Você pode então avaliar o custo do pior caso deste callback e determinar se seu tempo de execução é aceitável em seu contexto.
Bloqueando o Loop de Eventos: REDOS
Uma maneira comum de bloquear o Loop de Eventos desastrosamente é usar uma expressão regular "vulnerável" expressão regular.
Evitando expressões regulares vulneráveis
Uma expressão regular (regexp) compara uma string de entrada a um padrão. Normalmente, pensamos em uma correspondência regexp como exigindo uma única passagem pela string de entrada --- O(n)
tempo, onde n
é o comprimento da string de entrada. Em muitos casos, uma única passagem é tudo o que é necessário. Infelizmente, em alguns casos, a correspondência regexp pode exigir um número exponencial de viagens pela string de entrada --- O(2^n)
tempo. Um número exponencial de viagens significa que se o mecanismo exigir x viagens para determinar uma correspondência, ele precisará de 2*x
viagens se adicionarmos apenas mais um caractere à string de entrada. Como o número de viagens é linearmente relacionado ao tempo necessário, o efeito dessa avaliação será bloquear o Loop de Eventos.
Uma expressão regular vulnerável é aquela na qual seu mecanismo de expressão regular pode levar tempo exponencial, expondo você a REDOS em "entrada maligna". Se o padrão de sua expressão regular é vulnerável (ou seja, o mecanismo regexp pode levar tempo exponencial nele) é realmente uma pergunta difícil de responder e varia dependendo se você está usando Perl, Python, Ruby, Java, JavaScript, etc., mas aqui estão algumas regras práticas que se aplicam a todas essas linguagens:
- Evite quantificadores aninhados como
(a+)*
. O mecanismo regexp do V8 pode lidar com alguns desses rapidamente, mas outros são vulneráveis. - Evite ORs com cláusulas sobrepostas, como
(a|a)*
. Novamente, estes são às vezes rápidos. - Evite usar backreferences, como
(a.*) \1
. Nenhum mecanismo regexp pode garantir a avaliação desses em tempo linear. - Se você estiver fazendo uma correspondência de string simples, use
indexOf
ou o equivalente local. Será mais barato e nunca levará mais do queO(n)
.
Se você não tiver certeza se sua expressão regular é vulnerável, lembre-se de que o Node.js geralmente não tem problemas em relatar uma correspondência, mesmo para um regexp vulnerável e uma string de entrada longa. O comportamento exponencial é acionado quando há uma incompatibilidade, mas o Node.js não pode ter certeza até tentar muitos caminhos pela string de entrada.
Um exemplo REDOS
Aqui está um exemplo de regexp vulnerável expondo seu servidor a REDOS:
app.get('/redos-me', (req, res) => {
let filePath = req.query.filePath
// REDOS
if (filePath.match(/(\/.+)+$/)) {
console.log('caminho válido')
} else {
console.log('caminho inválido')
}
res.sendStatus(200)
})
A regexp vulnerável neste exemplo é uma maneira (ruim!) de verificar um caminho válido no Linux. Ele corresponde a strings que são uma sequência de nomes delimitados por "/", como "/a/b/c
". É perigoso porque viola a regra 1: possui um quantificador duplamente aninhado.
Se um cliente consultar com filePath
///.../\n
(100 '/' seguidos de um caractere de nova linha que o "." da regexp não corresponderá), o Loop de Eventos levará efetivamente para sempre, bloqueando o Loop de Eventos. O ataque REDOS deste cliente faz com que todos os outros clientes não tenham uma vez até que a correspondência regexp termine.
Por esse motivo, você deve ter cuidado ao usar expressões regulares complexas para validar a entrada do usuário.
Recursos Anti-REDOS
Existem algumas ferramentas para verificar suas regexps quanto à segurança, como
No entanto, nenhuma dessas ferramentas detectará todas as regexps vulneráveis.
Outra abordagem é usar um mecanismo regexp diferente. Você pode usar o módulo node-re2, que usa o mecanismo regexp RE2 ultrarrápido do Google. Mas esteja avisado, o RE2 não é 100% compatível com as regexps do V8, portanto, verifique as regressões se você substituir o módulo node-re2 para lidar com suas regexps. E regexps particularmente complicadas não são suportadas pelo node-re2.
Se você está tentando corresponder a algo "óbvio", como uma URL ou um caminho de arquivo, encontre um exemplo em uma biblioteca regexp ou use um módulo npm, por exemplo, ip-regex.
Bloqueando o Loop de Eventos: módulos principais do Node.js
Vários módulos principais do Node.js possuem APIs caras síncronas, incluindo:
Essas APIs são caras, pois envolvem computação significativa (criptografia, compressão), exigem E/S (E/S de arquivo) ou potencialmente ambas (processo filho). Essas APIs são destinadas à conveniência de script, mas não são destinadas ao uso no contexto do servidor. Se você as executar no Loop de Eventos, elas levarão muito mais tempo para serem concluídas do que uma instrução JavaScript típica, bloqueando o Loop de Eventos.
Em um servidor, você não deve usar as seguintes APIs síncronas desses módulos:
- Criptografia:
crypto.randomBytes
(versão síncrona)crypto.randomFillSync
crypto.pbkdf2Sync
- Você também deve ter cuidado ao fornecer uma entrada grande para as rotinas de criptografia e descriptografia.
- Compressão:
zlib.inflateSync
zlib.deflateSync
- Sistema de arquivos:
- Não use as APIs síncronas do sistema de arquivos. Por exemplo, se o arquivo que você acessa estiver em um sistema de arquivos distribuído como NFS, os tempos de acesso podem variar amplamente.
- Processo filho:
child_process.spawnSync
child_process.execSync
child_process.execFileSync
Esta lista está razoavelmente completa a partir do Node.js v9.
Bloqueando o Loop de Eventos: JSON DOS
JSON.parse
e JSON.stringify
são outras operações potencialmente caras. Embora sejam O(n) no comprimento da entrada, para n grande, podem levar um tempo surpreendentemente longo.
Se seu servidor manipula objetos JSON, particularmente aqueles de um cliente, você deve ter cuidado com o tamanho dos objetos ou strings com os quais trabalha no Loop de Eventos.
Exemplo: Bloqueio JSON. Criamos um objeto obj
de tamanho 2^21 e JSON.stringify
-o, executamos indexOf na string e depois JSON.parse
-o. A string JSON.stringify
'd tem 50MB. Leva 0,7 segundos para serializar o objeto, 0,03 segundos para indexOf na string de 50MB e 1,3 segundos para analisar a string.
let obj = { a: 1 }
let niter = 20
let before, str, pos, res, took
for (let i = 0; i < niter; i++) {
obj = { obj1: obj, obj2: obj } // Dobra de tamanho a cada iteração
}
before = process.hrtime()
str = JSON.stringify(obj)
took = process.hrtime(before)
console.log('JSON.stringify levou ' + took)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('indexOf puro levou ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse levou ' + took)
Existem módulos npm que oferecem APIs JSON assíncronas. Veja, por exemplo:
- JSONStream, que possui APIs de stream.
- Big-Friendly JSON, que também possui APIs de stream, bem como versões assíncronas das APIs JSON padrão usando o paradigma de particionamento no Loop de Eventos descrito abaixo.
Cálculos complexos sem bloquear o Loop de Eventos
Suponha que você queira fazer cálculos complexos em JavaScript sem bloquear o Loop de Eventos. Você tem duas opções: particionamento ou descarregamento.
Particionamento
Você pode particionar seus cálculos para que cada um seja executado no Loop de Eventos, mas ceda regularmente (dê vez a) outros eventos pendentes. Em JavaScript, é fácil salvar o estado de uma tarefa em andamento em um closure, como mostrado no exemplo 2 abaixo.
Para um exemplo simples, suponha que você queira calcular a média dos números 1
a n
.
Exemplo 1: Média não particionada, custa O(n)
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('média: ' + avg)
Exemplo 2: Média particionada, cada uma das n
etapas assíncronas custa O(1)
.
function asyncAvg(n, avgCB) {
// Salva a soma em andamento no closure JS.
let sum = 0
function help(i, cb) {
sum += i
if (i == n) {
cb(sum)
return
}
// "Recursão assíncrona".
// Agenda a próxima operação assincronamente.
setImmediate(help.bind(null, i + 1, cb))
}
// Inicia o auxiliar, com CB para chamar avgCB.
help(1, function (sum) {
let avg = sum / n
avgCB(avg)
})
}
asyncAvg(n, function (avg) {
console.log('média de 1-n: ' + avg)
})
Você pode aplicar esse princípio a iterações de array e assim por diante.
Descarregamento
Se precisar fazer algo mais complexo, a partição não é uma boa opção. Isso ocorre porque a partição usa apenas o Loop de Eventos, e você provavelmente não se beneficiará dos vários núcleos disponíveis na sua máquina. Lembre-se, o Loop de Eventos deve orquestrar solicitações de clientes, e não atendê-las por si só. Para uma tarefa complicada, mova o trabalho do Loop de Eventos para um Pool de Trabalhadores.
Como descarregar
Você tem duas opções para um Pool de Trabalhadores de destino para o qual descarregar o trabalho.
- Você pode usar o Pool de Trabalhadores Node.js integrado desenvolvendo um addon C++. Em versões mais antigas do Node, crie seu addon C++ usando NAN, e em versões mais novas use N-API. node-webworker-threads oferece uma maneira apenas em JavaScript de acessar o Pool de Trabalhadores Node.js.
- Você pode criar e gerenciar seu próprio Pool de Trabalhadores dedicado à computação em vez do Pool de Trabalhadores temático de E/S do Node.js. As maneiras mais simples de fazer isso são usando Child Process ou Cluster.
Você não deve simplesmente criar um Child Process para cada cliente. Você pode receber solicitações de clientes mais rapidamente do que pode criar e gerenciar filhos, e seu servidor pode se tornar uma bomba de forks.
Desvantagem do descarregamento A desvantagem da abordagem de descarregamento é que ela incorre em sobrecarga na forma de custos de comunicação. Apenas o Loop de Eventos tem permissão para ver o "namespace" (estado JavaScript) do seu aplicativo. De um Trabalhador, você não pode manipular um objeto JavaScript no namespace do Loop de Eventos. Em vez disso, você tem que serializar e desserializar quaisquer objetos que deseja compartilhar. Então, o Trabalhador pode operar em sua própria cópia desse(s) objeto(s) e retornar o objeto modificado (ou uma "correção") para o Loop de Eventos.
Para problemas de serialização, consulte a seção sobre JSON DOS.
Algumas sugestões para descarregamento
Você pode querer distinguir entre tarefas intensivas em CPU e intensivas em E/S, pois elas têm características marcadamente diferentes.
Uma tarefa intensiva em CPU só progride quando seu Trabalhador é programado, e o Trabalhador deve ser programado em um dos núcleos lógicos da sua máquina. Se você tiver 4 núcleos lógicos e 5 Trabalhadores, um desses Trabalhadores não poderá progredir. Como resultado, você está pagando sobrecarga (custos de memória e programação) para este Trabalhador e não obtendo nenhum retorno por ele.
Tarefas intensivas em E/S envolvem consultar um provedor de serviço externo (DNS, sistema de arquivos, etc.) e aguardar sua resposta. Enquanto um Trabalhador com uma tarefa intensiva em E/S está aguardando sua resposta, ele não tem mais nada a fazer e pode ser desprogramado pelo sistema operacional, dando a outro Trabalhador a chance de enviar sua solicitação. Assim, as tarefas intensivas em E/S estarão progredindo mesmo quando a thread associada não estiver em execução. Provedores de serviços externos, como bancos de dados e sistemas de arquivos, foram altamente otimizados para lidar com muitas solicitações pendentes simultaneamente. Por exemplo, um sistema de arquivos examinará um grande conjunto de solicitações pendentes de gravação e leitura para mesclar atualizações conflitantes e recuperar arquivos em uma ordem ideal.
Se você depender apenas de um Pool de Trabalhadores, por exemplo, o Pool de Trabalhadores Node.js, as características diferentes do trabalho vinculado à CPU e vinculado à E/S podem prejudicar o desempenho do seu aplicativo.
Por esse motivo, você pode querer manter um Pool de Trabalhadores de Computação separado.
Descarregamento: conclusões
Para tarefas simples, como iterar sobre os elementos de um array arbitrariamente longo, a particionamento pode ser uma boa opção. Se o seu cálculo for mais complexo, o descarregamento é uma abordagem melhor: os custos de comunicação, ou seja, a sobrecarga de passar objetos serializados entre o Loop de Eventos e o Pool de Trabalhadores, são compensados pelo benefício de usar vários núcleos.
No entanto, se o seu servidor depender muito de cálculos complexos, você deve pensar se o Node.js é realmente uma boa escolha. O Node.js se destaca para trabalhos vinculados a E/S, mas para computação cara, pode não ser a melhor opção.
Se você adotar a abordagem de descarregamento, consulte a seção sobre não bloquear o Pool de Trabalhadores.
Não bloqueie o Pool de Trabalhadores
O Node.js possui um Pool de Trabalhadores composto por k Trabalhadores. Se você estiver usando o paradigma de Descarregamento discutido acima, poderá ter um Pool de Trabalhadores Computacionais separado, ao qual os mesmos princípios se aplicam. Em qualquer caso, vamos assumir que k é muito menor que o número de clientes que você pode estar atendendo simultaneamente. Isso está de acordo com a filosofia "uma thread para muitos clientes" do Node.js, o segredo de sua escalabilidade.
Como discutido acima, cada Trabalhador conclui sua Tarefa atual antes de prosseguir para a próxima na fila do Pool de Trabalhadores.
Agora, haverá variação no custo das Tarefas necessárias para lidar com as solicitações de seus clientes. Algumas Tarefas podem ser concluídas rapidamente (por exemplo, lendo arquivos curtos ou em cache, ou produzindo um pequeno número de bytes aleatórios), e outras levarão mais tempo (por exemplo, lendo arquivos maiores ou não armazenados em cache, ou gerando mais bytes aleatórios). Seu objetivo deve ser minimizar a variação nos tempos de Tarefa, e você deve usar a partição de Tarefas para alcançar isso.
Minimizar a variação nos tempos de Tarefa
Se a Tarefa atual de um Trabalhador for muito mais cara do que outras Tarefas, ela não estará disponível para trabalhar em outras Tarefas pendentes. Em outras palavras, cada Tarefa relativamente longa diminui efetivamente o tamanho do Pool de Trabalhadores em um até que seja concluída. Isso é indesejável porque, até certo ponto, quanto mais Trabalhadores no Pool de Trabalhadores, maior a taxa de transferência do Pool de Trabalhadores (tarefas/segundo) e, portanto, maior a taxa de transferência do servidor (solicitações de clientes/segundo). Um cliente com uma Tarefa relativamente cara diminuirá a taxa de transferência do Pool de Trabalhadores, diminuindo por sua vez a taxa de transferência do servidor.
Para evitar isso, você deve tentar minimizar a variação no comprimento das Tarefas que você envia para o Pool de Trabalhadores. Embora seja apropriado tratar os sistemas externos acessados pelas suas solicitações de E/S (DB, FS, etc.) como caixas pretas, você deve estar ciente do custo relativo dessas solicitações de E/S e deve evitar enviar solicitações que você espera que sejam particularmente longas.
Dois exemplos devem ilustrar a possível variação nos tempos de tarefa.
Exemplo de variação: Leitura de sistema de arquivos de longa duração
Suponha que seu servidor precise ler arquivos para lidar com algumas solicitações de cliente. Após consultar as APIs do Node.js Sistema de arquivos, você optou por usar fs.readFile()
por simplicidade. No entanto, fs.readFile()
não é (atualmente) particionado: ele envia uma única tarefa fs.read()
que abrange todo o arquivo. Se você ler arquivos mais curtos para alguns usuários e arquivos mais longos para outros, fs.readFile()
pode introduzir uma variação significativa nos comprimentos das tarefas, em detrimento da taxa de transferência do pool de trabalhadores.
Para um cenário de pior caso, suponha que um atacante possa convencer seu servidor a ler um arquivo arbitrário (esta é uma vulnerabilidade de travessia de diretório). Se seu servidor estiver executando Linux, o atacante pode nomear um arquivo extremamente lento: /dev/random
. Para todos os fins práticos, /dev/random
é infinitamente lento, e cada trabalhador solicitado a ler de /dev/random
nunca terminará essa tarefa. Um atacante então envia k solicitações, uma para cada trabalhador, e nenhuma outra solicitação de cliente que use o pool de trabalhadores progredirá.
Exemplo de variação: Operações criptográficas de longa duração
Suponha que seu servidor gere bytes aleatórios criptograficamente seguros usando crypto.randomBytes()
. crypto.randomBytes()
não é particionado: ele cria uma única tarefa randomBytes()
para gerar tantos bytes quanto você solicitou. Se você criar menos bytes para alguns usuários e mais bytes para outros, crypto.randomBytes()
é outra fonte de variação nos comprimentos das tarefas.
Particionamento de tarefas
Tarefas com custos de tempo variáveis podem prejudicar a taxa de transferência do pool de trabalhadores. Para minimizar a variação nos tempos de tarefa, na medida do possível, você deve particionar cada tarefa em subtarefas de custo comparável. Quando cada subtarefa for concluída, ela deverá enviar a próxima subtarefa e, quando a subtarefa final for concluída, ela deverá notificar o remetente.
Para continuar o exemplo fs.readFile()
, você deve usar fs.read()
(particiona manual) ou ReadStream
(automaticamente particionado).
O mesmo princípio se aplica a tarefas limitadas pela CPU; o exemplo asyncAvg
pode ser inadequado para o loop de eventos, mas é adequado para o pool de trabalhadores.
Quando você particiona uma tarefa em subtarefas, tarefas mais curtas se expandem em um pequeno número de subtarefas e tarefas mais longas se expandem em um número maior de subtarefas. Entre cada subtarefa de uma tarefa mais longa, o trabalhador ao qual ela foi atribuída pode trabalhar em uma subtarefa de outra tarefa mais curta, melhorando assim a taxa de transferência geral de tarefas do pool de trabalhadores.
Observe que o número de subtarefas concluídas não é uma métrica útil para a taxa de transferência do pool de trabalhadores. Em vez disso, preocupe-se com o número de tarefas concluídas.
Evitando a partição de Tarefas
Lembre-se que o propósito da partição de Tarefas é minimizar a variação nos tempos de Tarefa. Se você puder distinguir entre Tarefas mais curtas e Tarefas mais longas (por exemplo, somar um array versus ordenar um array), você poderia criar um Pool de Trabalhadores para cada classe de Tarefa. Rotear Tarefas mais curtas e Tarefas mais longas para Pools de Trabalhadores separados é outra maneira de minimizar a variação do tempo de Tarefa.
A favor desta abordagem, a partição de Tarefas incorre em sobrecarga (os custos de criar uma representação de Tarefa do Pool de Trabalhadores e de manipular a fila do Pool de Trabalhadores), e evitar a partição economiza os custos de viagens adicionais ao Pool de Trabalhadores. Também evita que você cometa erros na partição de suas Tarefas.
A desvantagem desta abordagem é que os Trabalhadores em todos esses Pools de Trabalhadores incorrerão em sobrecargas de espaço e tempo e competirão entre si pelo tempo da CPU. Lembre-se que cada Tarefa limitada pela CPU progride apenas enquanto está agendada. Como resultado, você só deve considerar esta abordagem após uma análise cuidadosa.
Pool de Trabalhadores: conclusões
Se você usa apenas o Pool de Trabalhadores do Node.js ou mantém Pool(s) de Trabalhadores separados, você deve otimizar o throughput de Tarefa de seu(s) Pool(s).
Para fazer isso, minimize a variação nos tempos de Tarefa usando a partição de Tarefas.
Os riscos dos módulos npm
Enquanto os módulos principais do Node.js oferecem blocos de construção para uma ampla variedade de aplicações, às vezes algo mais é necessário. Desenvolvedores Node.js se beneficiam tremendamente do ecossistema npm, com centenas de milhares de módulos oferecendo funcionalidade para acelerar seu processo de desenvolvimento.
Lembre-se, no entanto, que a maioria desses módulos são escritos por desenvolvedores terceiros e geralmente são lançados apenas com garantias de melhor esforço. Um desenvolvedor usando um módulo npm deve se preocupar com duas coisas, embora a última seja frequentemente esquecida.
- Ele honra suas APIs?
- Suas APIs podem bloquear o Event Loop ou um Worker? Muitos módulos não fazem nenhum esforço para indicar o custo de suas APIs, em detrimento da comunidade.
Para APIs simples, você pode estimar o custo das APIs; o custo da manipulação de strings não é difícil de entender. Mas em muitos casos não está claro quanto uma API pode custar.
Se você estiver chamando uma API que pode fazer algo caro, verifique o custo em dobro. Peça aos desenvolvedores que o documentem ou examine o código-fonte você mesmo (e envie um PR documentando o custo).
Lembre-se, mesmo que a API seja assíncrona, você não sabe quanto tempo ela pode gastar em um Worker ou no Event Loop em cada uma de suas partições. Por exemplo, suponha que no exemplo asyncAvg
dado acima, cada chamada à função auxiliar somou metade dos números em vez de um deles. Então esta função ainda seria assíncrona, mas o custo de cada partição seria O(n)
, não O(1)
, tornando-a muito menos segura para usar para valores arbitrários de n
.
Conclusão
O Node.js possui dois tipos de threads: um Loop de Eventos e Workers k. O Loop de Eventos é responsável pelos callbacks JavaScript e I/O não bloqueante, e um Worker executa tarefas correspondentes ao código C++ que completa uma solicitação assíncrona, incluindo I/O bloqueante e trabalho intensivo de CPU. Ambos os tipos de threads trabalham em no máximo uma atividade por vez. Se algum callback ou tarefa levar muito tempo, a thread que o executa fica bloqueada. Se seu aplicativo fizer callbacks ou tarefas bloqueantes, isso pode levar a uma redução de throughput (clientes/segundo) no melhor dos casos e a uma completa negação de serviço no pior.
Para escrever um servidor web de alto throughput, mais à prova de DoS, você deve garantir que, em entradas benignas e maliciosas, nem seu Loop de Eventos nem seus Workers bloquearão.