O Loop de Eventos do Node.js
O que é o Loop de Eventos?
O loop de eventos é o que permite ao Node.js realizar operações de E/S não bloqueantes — apesar do fato de que uma única thread JavaScript é usada por padrão — descarregando operações para o kernel do sistema sempre que possível.
Como a maioria dos kernels modernos são multi-threaded, eles podem lidar com múltiplas operações sendo executadas em segundo plano. Quando uma dessas operações é concluída, o kernel avisa o Node.js para que o callback apropriado possa ser adicionado à fila de sondagem para eventualmente ser executado. Explicaremos isso com mais detalhes mais adiante neste tópico.
Loop de Eventos Explicado
Quando o Node.js inicia, ele inicializa o loop de eventos, processa o script de entrada fornecido (ou entra no REPL, que não é coberto neste documento), que pode fazer chamadas assíncronas de API, agendar timers ou chamar process.nextTick(), e então começa a processar o loop de eventos.
O diagrama a seguir mostra uma visão geral simplificada da ordem de operações do loop de eventos.
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
TIP
Cada caixa será referida como uma "fase" do loop de eventos.
Cada fase tem uma fila FIFO de callbacks para executar. Embora cada fase seja especial à sua maneira, geralmente, quando o loop de eventos entra em uma determinada fase, ele realizará quaisquer operações específicas dessa fase, e então executará os callbacks na fila dessa fase até que a fila tenha sido esgotada ou o número máximo de callbacks tenha sido executado. Quando a fila foi esgotada ou o limite de callback for atingido, o loop de eventos passará para a próxima fase, e assim por diante.
Como qualquer uma dessas operações pode agendar mais operações e novos eventos processados na fase poll são colocados em fila pelo kernel, eventos de poll podem ser colocados em fila enquanto eventos de poll estão sendo processados. Como resultado, callbacks de longa execução podem permitir que a fase de poll seja executada por muito mais tempo do que o limite de um timer. Consulte as seções de timers e poll para obter mais detalhes.
TIP
Existe uma pequena discrepância entre a implementação do Windows e a implementação do Unix/Linux, mas isso não é importante para esta demonstração. As partes mais importantes estão aqui. Na verdade, existem sete ou oito etapas, mas aquelas com que nos preocupamos — aquelas que o Node.js realmente usa — são as acima.
Visão Geral das Fases
- timers: esta fase executa callbacks programados por
setTimeout()
esetInterval()
. - pending callbacks: executa callbacks de I/O adiados para a próxima iteração do loop.
- idle, prepare: usado apenas internamente.
- poll: recupera novos eventos de I/O; executa callbacks relacionados a I/O (quase todos, com exceção dos callbacks de fechamento, aqueles programados por timers e
setImmediate()
); o node bloqueará aqui quando apropriado. - check: callbacks
setImmediate()
são invocados aqui. - close callbacks: alguns callbacks de fechamento, por exemplo,
socket.on('close', ...)
.
Entre cada execução do loop de eventos, o Node.js verifica se está esperando por alguma I/O assíncrona ou timers e desliga-se corretamente se não houver nenhum.
Fases em Detalhe
timers
Um timer especifica o limite após o qual um callback fornecido pode ser executado em vez do tempo exato que uma pessoa deseja que seja executado. Os callbacks de timers serão executados o mais cedo possível após o tempo especificado; no entanto, a programação do sistema operacional ou a execução de outros callbacks podem atrasá-los.
TIP
Tecnicamente, a fase poll controla quando os timers são executados.
Por exemplo, digamos que você programe um timeout para executar após um limite de 100 ms, então seu script começa a ler um arquivo assincronamente, o que leva 95 ms:
const fs = require('node:fs');
function someAsyncOperation(callback) {
// Assume que isso leva 95ms para completar
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms se passaram desde que fui programado`);
}, 100);
// faça alguma operação assíncrona que leva 95 ms para completar
someAsyncOperation(() => {
const startCallback = Date.now();
// faça algo que levará 10ms...
while (Date.now() - startCallback < 10) {
// faça nada
}
});
Quando o loop de eventos entra na fase poll, ele tem uma fila vazia (fs.readFile()
não foi concluído), então ele esperará o número de ms restantes até que o limite do timer mais próximo seja atingido. Enquanto espera, 95 ms se passam, fs.readFile()
termina de ler o arquivo e seu callback, que leva 10 ms para ser concluído, é adicionado à fila de poll e executado. Quando o callback termina, não há mais callbacks na fila, então o loop de eventos verá que o limite do timer mais próximo foi atingido e, em seguida, retornará para a fase de timers para executar o callback do timer. Neste exemplo, você verá que o atraso total entre o timer ser programado e seu callback ser executado será de 105ms.
TIP
Para evitar que a fase poll deixe o loop de eventos faminto, libuv (a biblioteca C que implementa o loop de eventos do Node.js e todos os comportamentos assíncronos da plataforma) também tem um máximo rígido (dependente do sistema) antes de parar de pesquisar mais eventos.
callbacks pendentes
Esta fase executa callbacks para algumas operações do sistema, como tipos de erros TCP. Por exemplo, se um socket TCP receber ECONNREFUSED
ao tentar se conectar, alguns sistemas *nix querem esperar para relatar o erro. Isso será colocado em fila para execução na fase de callbacks pendentes.
poll
A fase poll tem duas funções principais:
- Calcular quanto tempo deve bloquear e pesquisar por E/S, e então
- Processar eventos na fila poll.
Quando o loop de eventos entra na fase poll e não há temporizadores agendados, uma de duas coisas acontecerá:
Se a fila poll não estiver vazia, o loop de eventos iterará por sua fila de callbacks executando-os sincronicamente até que a fila tenha sido esgotada ou o limite máximo dependente do sistema seja atingido.
Se a fila poll estiver vazia, uma de duas coisas acontecerá:
Se scripts foram agendados por
setImmediate()
, o loop de eventos terminará a fase poll e continuará para a fase de verificação para executar esses scripts agendados.Se scripts não foram agendados por
setImmediate()
, o loop de eventos esperará que callbacks sejam adicionados à fila e, em seguida, os executará imediatamente.
Assim que a fila poll estiver vazia, o loop de eventos verificará os temporizadores cujos limiares de tempo foram atingidos. Se um ou mais temporizadores estiverem prontos, o loop de eventos retornará para a fase timers para executar os callbacks desses temporizadores.
check
Esta fase permite que uma pessoa execute callbacks imediatamente após a conclusão da fase poll. Se a fase poll ficar inativa e scripts tiverem sido colocados em fila com setImmediate()
, o loop de eventos poderá continuar para a fase de verificação em vez de esperar.
setImmediate()
é na verdade um temporizador especial que é executado em uma fase separada do loop de eventos. Ele usa uma API libuv que agenda callbacks para execução após a conclusão da fase poll.
Geralmente, à medida que o código é executado, o loop de eventos eventualmente atingirá a fase poll, onde aguardará uma conexão, solicitação etc. No entanto, se um callback tiver sido agendado com setImmediate()
e a fase poll ficar inativa, ela terminará e continuará para a fase check em vez de aguardar eventos poll.
Callbacks de fechamento
Se uma socket ou manipulador for fechado abruptamente (por exemplo, socket.destroy()
), o evento 'close'
será emitido nesta fase. Caso contrário, será emitido via process.nextTick()
.
setImmediate()
vs setTimeout()
setImmediate()
e setTimeout()
são semelhantes, mas se comportam de maneiras diferentes dependendo de quando são chamados.
setImmediate()
destina-se a executar um script assim que a fase de poll atual terminar.setTimeout()
agenda a execução de um script após um limite mínimo em ms ter decorrido.
A ordem em que os timers são executados variará dependendo do contexto em que são chamados. Se ambos forem chamados do módulo principal, o tempo será limitado pelo desempenho do processo (que pode ser afetado por outros aplicativos em execução na máquina).
Por exemplo, se executarmos o script a seguir que não está dentro de um ciclo de E/S (ou seja, o módulo principal), a ordem em que os dois timers são executados é não determinística, pois é limitada pelo desempenho do processo:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
No entanto, se você mover as duas chamadas para dentro de um ciclo de E/S, o callback imediato é sempre executado primeiro:
// timeout_vs_immediate.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
A principal vantagem de usar setImmediate()
sobre setTimeout()
é que setImmediate()
sempre será executado antes de quaisquer timers se programado dentro de um ciclo de E/S, independentemente de quantos timers estiverem presentes.
process.nextTick()
Entendendo process.nextTick()
Você pode ter notado que process.nextTick()
não foi exibido no diagrama, mesmo sendo parte da API assíncrona. Isso ocorre porque process.nextTick()
não faz tecnicamente parte do loop de eventos. Em vez disso, a nextTickQueue
será processada após a conclusão da operação atual, independentemente da fase atual do loop de eventos. Aqui, uma operação é definida como uma transição do manipulador subjacente C/C++ e o tratamento do JavaScript que precisa ser executado.
Observando novamente nosso diagrama, sempre que você chamar process.nextTick()
em uma determinada fase, todas as callbacks passadas para process.nextTick()
serão resolvidas antes que o loop de eventos continue. Isso pode criar algumas situações ruins porque permite "faminto" sua E/S fazendo chamadas recursivas process.nextTick()
, o que impede o loop de eventos de atingir a fase de pesquisa.
Por que isso seria permitido?
Por que algo assim seria incluído no Node.js? Parte disso é uma filosofia de design em que uma API deve ser sempre assíncrona, mesmo quando não precisa ser. Pegue este trecho de código como exemplo:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(
callback,
new TypeError('argument should be string')
);
}
O trecho faz uma verificação de argumento e, se não estiver correto, passará o erro para a callback. A API foi atualizada recentemente para permitir a passagem de argumentos para process.nextTick()
, permitindo que quaisquer argumentos passados após a callback sejam propagados como os argumentos para a callback, para que você não precise aninhar funções.
O que estamos fazendo é passar um erro de volta para o usuário, mas somente depois que permitimos que o restante do código do usuário seja executado. Usando process.nextTick()
, garantimos que apiCall()
sempre execute sua callback após o restante do código do usuário e antes que o loop de eventos possa prosseguir. Para alcançar isso, a pilha de chamadas JS tem permissão para desvendar e, em seguida, executar imediatamente a callback fornecida, o que permite que uma pessoa faça chamadas recursivas para process.nextTick()
sem atingir um RangeError: Maximum call stack size exceeded from v8
.
Essa filosofia pode levar a algumas situações potencialmente problemáticas. Pegue este trecho de código como exemplo:
let bar;
// isso tem uma assinatura assíncrona, mas chama a callback sincronicamente
function someAsyncApiCall(callback) {
callback();
}
// a callback é chamada antes de `someAsyncApiCall` ser concluída.
someAsyncApiCall(() => {
// como someAsyncApiCall não foi concluído, bar não recebeu nenhum valor
console.log('bar', bar); // undefined
});
bar = 1;
O usuário define someAsyncApiCall()
para ter uma assinatura assíncrona, mas na verdade opera sincronicamente. Quando é chamada, a callback fornecida para someAsyncApiCall()
é chamada na mesma fase do loop de eventos porque someAsyncApiCall()
não faz nada assincronamente. Como resultado, a callback tenta referenciar bar mesmo que ainda não tenha essa variável em escopo, porque o script não conseguiu ser executado até o fim.
Ao colocar a callback em um process.nextTick()
, o script ainda tem a capacidade de ser executado até o fim, permitindo que todas as variáveis, funções etc. sejam inicializadas antes da chamada da callback. Também tem a vantagem de não permitir que o loop de eventos continue. Pode ser útil para o usuário ser alertado sobre um erro antes que o loop de eventos possa continuar. Aqui está o exemplo anterior usando process.nextTick()
:
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
Aqui está outro exemplo do mundo real:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
Quando apenas uma porta é passada, a porta é vinculada imediatamente. Portanto, a callback 'listening'
pode ser chamada imediatamente. O problema é que a callback .on('listening')
ainda não terá sido definida até então.
Para contornar isso, o evento 'listening'
é colocado em fila em um nextTick()
para permitir que o script seja executado até o fim. Isso permite que o usuário defina quaisquer manipuladores de eventos que desejar.
process.nextTick()
vs setImmediate()
Temos duas chamadas que são semelhantes para os usuários, mas seus nomes são confusos.
process.nextTick()
dispara imediatamente na mesma fasesetImmediate()
dispara na iteração seguinte ou'tick'
do loop de eventos
Em essência, os nomes deveriam ser trocados. process.nextTick()
dispara mais imediatamente do que setImmediate()
, mas este é um artefato do passado que provavelmente não mudará. Fazer esta troca quebraria uma grande porcentagem dos pacotes no npm. A cada dia, mais novos módulos estão sendo adicionados, o que significa que a cada dia que esperamos, mais potenciais quebras ocorrem. Embora sejam confusos, os próprios nomes não mudarão.
TIP
Recomendamos que os desenvolvedores usem setImmediate()
em todos os casos, pois é mais fácil de entender.
Por que usar process.nextTick()
?
Existem duas razões principais:
Permitir que os usuários manipulem erros, limpem quaisquer recursos desnecessários ou talvez tentem a solicitação novamente antes que o loop de eventos continue.
Às vezes, é necessário permitir que um callback seja executado após a pilha de chamadas ter sido desfeita, mas antes que o loop de eventos continue.
Um exemplo é corresponder às expectativas do usuário. Exemplo simples:
const server = net.createServer();
server.on('connection', conn => {});
server.listen(8080);
server.on('listening', () => {});
Digamos que listen()
seja executado no início do loop de eventos, mas o callback de escuta seja colocado em um setImmediate()
. A menos que um nome de host seja passado, a vinculação à porta acontecerá imediatamente. Para que o loop de eventos prossiga, ele deve atingir a fase de pesquisa, o que significa que há uma chance diferente de zero de que uma conexão possa ter sido recebida, permitindo que o evento de conexão seja disparado antes do evento de escuta.
Outro exemplo é estender um EventEmitter
e emitir um evento de dentro do construtor:
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
this.emit('event');
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
Você não pode emitir um evento do construtor imediatamente porque o script não terá processado até o ponto em que o usuário atribui um callback a esse evento. Portanto, dentro do próprio construtor, você pode usar process.nextTick()
para definir um callback para emitir o evento após o construtor ter terminado, o que fornece os resultados esperados:
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event');
});
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});