Última atualização:

Node.js, V8, e as 5 fases do Event Loop

Nawarian
Nawarian javascript

A linguagem JavaScript é conhecida por executar de forma assíncrona, apesar de não oferecer suporte direto a multithreading. O mecanismo que habilita este comportamento da linguagem é o Event Loop, que eu vou explicar melhor sobre neste post.

O ambiente NodeJS em si utiliza diferentes threads internamente, mas este mecanismo não é exposto via JavaScript. Para aprender sobre as diferentes threads do NodeJS, dá uma olhada neste outro artigo sobre como funciona o motor V8.

As minhas duas referências para escrever este post foram a documentação oficial do NodeJS e o livro Distributed Systems with Node.js, do Thomas Hunter e publicado pela O'Reilly.

Código bloqueante e não bloqueante? Hã?

Deixa eu explicar um pouquinho melhor o que é uma operação de E/S e qual a diferença entre esta operação ser bloqueante ou não bloqueante.

Operações de Entrada e Saída (E/S) são todas as operações que não dependem somente do Processador (CPU) e memória. Toda interação com o disco rígido, teclado, mouse, internet, bluetooth... todas essas interações são consideradas operações de E/S.

Naturalmente toda operação de E/S demoram um tempo consideravelmente maior do que operações "puras" que dependem somente da CPU.

Vamos imaginar um programa que executa duas operações: Operação A e Operação B. A Operação A leva um pouquinho mais tempo para finalizar em comparação à Operação B e as duas são executadas em sequência. Veja:

Duas operações executando em sequência. A Operação A leva um pouco mais de tempo que a Operação B.
Duas operações executando em sequência. A Operação A leva um pouco mais de tempo que a Operação B.

Agora vamos considerar que a Operação A na verdade demora mais tempo para executar porque precisa escrever num arquivo chamado arquivo.txt, uma operação de E/S representada pela sub-rotina Escrever "arquivo.txt" na próxima figura:

Operação A, explicitamente bloqueada pela sub-rotina Escrever "arquivo.txt". Só depois desta execução, a Operação B irá rodar.
Operação A, explicitamente bloqueada pela sub-rotina Escrever "arquivo.txt". Só depois desta execução, a Operação B irá rodar.

Note que a Operação A depende de que a sub-rotina chegue ao seu fim para poder ser finalizada, e que de toda forma Operação B só é executada depois de tudo isso.

Este esquema pode ser considerado bloqueante porque a Operação A fica "bloqueada", aguardando o resultado da sub-rotina Escrever "arquivo.txt" e nada além disso é executado. Uma representação mais justa ficaria assim:

Explicitamente a Operação A executa um pouco, é interrompida pela sub-rotina Escrever "arquivo.txt" e retomada assim que a sub-rotina se encerra. Só depois a Operação B executa.
Explicitamente a Operação A executa um pouco, é interrompida pela sub-rotina Escrever "arquivo.txt" e retomada assim que a sub-rotina se encerra. Só depois a Operação B executa.

Acontece que, como eu mencionei antes, a sub-rotina Escrever "arquivo.txt" não depende tão somente da CPU. Na realidade a menor parte do tempo desta operação se dá em tempo de CPU, enquanto a maior parte do tempo é gasta em periféricos.

Uma outra forma de resolver este problema é permitir que a CPU execute diferentes operações enquanto aguarda a resposta do hardware de E/S. Esta forma é chamada de não bloqueante e é mais ou menos como na imagem a seguir:

Execução não bloqueante. A Operação A inicia e solicita uma operação de E/S, que roda em paralelo. Imediatamente a Operação B executa. Assim que a operação de E/S está pronta, retomamos a Operação A.
Execução não bloqueante. A Operação A inicia e solicita uma operação de E/S, que roda em paralelo. Imediatamente a Operação B executa. Assim que a operação de E/S está pronta, retomamos a Operação A.

Inicialmente a Operação A solicita uma operação de E/S e delega ao Sistema Operacional para que lide com aquela operação. Desta forma o processo está livre para executar outras operações, e imediatamente começa a executar a Operação B. Finalmente o Sistema Operacional nos informa que a operação de E/S finalizou, e podemos retomar o restante da Operação A que havia sido interrompida.

Este modelo, onde uma operação de E/S não precisa segurar a aplicação inteira, é chamado de E/S não bloqueante – ou non-blocking I/O, em inglês.

É natural pensar que uma aplicação não possui apenas 2 operações, mas sim milhares delas. Gerenciar tudo isso não é uma tarefa trivial e muito menos uma tarefa que você deva se preocupar em resolver todas as vezes.

O motor V8 e, por conseguinte, o ambiente Node.js resolvem o problema de gerenciamento destas tarefas e suas interrupções através de um mecanismo chamado Event Loop.

O que é o Event Loop?

Event Loop é um mecanismo que agrega diversas filas internas e threads para otimizar o processamento e toma proveito de técnicas como E/S não bloqueante e sinais do sistema operacional.

Event Loop no motor V8 mantém um thread principal que gerencia várias filas e determina quais operações devem executar em que momentos. Quando necessário executar uma operação mais custosa, como uma operação de E/S, a thread principal então solicita uma nova thread para cuidar daquela operação.

Eu tentei ilustrar este mecanismo no GIF abaixo:

Animação (ultra simplificada) exemplificando como as operações são enviadas à filas específicas.
Animação (ultra simplificada) exemplificando como as operações são enviadas à filas específicas.

Esta é uma representação ultra simplificada da coisa toda, mas serve para ilustrar mais ou menos o que o Event Loop faz.

