Node.js por Baixo dos Panos #3 - Um Mergulho no Event Loop

Lucas Santos - Oct 15 '19 - - Dev Community

No nosso último artigo, falamos sobre call stacks, stack frames, stack overflow e várias outras coisas do JS. Entendemos como o engine se relaciona com o JavaScript e como todas as nossas execuções de código são feitas no runtime do JS.

Agora vamos entrar em outra parte, a parte do Event Loop e entender por que todos os runtimes JS e todos os engines JS tem um desses. Primeiro, vamos entender o core disso tudo.

Libuv

O que é libuv? Por que eu preciso disso?

O Libuv é uma biblioteca open source que lida com a thread-pool, sinalização e comunicação entre processos, e toda a mágica necessária para fazer com que as tarefas assíncronas funcionem. O Libuv foi desenvolvido originalmente para o próprio Node.js como uma abstração em torno do libev, no entanto, hoje em dia, vários projetos já estão usando ela.

A maioria das pessoas pensa que o libuv é o event loop em si, isso não é verdade, o libuv implementa um event loop com todos os recursos, mas também é a casa de várias outras partes principais do Node, como:

  • Sockets TCP e UDP do pacote net
  • Resoluções DNS assíncronas
  • Operações assíncronas de arquivos e file system
  • Eventos do file system
  • IPC
  • Child processes e controle de shell
  • Controle de threads
  • Sinalização
  • Relógio de alta resolução

É principalmente por isso que o Node.js usa esta biblioteca, ela é uma abstração completa em torno de várias partes principais de todos os sistemas operacionais e é necessário que todo o runtime interaja com o ambiente que há ao redor dele.

Event Loop

Vamos deixar o ambiente do Node.js por um tempo. No navegador, em JavaScript puro, o que aconteceria se você tivesse uma função de longa duração na pilha de chamadas? Esses tipos de funções demoram um pouco para serem concluídas, como um processamento de imagem complexo ou uma longa transformação de matriz?

Na maioria das linguagens, você não deve ter problemas, pois elas são multithread, no entanto, em linguagens single-thread, esse é um problema muito sério. Como a pilha de chamadas tem funções a serem executadas, o navegador não pode fazer mais nada, e o navegador não é apenas HTML e CSS, existem algumas outras coisas, como, por exemplo, um motor de renderização que pinta a tela para desenhar o que quer que seja você codificou no markup da página. Isso significa que, se você tiver funções de execução longa, seu navegador interromperá literalmente toda a execução nessa página. É por isso que a maioria dos navegadores trata as guias como threads ou processos separados, para que uma guia não congele todas as outras.

Outra questão que pode ser levantada é que os navegadores são bem controladores, portanto, se uma guia demorar muito para responder, eles entram em cena gerando um erro e perguntando se você deseja ou não encerrar a página da web. E isso não é o melhor UX que podemos ter, certo? Por outro lado, tarefas complexas e código de longa duração são o que nos permite criar softwares cada vez mais complexos e mais legais, então, como podemos executá-los sem deixar nosso browser controlador irritado? Callbacks assíncronos, a base do Node.js.

Callbacks Assíncronos

A maioria dos aplicativos JavaScript funciona carregando um único arquivo .js na memória e, em seguida, toda a mágica acontece após a execução desse único ponto de entrada. Isso pode ser dividido em vários blocos de tempo, os blocos "agora" e os "posteriores". Normalmente, apenas um desses blocos será o "agora", o que significa que será o único a ser executado na thread principal (enviando chamadas para a call stack), e todos os outros serão executados posteriormente.

O maior problema quando se trata de programação assíncrona é que a maioria das pessoas pensa que "mais tarde" está entre "agora" e um milissegundo depois, o que é uma mentira. Tudo em JavaScript que está programado para ser executado e finalizado posteriormente não necessariamente acontece estritamente após a thread principal; eles, por definição, serão concluídos quando concluídos. O que significa que você não terá a resposta imediata que estava procurando.

Por exemplo, vamos pegar uma chamada AJAX simples que chama uma API:

const response = call('http://api') // call() is some http request package, like fetch
console.log(response)
Enter fullscreen mode Exit fullscreen mode

Como as chamadas AJAX não são concluídas logo após serem chamadas - demora um tempo para que o handshake HTTP seja executado, obtenha os dados, faça o download deles... - para que essa chamada seja concluída mais tarde, então a resposta ainda não possui um valor atribuído, o que significa que nossa função console imprimiria undefined.

Uma maneira simples de "esperar" pela resposta são callbacks. Os callbacks são, desde o início da programação, uma função chamada automaticamente que é passada como parâmetro para outra função que será executada e/ou terá seu valor retornado após "agora". Portanto, basicamente, callbacks são uma maneira de dizer: "Ei, quando você tiver esse valor, chame essa função aqui". Então, vamos melhorar nosso exemplo:

