Como o Node.js é inerentemente mais rápido quando ainda depende internamente de threads?

281

Acabei de assistir o seguinte vídeo: Introdução ao Node.js e ainda não entendo como você obtém os benefícios de velocidade.

Principalmente, a certa altura, Ryan Dahl (criador do Node.js.) diz que o Node.js é baseado em loop de eventos, em vez de em threads. Os threads são caros e devem ser deixados apenas para os especialistas em programação simultânea a serem utilizados.

Posteriormente, ele mostra a pilha de arquitetura do Node.js que possui uma implementação C subjacente e que possui seu próprio pool de threads internamente. Então, obviamente, os desenvolvedores do Node.js. nunca lançariam seus próprios threads ou usariam o pool de threads diretamente ... eles usam retornos de chamada assíncronos. Isso eu entendo.

O que eu não entendo é o ponto em que o Node.js ainda está usando threads ... está apenas ocultando a implementação. Por que isso é mais rápido se 50 pessoas solicitam 50 arquivos (atualmente não estão na memória) e não são necessários 50 threads? ?

A única diferença é que, como é gerenciado internamente, o desenvolvedor do Node.js. não precisa codificar os detalhes do encadeamento, mas por baixo ainda está usando os encadeamentos para processar as solicitações de arquivo IO (bloqueio).

Então você não está apenas pegando um problema (encadeamento) e ocultando-o enquanto esse problema ainda existe: principalmente vários threads, alternância de contexto, travas ... etc?

Deve haver alguns detalhes que ainda não entendo aqui.

Ralph Caraveo
fonte
14
Estou inclinado a concordar com você de que a reivindicação é um pouco simplificada. Acredito que a vantagem de desempenho do nó se resume a duas coisas: 1) os threads reais estão todos contidos em um nível bastante baixo e, portanto, permanecem restritos em tamanho e número, e a sincronização do thread é simplificada; 2) A "comutação" no nível do sistema operacional select()é mais rápida que as trocas de contexto de thread.
Pointy

Respostas:

140

Na verdade, existem algumas coisas diferentes sendo confundidas aqui. Mas começa com o meme de que os tópicos são realmente difíceis. Portanto, se eles são difíceis, é mais provável que, ao usar threads para 1) quebrar devido a bugs e 2) não os use da maneira mais eficiente possível. (2) é sobre o que você está perguntando.

Pense em um dos exemplos que ele fornece, em que uma solicitação é recebida e você executa alguma consulta e, em seguida, faz algo com os resultados dela. Se você o escrever de maneira processual padrão, o código poderá ser assim:

result = query( "select smurfs from some_mushroom" );
// twiddle fingers
go_do_something_with_result( result );

Se a solicitação recebida fez com que você criasse um novo encadeamento que executasse o código acima, você terá um encadeamento ali, sem fazer nada enquanto query()estiver em execução. (O Apache, de acordo com Ryan, está usando um único encadeamento para satisfazer a solicitação original, enquanto o nginx está superando-o nos casos de que ele está falando porque não está.)

Agora, se você fosse realmente inteligente, expressaria o código acima de uma maneira em que o ambiente pudesse disparar e fazer outra coisa enquanto você estiver executando a consulta:

query( statement: "select smurfs from some_mushroom", callback: go_do_something_with_result() );

Isso é basicamente o que o node.js está fazendo. Você está basicamente decorando - de uma maneira que seja conveniente por causa da linguagem e do ambiente, daí os pontos sobre os fechamentos - seu código de forma que o ambiente possa ser inteligente sobre o que é executado e quando. Dessa forma, o node.js não é novo no sentido de que inventou E / S assíncrona (não que alguém tenha reivindicado algo assim), mas é novo no modo como é expresso é um pouco diferente.

Nota: quando digo que o ambiente pode ser inteligente sobre o que é executado e quando, especificamente, o que quero dizer é que o encadeamento usado para iniciar algumas E / S agora pode ser usado para lidar com outra solicitação ou algum cálculo que possa ser feito em paralelo ou inicie outra E / S paralela. (Não sei se o nó é sofisticado o suficiente para iniciar mais trabalho para a mesma solicitação, mas você entendeu.)