event loop possui diferentes filas com callbacks (funções) e gerencia quando determinada função nestas filas deveria executar.

Mas eu não quero manter essa explicação superficial (e parcialmente errada). Vamos dar uma olhadinha na forma como o Event Loop funciona.

As 5 fases do Event Loop

Quando um processo Node.js é inicializado, o Event Loop começa. Dentro de um script várias chamadas a funções, chamadas assíncronas, timers e outros tipos de chamadas podem acontecer.

Como o nome sugere, o Event Loop é na verdade um loop infinito (while true mesmo) que a cada iteração tenta executar um pouquinho de cada fila diferente. Há 5 fases distintas que o Event Loop trata a cada iteração.

Uma visualização mega simplificada (e incorreta) e didática do Event Loop é a seguinte:

while (true) {
poll()
check()
close()
timers()
pending()
}

Cada função chamada alí representa uma fase da iteração do Event Loop. Abaixo eu descrevo com mais detalhes o que cada fase faz. Mas por enquanto, este infográfico já deve te ajudar a entender um pouco o que tá rolando:

As 5 fases do ciclo de vida do Event Loop no JavaScript.
As 5 fases do ciclo de vida do Event Loop no JavaScript: poll, check, close, timers e pending.

Cada uma das fases acima possui uma fila de callbacks. Quando a fase se inicia, Node.js tenta executar todos os callbacks daquela fila: até que a fila fique vazia ou até que o número máximo de callbacks por iteração já tenham executado.

Algo assim:

function poll() {
let maxIterations = 10 // número aleatório que eu escolhi
while (maxIterations--) {
runNextCallback()
}
// fim da fase Poll
}

Cada fase possui filas, lógicas e parâmetros diferentes. Vamos dar uma olhada em cada delas uma agora.

Importante: existe um tempo limite que o Node.js pode gastar entre cada fase e, quando este limite é atingido, avançamos diretamente para a próxima fase.

timers

Um timer define um intervalo a partir do qual um código será executado.

Por exemplo, o código setTimeout(() => console.log('Hello, world'), 1000) garante que o "Hello, world" será jogado no console, no mínimo, daqui a 1000ms. Mas não oferece nenhuma garantia de que executará exatamente daqui a 1000ms.

Em situações normais estes timers executam no tempo especificado, mas outros callbacks podem interferir no tempo que leva para iniciar um timer.

Há dois tipos de callbacks que entram na fila dos timers: timeoutsintervals.

timeout agenda uma função para ser executada uma vez no futuro. Enquanto o interval agenda uma função para ser executada infinitamente num intervalo definido.

Aqui um exemplo de timeout e interval:

setTimeout(() => {
console.log('Hello, timeout')
}, 1000)

let i = 0
setInterval(() => {
console.log(`Hello, interval: ${i}`)
i++
}, 1000)

O snippet acima produziria a seguinte saída:

1s: Hello, timeout
1s: Hello, interval: 0
2s: Hello, interval: 1
3s: Hello, interval: 2
...

pending

Esta fase executa os callbacks para algumas operações de sistema. Por exemplo quando um socket TCP recebe ECONNREFUSED ao tentar se conectar, o seu respectivo callback será invocado. É nesta fase que este callback é invocado.

poll

Nesta fase o Node.js vai solicitar (poll) ao Sistema Operacional as operações de E/S que já estão prontas para serem processadas.

O código que você escreve em JavaScript normalmente é executado nesta fase!

A partir disso, executa os callbacks destas operações prontas até a fila ficar vazia OU até atingir o limite máximo de tempo nesta fase.

Ao finalizar esta fase, o event loop invoca a fase check.

check

Esta fase executa um único tipo de callback: os callbacks agendados pela função setImmediate().

É como se fosse um timer, mas com sua própria fila e alta prioridade de execução logo após a fase poll.

close

Esta fase gerencia conexões que foram fechadas de repente, como quando um socket se fecha. O evento close é emitido nesta fase.

A fase escondida: process.nextTick()

Existe um mecanismo que eu não descrevi aqui mas que é importante explicar. É o process.nextTick().

A forma como utilizamos é parecida com o setImmediate():

process.nextTick(() => {
// ...
})

Mas há uma diferença fundamental: process.nextTick() opera numa fila própria, a nextTickQueue. E executa os callbacks desta fila imediatamente depois de cada fase.

Corrigindo o snippet anterior sobre o event loop, poderíamos representar o process.nextTick() desta forma:

while (true) {
poll()
nextTick()
check()
nextTick()
close()
nextTick()
timers()
nextTick()
pending()
nextTick()
}

Portanto é importante sempre tomar MUITO cuidado ao utilizar process.nextTick(). Se você rodar alguma lógica pesada, ou gerar recursão nesta fase, pode tirar um tempo precioso que seria utilizado pelas outras fases.

A não ser que você saiba bem o que está fazendo e tenha um ótimo motivo, prefira usar setImmediate() em vez de process.nextTick().

Conclusão

O motor V8, e por conseguinte, o Node.js utilizam um mecanismo chamado Event Loop pra gerenciar diversas filas de callbacks que são executados em 5 fases diferentes: poll, check, close, timers e pending.

Além disso, para executar operações de Entrada e Saída, outras threads são utilizadas, aumentando o número de processos que podem ser executados em paralelo.


Este foi mais um artigo da série de estudos que eu tenho feito sobre o ecossistema Node.js. E apesar de o conteúdo não dar trabalho, estes gráficos e GIFs todos deram um trabalhão!

Você pode me ajudar compartilhando este artigo nas suas redes sociais. Caso queira compartilhar as imagens, dê também um link pra este artigo.

Até a próxima! 👋

Comentários