const response = call('http://api', (response) => {
  console.log(response)
})
Enter fullscreen mode Exit fullscreen mode

Isso significa basicamente que, quando a chamada for encerrada, uma função anônima com a assinatura (response) => void será automaticamente chamada, já que a chamada retorna a resposta, esse parâmetro é passado para o callback. Agora teríamos o log na resposta.

Portanto, em nosso primeiro exemplo de código, a chamada readFile (lembra? No primeiro artigo?), estamos basicamente transformando-a em uma Promise, que é um código que retornará seu valor em um estado posterior e, em seguida, imprimindo-o, estamos lendo um arquivo de forma assíncrona. Mas como isso funciona?

Dentro do event loop

Até o ES6, o JS nunca teve nenhum tipo de consenso ou noção de assincronia embutida no seu core, isso significa que o JS receberia seu pedido para executar algum código assíncrono e enviaria ao engine, o que daria um sinal de positivo e responderia ao JS com "eu já vejo isso ai". Portanto, não havia ordem nem lógica sobre como o "posterior" se comportaria nos engines.

Os engines JS, na verdade, não funcionam isolados de tudo. Eles rodam dentro do que é chamado de ambiente de hospedagem (ou hosting environment). Esse ambiente pode ser o local em que o JS está rodando, como um navegador, o Node.js ou, como o JS está praticamente em todo lugar, pode ser uma torradeira ou um avião. Todo ambiente é diferente um do outro, cada um tem suas próprias funções e ferramentas, mas todos eles têm um "event loop".

O event loop é o que realmente cuida da execução de código assíncrono para os engines JS, pelo menos na parte da programação. É quem chama o engine e envia os comandos a serem executados, e também é quem enfileira os retornos de resposta que o engine retorna para ser chamado posteriormente. Portanto, estamos começando a entender que um engine JS nada mais é do que um ambiente de execução sob demanda para qualquer código JS, quer esse código funcione ou não. Tudo o que está em volta dele – o ambiente, o event loop – é responsável por agendar essas execuções de código, o que chamamos de eventos.

Agora vamos voltar ao nosso código readFile. Quando executamos, a função readFile é agrupada em um objeto Promise, mas, em essência, a função readFile é uma função que possui um callback. Então, vamos analisar apenas esta parte:

fs.readFile(filePath, function cb (err, data) => {
      if (err) return reject(err)
      return resolve(callback(data))
    })
Enter fullscreen mode Exit fullscreen mode

Viu que temos um callback (err, data) => string? Isso basicamente diz ao engine para executar uma operação de leitura em um arquivo. O engine diz ao hosting environment que suspenderá a execução desse pedaço de código por enquanto, mas, assim que o ambiente (o loop de eventos) tiver a resposta, ele deve agendar esse callback anônimo (o cb) para ser executado o mais rápido possível. Então, o ambiente (no nosso caso, é o Node.js) é configurado para ouvir essa resposta da operação do arquivo; quando essa resposta chega, ele agenda a função cb para ser executada, inserindo-a no loop de eventos.

Vamos lembrar do nosso diagrama:

As Web APIs são, em essência, threads que não podemos acessar como desenvolvedores, só podemos fazer chamadas para elas. Geralmente, essas são partes integradas ao próprio ambiente, por exemplo, em um ambiente de navegador; essas seriam APIs como document,XMLHttpRequest ou setTimeout, que são principalmente funções assíncronas. No Node.js, essas seriam nossas APIs C++ que vimos na primeira parte do guia.

Portanto, em palavras simples, sempre que chamamos uma função como setTimeout no Node.js, essa chamada é enviada para um thread diferente. Tudo isso é controlado e fornecido pelo libuv, incluindo as APIs que estamos usando.

Vamos ampliar a parte do event loop:

O event loop tem uma única tarefa: Monitorar a call stack e o que é chamado de fila de callbacks. Quando a call stack estiver vazia, o primeiro evento será retirado da fila de retorno e inserido na call stack, que efetivamente executa esse código. Para essa iteração, pegar um retorno da fila e executá-lo na call stack, damos o nome de tick.

Vamos dar um exemplo mais simples para mostrar como o event loop realmente funciona:

console.log('Node.js')
setTimeout(function cb() { console.log(' awesome!') }, 5000)
console.log(' is')
Enter fullscreen mode Exit fullscreen mode

