O Node foi originalmente criado para construir servidores web de alto desempenho. Como um tempo de execução do lado do servidor para JavaScript, ele possui recursos como E/S assíncrona, orientada a eventos e threading único. O modelo de programação assíncrona baseado no loop de eventos permite que o Node lide com alta simultaneidade e melhora muito o desempenho do servidor. Ao mesmo tempo, por manter as características de thread único do JavaScript, o Node não precisa lidar com problemas como sincronização de estado e. impasse em multithreads Não há sobrecarga de desempenho causada pela alternância de contexto de thread. Com base nessas características, o Node tem as vantagens inerentes de alto desempenho e alta simultaneidade, e várias plataformas de aplicativos de rede escaláveis e de alta velocidade podem ser construídas com base nele.
Este artigo se aprofundará no mecanismo subjacente de implementação e execução do loop assíncrono e de eventos do Node. Espero que seja útil para você.
Por que o Node usa assíncrono como modelo de programação principal?
Como mencionado anteriormente, o Node foi originalmente criado para construir servidores web de alto desempenho. Supondo que haja vários conjuntos de tarefas não relacionadas a serem concluídas no cenário de negócios, existem duas soluções modernas convencionais:
execução serial de thread único.
Concluído em paralelo com vários threads.
A execução serial de thread único é um modelo de programação síncrona, embora esteja mais alinhado com a maneira de pensar do programador em sequência e torne mais fácil escrever código mais conveniente, porque executa E/S de forma síncrona, ele só pode processar E/S. ao mesmo tempo. Uma única solicitação fará com que o servidor responda lentamente e não pode ser aplicada em cenários de aplicativos de alta simultaneidade. Além disso, como bloqueia a E/S, a CPU sempre aguardará a conclusão da E/S e não poderá fazê-lo. outras coisas, que limitarão o poder de processamento da CPU Para serem totalmente utilizadas, acabarão por levar à baixa eficiência,
e o modelo de programação multithread também causará dores de cabeça aos desenvolvedores devido a problemas como sincronização de estado e impasse na programação. Embora o multi-threading possa efetivamente melhorar a utilização da CPU em CPUs com vários núcleos.
Embora o modelo de programação de execução serial de thread único e execução paralela multithread tenha suas próprias vantagens, ele também apresenta deficiências em termos de desempenho e dificuldade de desenvolvimento.
Além disso, partindo da velocidade de resposta às solicitações do cliente, se o cliente obtiver dois recursos ao mesmo tempo, a velocidade de resposta do método síncrono será a soma das velocidades de resposta dos dois recursos, e a velocidade de resposta do o método assíncrono será o meio dos dois. O maior deles, a vantagem de desempenho é muito óbvia em comparação com a sincronização. À medida que a complexidade da aplicação aumenta, este cenário evoluirá para responder a n solicitações ao mesmo tempo, e as vantagens do assíncrono em comparação ao sincronizado serão destacadas.
Resumindo, o Node dá sua resposta: use um único thread para evitar deadlocks multi-thread, sincronização de estado e outros problemas, use E/S assíncrona para manter um único thread longe do bloqueio para melhor usar a CPU; É por isso que o Node usa assíncrono como seu modelo de programação principal.
Além disso, para compensar a deficiência de um único thread que não pode utilizar CPUs multi-core, o Node também fornece um subprocesso semelhante aos Web Workers no navegador, que pode utilizar eficientemente a CPU por meio de processos de trabalho.
Depois de falar sobre por que devemos usar o assíncrono, como implementar o assíncrono?
Existem dois tipos de operações assíncronas que normalmente chamamos: uma são operações relacionadas a E/S, como E/S de arquivo e E/S de rede, a outra são operações não relacionadas a E/S, como setTimeOut
e setInterval
; Obviamente o assíncrono que estamos discutindo refere-se a operações relacionadas a E/S, ou seja, E/S assíncrona.
A E/S assíncrona é proposta na esperança de que as chamadas de E/S não bloqueiem a execução de programas subsequentes, e o tempo original de espera pela conclusão da E/S será alocado para outros negócios necessários para execução. Para atingir esse objetivo, você precisa usar E/S sem bloqueio.
Bloquear E/S significa que após a CPU iniciar uma chamada de E/S, ela será bloqueada até que a E/S seja concluída. Conhecendo a E/S de bloqueio, a E/S sem bloqueio é fácil de entender. A CPU retornará imediatamente após iniciar a chamada de E/S, em vez de bloquear e esperar. A CPU pode lidar com outras transações antes que a E/S seja concluída. Obviamente, em comparação com a E/S com bloqueio, a E/S sem bloqueio apresenta mais melhorias de desempenho.
Portanto, como a E/S sem bloqueio é usada e a CPU pode retornar imediatamente após iniciar a chamada de E/S, como ela sabe que a E/S foi concluída? A resposta é a votação.
Para obter o status das chamadas de E/S a tempo, a CPU chamará continuamente as operações de E/S repetidamente para confirmar se a E/S foi concluída. Esta tecnologia de chamadas repetidas para determinar se a operação foi concluída é chamada de polling. .
Obviamente, a pesquisa fará com que a CPU execute julgamentos de status repetidamente, o que é um desperdício de recursos da CPU. Além disso, o intervalo de polling é difícil de controlar. Se o intervalo for muito longo, a conclusão da operação de E/S não receberá uma resposta oportuna, o que reduzirá indiretamente a velocidade de resposta do aplicativo; A CPU será inevitavelmente gasta em pesquisas. Demora mais e reduz a utilização dos recursos da CPU.
Portanto, embora a votação atenda ao requisito de que a E/S sem bloqueio não bloqueie a execução de programas subsequentes, para a aplicação ela ainda pode ser considerada apenas como um tipo de sincronização, pois a aplicação ainda precisa aguardar a E/S Ó para voltar completamente. Ainda passei muito tempo esperando.
A E/S assíncrona perfeita que esperamos deve ser que o aplicativo inicie uma chamada sem bloqueio. Não há necessidade de consultar continuamente o status da chamada de E/S por meio de pesquisa. A E/S está concluída, basta passar os dados para a aplicação por meio de um semáforo ou retorno de chamada.
Como implementar essa E/S assíncrona? A resposta é pool de threads.
Embora este artigo sempre tenha mencionado que o Node é executado em um único thread, o único thread aqui significa que o código JavaScript é executado em um único thread. Para partes como operações de E/S que não têm nada a ver com a lógica de negócios principal, executar em outra implementação na forma de threads não afetará ou bloqueará a execução do thread principal. Pelo contrário, pode melhorar a eficiência de execução do thread principal e realizar E/S assíncrona.
Por meio do pool de threads, deixe o thread principal apenas fazer chamadas de E/S, deixe outros threads realizarem E/S de bloqueio ou E/S sem bloqueio, além de tecnologia de pesquisa para concluir a aquisição de dados e, em seguida, usar a comunicação entre threads para concluir o I /O Os dados obtidos são passados, o que implementa facilmente E/S assíncrona:
O thread principal executa chamadas de E/S, enquanto o pool de threads executa operações de E/S, completa a aquisição de dados e, em seguida, passa os dados para o thread principal por meio da comunicação entre threads para completar uma chamada de E/S e o thread principal reutilizações A função de retorno de chamada expõe os dados ao usuário, que então os usa para concluir operações no nível da lógica de negócios. Este é um processo de E/S assíncrono completo no Node. Para os usuários, não há necessidade de se preocupar com os complicados detalhes de implementação da camada subjacente. Eles só precisam chamar a API assíncrona encapsulada pelo Node e passar a função de retorno de chamada que trata da lógica de negócios, conforme mostrado abaixo:
const fs = require. ("fs") ; fs.readFile('exemplo.js', (dados) => { // Processar lógica de negócios});
O mecanismo de implementação assíncrono subjacente do Nodejs é diferente em diferentes plataformas: no Windows, o IOCP é usado principalmente para enviar chamadas de E/S para o kernel do sistema e obter operações de E/S completadas do kernel. com um loop de eventos para completar o processo de E/S assíncrono; este processo é implementado através de epoll no Linux; O pool de threads é fornecido diretamente pelo kernel (IOCP) no Windows, enquanto a série *nix
é implementada pela própria libuv.
Devido à diferença entre a plataforma Windows e a plataforma *nix
, o Node fornece libuv como uma camada de encapsulamento abstrata, de modo que todos os julgamentos de compatibilidade da plataforma sejam concluídos por esta camada, garantindo que o Node da camada superior e o pool de threads personalizados da camada inferior e IOCP são independentes um do outro. O Node determinará as condições da plataforma durante a compilação e compilará seletivamente os arquivos de origem no diretório unix ou no diretório win no programa de destino:
O texto acima é a implementação assíncrona do Node.
(O tamanho do conjunto de encadeamentos pode ser definido por meio da variável de ambiente UV_THREADPOOL_SIZE
. O valor padrão é 4. O usuário pode ajustar o tamanho desse valor com base na situação real.)
Então a questão é, depois de obter os dados transmitidos pelo pool de threads, como funciona o thread principal? Quando a função de retorno de chamada é chamada? A resposta é o loop de eventos.
Comousa funções de retorno de chamada para processar dados de E/S, inevitavelmente envolve a questão de quando e como chamar a função de retorno de chamada. No desenvolvimento real, cenários de chamada de E/S assíncronos múltiplos e de vários tipos estão frequentemente envolvidos. Além disso, como organizar razoavelmente as chamadas desses retornos de chamada de E/S assíncronos e garantir o progresso ordenado, além de. E/S assíncrona Além de /O, também existem chamadas assíncronas não E/S, como temporizadores. Essas APIs são altamente em tempo real e têm prioridades correspondentemente mais altas. Como agendar retornos de chamada com prioridades diferentes?
Portanto, deve haver um mecanismo de agendamento para coordenar tarefas assíncronas de diferentes prioridades e tipos para garantir que essas tarefas sejam executadas de maneira ordenada no thread principal. Assim como os navegadores, o Node escolheu o loop de eventos para fazer esse trabalho pesado.
O Node divide as tarefas em sete categorias de acordo com seu tipo e prioridade: Timers, Pendente, Ocioso, Preparar, Pesquisar, Verificar e Fechar. Para cada tipo de tarefa, há uma fila de tarefas primeiro a entrar, primeiro a sair para armazenar tarefas e seus retornos de chamada (os temporizadores são armazenados em uma pequena pilha superior). Com base nesses sete tipos, o Node divide a execução do loop de eventos nos sete estágios a seguir:
A prioridade de execução deste estágio de
Neste estágio, o loop de eventos verificará a estrutura de dados (heap mínimo) que armazena o cronômetro, percorrerá os cronômetros nela, comparará o tempo atual e o tempo de expiração um por um e determinará se o cronômetro expirou. , o cronômetro será A função de retorno de chamada será retirada e executada.
A faseexecutará retornos de chamada quando ocorrerem exceções de rede, IO e outras exceções. Alguns erros relatados por *nix
serão tratados nesta fase. Além disso, alguns callbacks de I/O que deveriam ser executados na fase de poll do ciclo anterior serão adiados para esta fase.
são usadas apenas dentro do loop de eventos.
recupera novos eventos de E/S; executa retornos de chamada relacionados a E/S (quase todos os retornos de chamada, exceto retornos de chamada de desligamento, retornos de chamada programados por temporizador e setImmediate()
);
Poll, ou seja, o estágio de polling é o estágio mais importante do loop de eventos. Os retornos de chamada para E/S de rede e E/S de arquivo são processados principalmente neste estágio. Este estágio tem duas funções principais:
calcular por quanto tempo este estágio deve bloquear e pesquisar E/S.
Lidar com retornos de chamada na fila de E/S.
Quando o loop de eventos entra na fase de poll e nenhum cronômetro está definido:
Se a fila de poll não estiver vazia, o loop de eventos percorrerá a fila, executando-os de forma síncrona até que a fila esteja vazia ou o número máximo que pode ser executado seja alcançado.
Se a fila de polling estiver vazia, uma de duas outras coisas acontecerá:
Se houver setImmediate()
que precise ser executado, a fase de poll termina imediatamente e a fase de verificação é inserida para executar o retorno de chamada.
Se não houver retornos de chamada setImmediate()
para executar, o loop de eventos permanecerá nesta fase aguardando que os retornos de chamada sejam adicionados à fila e então os executará imediatamente. O loop de eventos aguardará até que o tempo limite expire. A razão pela qual optei por parar aqui é porque o Node lida principalmente com IO, para que possa responder a IO de maneira mais oportuna.
Quando a fila de sondagem estiver vazia, o loop de eventos verificará se há temporizadores que atingiram seu limite de tempo. Se um ou mais temporizadores atingirem o limite de tempo, o loop de eventos retornará à fase de temporizadores para executar os retornos de chamada para esses temporizadores.
fase executará os retornos de chamada de setImmediate()
em sequência.
Esta fase executará alguns retornos de chamada para fechar recursos, como socket.on('close', ...)
. A execução atrasada desta fase terá pouco impacto e tem a prioridade mais baixa.
Quando o processo do Node for iniciado, ele inicializará o loop de eventos, executará o código de entrada do usuário, fará chamadas de API assíncronas correspondentes, agendamento de timer, etc., e então começará a entrar no loop de eventos:
┌───────── ── ────────────────┐ ┌─>│ temporizadores │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ retornos de chamada pendentes │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ ocioso, prepare │ │ └─────────────┬────────────┘ ┌────────── ──────┐ │ ┌─────────────┴────────────┐ │ entrada: │ │ │ enquete │<─────┤ conexões, │ │ └─────────────┬─────────────┘ │ dados, etc. │ ┌─────────────┴────────────┐ └────────── ──────┘ │ │ verificar │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ fechar retornos de chamada │ └─────────────────────────────┘Cada
iteração do loop de eventos (geralmente chamado de tick) será conforme fornecido acima A prioridade a ordem entra nos sete estágios de execução. Cada estágio executará um certo número de retornos de chamada na fila. A razão pela qual apenas um determinado número é executado, mas nem todos são executados, é para evitar que o tempo de execução do estágio atual seja muito longo e evitar a falha do próximo estágio. Não executado.
OK, o texto acima é o fluxo de execução básico do loop de eventos. Agora vamos analisar outra questão.
Para o seguinte cenário:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
Quando o serviço é vinculado com sucesso à porta 8000, ou seja, quando listen()
é chamado com sucesso, o retorno de chamada do evento listening
ainda não foi vinculado, portanto depois que a porta for vinculada com sucesso, o retorno de chamada do evento listening
que passamos não será executado.
Pensando em outra questão, podemos ter algumas necessidades durante o desenvolvimento, como tratamento de erros, limpeza de recursos desnecessários e outras tarefas de baixa prioridade. Se essas lógicas forem executadas de forma síncrona, isso afetará a eficiência da execução da tarefa atual; se setImmediate()
for passado de forma assíncrona, como na forma de retornos de chamada, seu tempo de execução não poderá ser garantido e o desempenho em tempo real não será alto. Então, como lidar com essas lógicas?
Com base nessas questões, o Node se baseou no navegador e implementou um conjunto de mecanismos de microtarefas. No Node, além de chamar new Promise().then()
a função de retorno de chamada transmitida será encapsulada em uma microtarefa. O retorno de chamada de process.nextTick()
também será encapsulado em uma microtarefa e a prioridade de execução do. este último será maior que o primeiro.
Com microtarefas, qual é o processo de execução do loop de eventos? Em outras palavras, quando as microtarefas são executadas?
No nó 11 e versões posteriores, assim que uma tarefa em um estágio é executada, a fila de microtarefas é executada imediatamente e a fila é limpa.
A execução da microtarefa começa após um estágio ter sido executado antes do node11.
Portanto, com microtarefas, cada ciclo do loop de eventos executará primeiro uma tarefa no estágio de temporizadores e, em seguida, limpará as filas de microtarefas de process.nextTick()
e new Promise().then()
em ordem e, em seguida, continuará a executar a próxima tarefa no estágio de temporizadores ou o próximo estágio, ou seja, uma tarefa no estágio pendente, e assim por diante nesta ordem.
Usando process.nextTick()
, o Node pode resolver o problema de ligação de porta acima: dentro do método listen()
, a emissão do evento listening
será encapsulada em um retorno de chamada e passada para process.nextTick()
, conforme mostrado no pseudo seguinte código:
função ouvir() { // Realiza operações de escuta na porta... // Encapsula a emissão do evento `listening` em um retorno de chamada e passa-o para `process.nextTick()` em process.nextTick(() => { emitir('ouvindo'); }); };
Após a execução do código atual, a microtarefa começará a ser executada, emitindo assim listening
e acionando a chamada do retorno de chamada do evento.
Devido à imprevisibilidade e complexidade do próprio assíncrono, no processo de utilização da API assíncrona fornecida pelo Node, embora tenhamos dominado o princípio de execução do loop de eventos, ainda pode haver alguns fenômenos que não são intuitivos ou esperados. .
Por exemplo, a ordem de execução dos temporizadores ( setTimeout
, setImmediate
) será diferente dependendo do contexto em que são chamados. Se ambos forem chamados a partir do contexto de nível superior, o tempo de execução dependerá do desempenho do processo ou da máquina.
Vejamos o seguinte exemplo:
setTimeout(() => { console.log('tempo limite'); }, 0); setImmediate(() => { console.log('imediato'); });
Qual é o resultado da execução do código acima? De acordo com nossa descrição do loop de eventos agora, você pode ter esta resposta: Como a fase dos temporizadores será executada antes da fase de verificação, o retorno de chamada de setTimeout()
será executado primeiro e, em seguida, o retorno de chamada de setImmediate()
será executado.
Na verdade, o resultado de saída deste código é incerto. O tempo limite pode ser gerado primeiro ou imediato pode ser gerado primeiro. Isso ocorre porque ambos os temporizadores são chamados no contexto global. Quando o loop de eventos começa a ser executado e é executado no estágio dos temporizadores, o tempo atual pode ser maior que 1 ms ou menor que 1 ms, dependendo do desempenho de execução da máquina. , na verdade é incerto setTimeout()
será executado no estágio de primeiros temporizadores, portanto, resultados de saída diferentes aparecerão.
(Quando o valor do delay
(o segundo parâmetro de setTimeout
) for maior que 2147483647
ou menor que 1
, delay
será definido como 1
)
Vejamos o seguinte código:
const fs = require('fs'); fs.readFile(__nomedoarquivo, () => { setTimeout(() => { console.log('tempo limite'); }, 0); setImmediate(() => { console.log('imediato'); }); });
Pode-se ver que neste código, ambos os temporizadores são encapsulados em funções de retorno de chamada e passados para readFile
. É óbvio que quando o retorno de chamada é chamado, o tempo atual deve ser maior que 1 ms, então o retorno de chamada de setTimeout
será. ser maior que o retorno de chamada de setImmediate
O retorno de chamada é chamado primeiro, então o resultado impresso é: timeout immediate
.
Os itens acima são relacionados a temporizadores aos quais você precisa prestar atenção ao usar o Node. Além disso, você também precisa prestar atenção à ordem de execução de process.nextTick()
, new Promise().then()
e setImmediate()
. Como esta parte é relativamente simples, ela foi mencionada antes e não será repetida. .
: O artigo começa com uma explicação mais detalhada dos princípios de implementação do loop de eventos do Node a partir das duas perspectivas de por que o assíncrono é necessário e como implementar o assíncrono, e menciona alguns assuntos relacionados que precisam de atenção. você.