jrtipton
fonte
6
Ok, eu definitivamente posso ver como isso pode aumentar o desempenho porque me parece que você pode maximizar sua CPU porque não há threads ou pilhas de execução esperando apenas o retorno do IO, para que o que Ryan tenha feito seja efetivamente encontrado uma maneira de fechar todas as lacunas.
Ralph Caraveo
34
Sim, a única coisa que eu diria é que ele não encontrou uma maneira de fechar as lacunas: não é um novo padrão. O que é diferente é que ele está usando Javascript para permitir que o programador expresse seu programa de uma maneira que seja muito mais conveniente para esse tipo de assincronia. Possivelmente um detalhe detalhista, mas ainda assim ...
jrtipton
16
Também vale ressaltar que, para muitas tarefas de E / S, o Node usa qualquer API de E / S assíncrona no nível do kernel disponível (epoll, kqueue, / dev / poll, o que for)
Paul
7
Ainda não tenho certeza de que entendi completamente. Se considerarmos que as operações de E / S de solicitação da Web são as que demoram o tempo necessário para processar a solicitação e, para cada operação de E / S, um novo encadeamento é criado, então, para 50 solicitações que ocorrem em uma sucessão muito rápida, provavelmente tem 50 threads rodando em paralelo e executando sua parte de E / S. A diferença dos servidores Web padrão é que, ali, toda a solicitação é executada no encadeamento, enquanto no node.js apenas sua parte de E / S, mas essa é a parte que está demorando a maior parte do tempo e fazendo com que o encadeamento espere.
Florin Dumitrescu
13
@SystemParadox, obrigado por apontar isso. Ultimamente, fiz algumas pesquisas sobre o tópico e, de fato, o problema é que a E / S assíncrona, quando implementada adequadamente no nível do kernel, não usa threads ao executar operações de E / S assíncronas. Em vez disso, o encadeamento de chamada é liberado assim que uma operação de E / S é iniciada e um retorno de chamada é executado quando a operação de E / S é concluída e um encadeamento está disponível para ele. Portanto, o node.js pode executar 50 solicitações simultâneas com 50 operações de E / S em (quase) paralelamente usando apenas um encadeamento se o suporte assíncrono para as operações de E / S for implementado corretamente.
Florin Dumitrescu
32

Nota! Esta é uma resposta antiga. Embora ainda seja verdade no esboço, alguns detalhes podem ter sido alterados devido ao rápido desenvolvimento do Node nos últimos anos.

Está usando threads porque:

  1. A opção O_NONBLOCK de open () não funciona em arquivos .
  2. Existem bibliotecas de terceiros que não oferecem IO sem bloqueio.

Para falsas E / S sem bloqueio, os threads são necessários: faça o IO em um thread separado. É uma solução feia e causa muita sobrecarga.

É ainda pior no nível do hardware:

  • Com o DMA, a CPU transfere assincronamente as entradas / saídas.
  • Os dados são transferidos diretamente entre o dispositivo IO e a memória.
  • O kernel envolve isso em uma chamada de sistema síncrona e bloqueadora.
  • O Node.js agrupa a chamada do sistema de bloqueio em um encadeamento.

Isso é simplesmente estúpido e ineficiente. Mas funciona pelo menos! Podemos aproveitar o Node.js porque oculta os detalhes feios e pesados ​​por trás de uma arquitetura assíncrona orientada a eventos.

Talvez alguém implemente O_NONBLOCK para arquivos no futuro? ...

Edit: Eu discuti isso com um amigo e ele me disse que uma alternativa para threads está pesquisando com select : especifique um tempo limite de 0 e faça IO nos descritores de arquivo retornados (agora que eles garantem que não serão bloqueados).

nalply
fonte
E o Windows?
Pacerier 19/02
Desculpe, não faço ideia. Eu só sei que o libuv é a camada neutra na plataforma para realizar um trabalho assíncrono. No começo do Node não havia libuv. Foi então decidido dividir o libuv e isso tornou o código específico da plataforma mais fácil. Em outras palavras, o Windows tem sua própria história assíncrona que pode ser completamente diferente do Linux, mas para nós isso não importa, porque o libuv faz o trabalho duro para nós.
Nalply 1/11
28

Receio estar "fazendo a coisa errada" aqui, se assim for, me exclua e peço desculpas. Em particular, não vejo como crio as pequenas anotações que algumas pessoas criaram. No entanto, tenho muitas preocupações / observações a fazer sobre este tópico.

1) O elemento comentado no pseudo-código em uma das respostas populares

result = query( "select smurfs from some_mushroom" );
// twiddle fingers
go_do_something_with_result( result );

é essencialmente falso. Se o encadeamento estiver computando, então não está girando o polegar, está fazendo o trabalho necessário. Se, por outro lado, é simplesmente aguardando a conclusão de IO, então é não usar tempo de CPU, toda a questão da infra-estrutura de controle fio no kernel é que a CPU vai encontrar algo útil para fazer. A única maneira de "mexer os polegares", como sugerido aqui, seria criar um loop de pesquisa, e ninguém que codificou um servidor da Web real é inepto o suficiente para fazer isso.

2) "Threads are hard", só faz sentido no contexto do compartilhamento de dados. Se você tiver threads essencialmente independentes, como é o caso ao lidar com solicitações independentes da Web, o encadeamento é trivialmente simples, basta codificar o fluxo linear de como lidar com um trabalho e ficar tranquilo sabendo que ele tratará vários pedidos e cada um será efetivamente independente. Pessoalmente, eu arriscaria que, para a maioria dos programadores, aprender o mecanismo de fechamento / retorno de chamada é mais complexo do que simplesmente codificar a versão do thread de cima para baixo. (Mas sim, se você precisar se comunicar entre os threads, a vida fica muito difícil muito rapidamente, mas não estou convencido de que o mecanismo de fechamento / retorno de chamada realmente mude isso, apenas restringe suas opções, porque essa abordagem ainda é possível com threads Enfim, isso '

3) Até agora, ninguém apresentou nenhuma evidência real de por que um tipo específico de mudança de contexto consumiria mais ou menos tempo do que qualquer outro tipo. Minha experiência na criação de kernels multitarefa (em pequena escala para controladores incorporados, nada tão sofisticado quanto um sistema operacional "real") sugere que esse não seria o caso.

4) Todas as ilustrações que vi até agora que pretendem mostrar o quão mais rápido o Node é do que outros servidores da Web são terrivelmente falhas, no entanto, são falhas de uma maneira que ilustra indiretamente uma vantagem que eu definitivamente aceitaria para o Node (e não é de forma alguma insignificante). O nó não parece precisar (ou até mesmo permitir) de ajuste. Se você tiver um modelo encadeado, precisará criar encadeamentos suficientes para lidar com a carga esperada. Faça isso mal e você terá um desempenho ruim. Se houver muito poucos encadeamentos, a CPU estará ociosa, mas incapaz de aceitar mais solicitações, criar muitos encadeamentos e você desperdiçará memória do kernel e, no caso de um ambiente Java, também estará desperdiçando a memória heap principal . Agora, para Java, desperdiçar heap é a primeira e melhor maneira de estragar o desempenho do sistema, porque a coleta eficiente de lixo (atualmente, isso pode mudar com o G1, mas parece que o júri ainda está nesse ponto desde o início de 2013, pelo menos) depende de ter muita pilha de reposição. Portanto, existe o problema: ajuste-o com muito poucos threads, você terá CPUs ociosas e taxa de transferência ruim, ajuste-o com muitos threads e atolará de outras maneiras.

5) Existe outra maneira pela qual aceito a lógica da afirmação de que a abordagem do Node "é mais rápida por design", e é isso. A maioria dos modelos de encadeamento usa um modelo de comutação de contexto com fatias de tempo, em camadas sobre o modelo preemptivo mais apropriado (alerta de julgamento do valor :) e mais eficiente (não um julgamento do valor). Isso acontece por duas razões: primeiro, a maioria dos programadores parece não entender a preempção de prioridade; e, segundo, se você aprender a segmentação em um ambiente Windows, o intervalo de tempo existe, quer você goste ou não (é claro, isso reforça o primeiro ponto) ; notavelmente, as primeiras versões do Java usavam preempção de prioridade nas implementações do Solaris e tempo no Windows. Porque a maioria dos programadores não entendeu e reclamou que "o encadeamento não funciona no Solaris" eles mudaram o modelo para timeslice em todos os lugares). De qualquer forma, a linha inferior é que o timelicing cria opções de contexto adicionais (e potencialmente desnecessárias). Cada troca de contexto leva tempo de CPU, e esse tempo é efetivamente removido do trabalho que pode ser feito no trabalho real em questão. No entanto, a quantidade de tempo investido na mudança de contexto por causa da divisão do tempo não deve ser superior a uma porcentagem muito pequena do tempo total, a menos que algo bastante estranho esteja acontecendo, e não há motivo para esperar que esse seja o caso em um servidor web simples). Portanto, sim, as excessivas alternâncias de contexto envolvidas na divisão do tempo são ineficientes (e isso não ocorre em e esse tempo é efetivamente removido do trabalho que pode ser feito no trabalho real em questão. No entanto, a quantidade de tempo investido na mudança de contexto por causa da divisão do tempo não deve ser superior a uma porcentagem muito pequena do tempo total, a menos que algo bastante estranho esteja acontecendo, e não há motivo para esperar que esse seja o caso em um servidor web simples). Portanto, sim, as excessivas alternâncias de contexto envolvidas na divisão do tempo são ineficientes (e isso não ocorre em e esse tempo é efetivamente removido do trabalho que pode ser feito no trabalho real em questão. No entanto, a quantidade de tempo investido na mudança de contexto por causa da divisão do tempo não deve ser superior a uma porcentagem muito pequena do tempo total, a menos que algo bastante estranho esteja acontecendo, e não há motivo para esperar que esse seja o caso em um servidor web simples). Portanto, sim, as excessivas alternâncias de contexto envolvidas na divisão do tempo são ineficientes (e isso não ocorre emcomo regra, os threads do kernel ), mas a diferença será de alguns por cento da taxa de transferência, e não do tipo de fatores de número inteiro que estão implícitos nas declarações de desempenho que geralmente estão implícitas no Node.

De qualquer forma, peço desculpas por tudo isso ser longo e desmedido, mas eu realmente sinto que até agora, a discussão não provou nada, e eu ficaria feliz em ouvir alguém de uma dessas situações:

a) uma explicação real de por que o Node deve ser melhor (além dos dois cenários que descrevi acima, o primeiro dos quais (mau ajuste) acredito ser a explicação real de todos os testes que vi até agora. ], na verdade, quanto mais eu penso sobre isso, mais me pergunto se a memória usada por um grande número de pilhas pode ser significativa aqui. Os tamanhos de pilha padrão para threads modernos tendem a ser bastante grandes, mas a memória alocada por um sistema de eventos baseado em fechamento seria apenas o necessário)

b) uma referência real que realmente oferece uma boa chance para o servidor de escolha. Pelo menos dessa maneira, eu teria que parar de acreditar que as afirmações são essencialmente falsas; referências mostradas não são razoáveis).

Saúde, Toby

Toby Eggitt
fonte
2
Um problema com os threads: eles precisam de RAM. Um servidor muito ocupado pode executar alguns milhares de threads. O Node.js evita os threads e, portanto, é mais eficiente. A eficiência não é executar o código mais rapidamente. Não importa se o código é executado em threads ou em um loop de eventos. Para a CPU é o mesmo. Mas, ao eliminar os threads, economizamos RAM: apenas uma pilha em vez de alguns milhares de pilhas. E também salvamos opções de contexto.
nalply
3
Mas o nó não está eliminando os threads. Ele ainda os usa internamente para as tarefas de E / S, que é o que a maioria das solicitações da Web exige.
levi 30/03
1
Também o nó armazena fechamentos de retornos de chamada na RAM, para que eu não possa ver onde ele vence.
Oleksandr Papchenko
@levi Mas o nodejs não usa o tipo de "um thread por solicitação". Ele usa um pool de threads de E / S, provavelmente para evitar a complicação do uso de APIs de E / S assíncronas (e talvez o POSIX open()não possa ser bloqueado?). Dessa forma, amortiza qualquer ocorrência de desempenho em que o modelo tradicional fork()/ pthread_create()sob solicitação tenha que criar e destruir threads. E, como mencionado no postscript a), isso também amortiza a questão do espaço de pilha. Provavelmente, você pode atender a milhares de solicitações com, digamos, 16 threads de IO.
binki
"Os tamanhos de pilha padrão para encadeamentos modernos tendem a ser bastante grandes, mas a memória alocada por um sistema de eventos baseado em fechamento seria apenas o necessário". Tenho a impressão de que eles devem ter a mesma ordem. Os fechamentos não são baratos, o tempo de execução terá que manter toda a árvore de chamadas do aplicativo de thread único na memória ("emulando pilhas", por assim dizer) e poderá limpar quando uma folha de árvore for liberada como o fechamento associado fica "resolvido". Isso incluirá muitas referências a coisas na pilha que não podem ser coletadas com lixo e afetarão o desempenho no momento da limpeza.
David Tonhofer 28/10
14

O que eu não entendo é o ponto em que o Node.js ainda está usando threads.

Ryan usa threads para as partes que estão bloqueando (a maioria do node.js usa E / S não bloqueadora) porque algumas partes são insanas e difíceis de gravar sem bloqueio. Mas acredito que Ryan deseja ter tudo sem bloqueio. No slide 63 (design interno), você vê Ryan usando a libev (biblioteca que abstrai a notificação de evento assíncrona) para o loop de eventos sem bloqueio . Por causa do loop de eventos node.js precisa de threads menores, o que reduz a alternância de contexto, o consumo de memória etc.

Alfred
fonte
11

Threads são usados ​​apenas para lidar com funções que não possuem facilidade assíncrona, como stat().

A stat()função está sempre bloqueando, portanto, o node.js precisa usar um encadeamento para executar a chamada real sem bloquear o encadeamento principal (loop de eventos). Potencialmente, nenhum encadeamento do conjunto de encadeamentos será usado se você não precisar chamar esse tipo de função.

gawi
fonte
7

Não sei nada sobre o funcionamento interno do node.js, mas posso ver como o uso de um loop de eventos pode superar o tratamento de E / S encadeado. Imagine uma solicitação de disco, me dê staticFile.x, faça 100 solicitações para esse arquivo. Cada solicitação normalmente ocupa um thread que recupera esse arquivo, ou seja, 100 threads.

Agora imagine a primeira solicitação criando um encadeamento que se torna um objeto publicador; todas as outras 99 solicitações primeiro examinam se existe um objeto publicador para staticFile.x; se houver, escute-o enquanto ele está fazendo seu trabalho; caso contrário, inicie um novo encadeamento e, portanto, um novo objeto de editor.

Depois que o encadeamento único é concluído, ele passa staticFile.x para todos os 100 ouvintes e se destrói, portanto, a próxima solicitação cria um novo encadeamento e objeto publicador.

Portanto, são 100 threads versus 1 thread no exemplo acima, mas também 1 pesquisa de disco em vez de 100 pesquisas de disco, o ganho pode ser bastante fenomenal. Ryan é um cara esperto!

Outra maneira de ver é um de seus exemplos no início do filme. Ao invés de:

pseudo code:
result = query('select * from ...');

Novamente, 100 consultas separadas em um banco de dados versus ...:

pseudo code:
query('select * from ...', function(result){
    // do stuff with result
});

Se uma consulta já estivesse em andamento, outras consultas iguais simplesmente iriam para o movimento, para que você possa ter 100 consultas em uma única ida e volta ao banco de dados.

BGerrissen
fonte
3
A questão do banco de dados é mais uma questão de não esperar a resposta enquanto mantém outras solicitações (que podem ou não usar o banco de dados), mas pedir algo e deixá-lo ligar quando voltar. Eu não acho que isso os une, pois seria muito difícil acompanhar a resposta. Também não acho que exista alguma interface MySQL que permita que você mantenha várias respostas não armazenadas em buffer em uma conexão (??)
Tor Valamo
É apenas um exemplo abstrato para explicar como laço de eventos pode oferecer mais eficiência, NodeJS não faz nada com DB sem módulos extras;)
BGerrissen
1
Sim, meu comentário foi mais direcionado às 100 consultas em uma única ida e volta ao banco de dados. : p
Tor Valamo
2
Oi BGerrissen: bom post. Portanto, quando uma consulta estiver em execução, outras consultas semelhantes serão "ouvidas" como o exemplo staticFile.X acima? por exemplo, 100 usuários recuperando a mesma consulta, apenas uma consulta será executada e as outras 99 estarão ouvindo a primeira? obrigado !
CHAPa
1
Você está fazendo parecer que o nodejs memoriza automaticamente chamadas de função ou algo assim. Agora, como você não precisa se preocupar com a sincronização de memória compartilhada no modelo de loop de eventos do JavaScript, é mais fácil armazenar em cache as coisas na memória com segurança. Mas isso não significa que nodejs magicamente faça isso para você ou que esse é o tipo de aprimoramento de desempenho que está sendo perguntado.
binki