Isso deve imprimir "Node.js é incrível!" no console, em linhas separadas. Mas como isso acontece? Vamos executá-lo passo a passo:

  1. O estado está vazio, a call stack está vazia, nada é chamado

  1. console.log ('Node.js') é adicionado à call stack

  1. console.log ('Node.js') é executado

  1. console.log ('Node.js') é removido da pilha

  1. setTimeout (função cb () {...} é adicionado à call stack

  1. setTimeout (function cb () {...} é executado. O ambiente cria um timer como parte das Web APIs. Este timer irá lidar com a contagem regressiva

  1. setTimeout (função cb () {...} em si é concluído e removido da call stack

  1. console.log ('is') é adicionado à call stack

  1. console.log ('is') é executado

  1. console.log ('is') é removido da call stack

  1. Após pelo menos 5000ms, o timer é concluído e inclui o callback cb na fila de callbacks

  1. O event loop verifica a pilha; se estiver vazia, ele tira o callback da fila de callbacks e o coloca na pilha.

  1. cb é executado e adiciona console.log ('awesome!') Na call stack

  1. console.log ('awesome!') É executado

  1. console.log ('awesome!') É removido da pilha

  1. cb é removido da pilha

Como observamos anteriormente, o ES6 especifica como o loop de eventos deve se comportar; portanto, agora, tecnicamente, está dentro do escopo das responsabilidades do engine cuidar desse agendamento, que não está mais desempenhando o papel de apenas um ambiente de hospedagem. A principal razão pela qual isso aconteceu é devido à implementação das Promises nativas no ES6, que - como veremos mais adiante - precisavam ter algum controle refinado sobre operações e filas de agendamento.

Quando a call stack e todas as filas estiverem vazias, o event loop simplesmente encerrará o processo.

Vale ressaltar que a fila de callbacks, como a call stack, é outra estrutura de dados, uma fila. As filas agem de maneira semelhante às pilhas, mas a diferença é sua ordem. Enquanto os stack frames são incluídos no topo da pilha, os itens da fila são empurrados para o final da fila. E enquanto, nas pilhas, a retirada desses itens ocorre da maneira LIFO, as filas se comportam no modelo FIFO (primeiro a entrar, primeiro a sair), o que significa que a operação de retirada (pop) removerá o primeiro item da fila, o mais antigo.

Mais tarde não significa necessariamente "mais tarde"

Uma coisa que é importante observar no código acima é que o setTimeout não coloca automaticamente seu callback na fila do event loop após o término. setTimeout é uma API externa cujo único trabalho é definir um timer para executar outra função posteriormente. Após o tempo expirar, o ambiente coloca seu callback na fila de callbacks do event loop, para que algum tick futuro o pegue e o inicie na call stack.

Então, quando fazemos setTimeout(cb, 1000), esperamos que nossa função cb seja chamada após 1000 ms, certo? Sim, mas não é isso que realmente acontece debaixo dos panos. Isso está dizendo apenas: "Ei! Anotei seu pedido, então, quando 1000ms passarem, colocarei sua função cb na fila", mas lembre-se de que as filas têm uma ordem diferente das pilhas, portanto, callbacks serão adicionados ao final da fila, o que significa que a fila pode ter outros eventos que foram adicionados anteriormente. Portanto, seu callback terá que aguardar a conclusão de todos eles para ser processado.

Um dos melhores exemplos para mostrar como essa loucura assíncrona funciona é definir uma função de tempo limite como 0. Naturalmente, você espera que essa função seja executada logo após adicioná-la ao código, certo? Errado.

console.log('Node.js')
setTimeout(() => console.log('is'), 0)
console.log('Awesome!')
Enter fullscreen mode Exit fullscreen mode

Nosso primeiro palpite é: "O código impresso será Node.js is awesome! em três linhas", mas não é isso que acontece. Definir um timeout para 0 apenas adia a execução do callback da para o próximo momento em que a call stack é limpa. De fato, nossa resposta seria uma frase do tipo Yoda:

Node.js
Awesome!
is
Enter fullscreen mode Exit fullscreen mode

Microtasks e Macrotasks

É por isso que o ES6 foi tão importante para o assincronismo no JS, pois ele padronizou tudo o que sabíamos sobre execuções assíncronas para que funcionassem da mesma maneira e também adicionou outro conceito chamado "Microtask Queue" - ou "Fila de jobs". É uma camada acima da fila de callbacks - que agora será chamada "Macrotask Queue" - na qual você provavelmente se deparará ao trabalhar com Promises.

Para ser muito específico. A fila de Microtasks é uma fila anexada ao final de cada tick no event loop. Portanto, certas ações assíncronas que ocorrem durante um tique do event loop não farão com que um callback seja adicionado na fila de Macrotask, mas adicionará um item - chamado "Microtask" ou "Job" - ao final da fila Microtask do tick atual. Isso significa que, agora, você pode ter certeza de que pode adicionar código a ser executado posteriormente na fila de Microtasks, que serão executados logo após o seu tick, antes que qualquer coisa da fila de Macrotask apareça.

Como não há restrições sobre o que uma Microtask pode fazer com seu código, é possível que uma Microtask adicione outra Microtask no final da mesma fila sem parar, causando o que é chamado de "loop de Microtasks", que impede o programa de ter os recursos necessários e impede que ele siga para o próximo tick. É o equivalente a ter um loop while (true) em execução no seu código, mas de forma assíncrona.

Para evitar esse problema, o engine possui uma proteção interna chamada process.maxTickDepth, que é definida com o valor de 1000, depois que 1000 microtasks foram agendadas e executadas no mesmo tick, a próxima macrotask é executada.

setTimeout(cb, 0) foi uma "solução alternativa" para adicionar callbacks que certamente seriam adicionados logo após a execução na fila, assim como as Microtasks, no entanto, as Microtasks são uma especificação muito mais limpa e definida, significando que as coisas serão executadas mais tarde, mas o mais rápido possível.

De acordo com a especificação WHATVG, uma e exatamente uma macrotask deve ser processada a partir da fila de macrotask em um tick do event loop. Após o término dessa macrotask, todas as outras microtasks disponíveis devem ser processadas no mesmo tick. Como as microtaks podem enfileirar outras microtasks, embora existam microtasks na fila de microtask, elas devem ser executadas uma a uma até que a fila de microtask esteja vazia. Como mostra este diagrama:

Nem todas as tarefas são microtasks, estes são alguns exemplos de microtasks:

  • process.nextTick
  • Promises
  • Object.observe

Estas são macrotasks:

  • setTimeout
  • setInterval
  • setImmediate
  • Qualquer operação de E/S

Vamos a um exemplo:

console.log('script start')

const interval = setInterval(() => {
  console.log('setInterval')
}, 0)

setTimeout(() => {
  console.log('setTimeout 1')

  Promise.resolve()
    .then(() => console.log('promise 3'))
    .then(() => console.log('promise 4'))
    .then(() => {
      setTimeout(() => {
        console.log('setTimeout 2')
        Promise.resolve().then(() => console.log('promise 5'))
          .then(() => console.log('promise 6'))
          .then(() => clearInterval(interval))
      }, 0)
    })
}, 0)

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'))
Enter fullscreen mode Exit fullscreen mode

Isto vai logar:

script start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
setInterval
promise5
promise6
Enter fullscreen mode Exit fullscreen mode

Se seguirmos este passo a passo, teremos algo como isto:

Primeiro tick

  • O primeiro console.log será empilhado na call stack e executado e, em seguida, será exibido
  • setInterval está agendado como uma tarefa
  • setTimeout 1 está agendado como uma tarefa
  • os dois "then's" de Promise.resolve 1 estão agendados como microtasks
  • Como a pilha está vazia, microtasks são executadas   - A pilha de chamadas empilha e exibe duas expressões console.log   - "promise 1" e "promise 2" são impressas

Nossa fila de macrotask possui: [setInterval,setTimeout 1]

Segundo tick

  • A fila de microtask está vazia, o handler setInterval pode ser executado.   - A call stack é executada e aparece a expressão console.log   - "setInterval" é impresso   - Agenda outro setInterval apóssetTimeout 1

Nossa fila de macrotask possui: [setTimeout 1,setInterval]

Terceiro tick

  • A fila de microtask permanece vazia
  • O handler setTimeout 1 é executado   - A call stack é executada e aparece a expressão console.log     - "setTimeout 1" é impresso   - Os handlers "Promise 3" e "Promise 4" estão agendados como microtasks   - Ambos os handlers das promessas 3 e 4 são executados     - A call stack é executada e exibe duas expressões console.log     - Imprime "promise 3" e "promise 4"   - O próximo handler das promises 3 e 4 agendam uma tarefa setTimeout 2

Nossa fila de macrotask possui: [setInterval,setTimeout 2]

Quarto Tick

  • A fila Microtask está vazia, o handler setInterval é executado, o que enfileira outrosetInterval logo atrás de setTimeout

Nossa fila de macrotask possui: [setTimeout 2,setInterval]

  • O handler setTimeout 2 é executado   - As promises 5 e 6 são agendadas como microtasks   - Os handlers das promises 5 e 6 são executados     - A call stack recebe mais duas chamadas console.log     - Imprime "promise 5" e "promise 6"     - Limpa o intervalo

Nossa fila de macrotask possui: []

É por isso que é importante observar como as coisas funcionam sob o capô, caso contrário, nunca saberíamos que Promises são executadas mais rapidamente do que callbacks.

Conclusão

Não deixe de acompanhar mais do meu conteúdo no meu blog e se inscreva na newsletter para receber notícias semanais!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .