Como até funções assíncronas rápidas do Node.js podem bloquear o Event Loop
Michael Gokhman
4 de fevereiro de 2019
0 minutos de leituraUm app típico do Node.js é basicamente uma coleção de callbacks executados em resposta a vários eventos: uma conexão recebida, a conclusão de uma operação de I/O, o fim de um timeout, a resolução de uma Promise etc. Há uma única thread principal (também chamada de Event Loop) que executa todos esses callbacks. Por isso, eles precisam ser rápidos, já que todos os outros callbacks pendentes estão esperando sua vez. Essa é uma limitação conhecida e desafiadora do Node, também muito bem explicada na documentação.
Recentemente, me deparei com um caso real de bloqueio do event loop aqui na Snyk. Ao tentar resolver o problema, percebi o quanto eu sabia pouco sobre o comportamento do event loop e cheguei a algumas conclusões que, a princípio, surpreenderam a mim e a outros desenvolvedores com quem conversei. Acredito que é importante que o maior número possível de desenvolvedores Node também tenha esse conhecimento — e foi isso que me levou a escrever este artigo.
Há bastante informação útil sobre o Event Loop do Node.js, mas levei um tempo para encontrar as respostas às perguntas específicas que eu tinha. Vou fazer o possível para compartilhar essas perguntas e as respostas que encontrei em vários ótimos artigos, além de algumas experiências e investigações divertidas.
Primeiro, vamos bloquear o Event Loop do Node.js!
Observação:
Os exemplos abaixo usam o Node 10.9.0 em uma VM Ubuntu 18.04 com 4 núcleos, executada em um MacBook Pro 2017
Você pode testar o código abaixo clonando https://github.com/michael-go/node-async-block
Vamos começar com um servidor Express simples:
Agora vamos adicionar um endpoint que bloqueia o event loop:
Então, esperamos que, durante a execução do endpoint /compute-sync, as requisições para /healthcheck fiquem travadas. Podemos testar o /healthcheck com este comando de uma linha em bash:
Ele vai testar o endpoint /healthcheck a cada segundo e estourar o tempo limite se não receber uma resposta em 5 segundos.
Vamos testar:

Assim que chamamos o endpoint /compute-sync, as verificações de integridade começaram a atingir o timeout e nenhuma delas teve sucesso. Note que o processo node está trabalhando intensamente, com uso de CPU em ~100%.

Após 43,2 segundos, o cálculo termina, as 8 requisições pendentes para /healthcheck são atendidas e o servidor volta a responder.
Essa situação é bem ruim: uma única requisição problemática bloqueia o servidor por completo. Sabendo que o event loop usa uma única thread, fica claro por que o código em /compute-sync bloqueou todas as outras requisições: antes de terminar, ele não devolveu o controle ao agendador do event loop, então o handler de /healthcheck não teve chance de executar.
Vamos desbloqueá-lo!
Vamos adicionar um novo endpoint, /compute-async, no qual dividiremos o loop longo que bloqueia a execução, devolvendo o controle ao event loop após cada etapa do cálculo (troca de contexto demais? Talvez… podemos otimizar depois e devolver o controle a cada X iterações, mas vamos começar de forma simples):
Vamos testar:

Hmm… ele ainda bloqueia o event loop?

O que está acontecendo? O Node.js está otimizando e eliminando nosso uso de brincadeira de async/await? O processo levou mais alguns segundos para terminar, então não parece ser o caso… Os flame graphs podem dar uma pista se o async estava mesmo em ação. Podemos usar este ótimo projeto no GitHub para gerar flame graphs com facilidade:
Primeiro, o flame graph capturado durante a execução de /compute-sync:

Podemos ver que a pilha de /compute-sync inclui a pilha do handler do Express, já que a execução foi 100% síncrona.
Agora, o flame graph capturado durante a execução de /compute-async:

Por outro lado, o flame graph de /compute-async é bem diferente: a execução ocorre no contexto das funções RunMicrotask e AsyncFunctionAwaitResolveClosure do V8. Então, não… parece que o async/await não foi “otimizado e eliminado”.
Antes de investigar mais a fundo, vamos tentar uma última cartada desesperada que nós, desenvolvedores, às vezes usamos: adicionar uma chamada de sleep!
Vamos executar:

Finalmente, o servidor continua respondendo durante o cálculo pesado. Infelizmente, o custo dessa solução é alto demais: vemos que o uso da CPU fica em torno de ~10%, então parece que ela não está trabalhando o suficiente no cálculo, que praticamente leva uma eternidade para terminar (não tive paciência para esperar). Só reduzindo o número de iterações de 10⁷ para 10⁵ é que ele terminou, após 2:23 minutos! (Se você está se perguntando, passar 1 em vez de 0 como parâmetro de atraso para setTimeout não faz diferença — falaremos mais disso adiante.)
Isso levanta várias perguntas:
por que o código
asyncfoi bloqueado sem a chamada asetTimeout()?por que
setTimeout()teve um custo de desempenho tão alto?como podemos desbloquear o cálculo sem esse custo?
Fases do Event Loop
Quando algo parecido aconteceu comigo, recorri ao Google em busca de respostas. Ao pesquisar coisas como “node async block event loop”, um dos primeiros resultados é este guia oficial do Node.js. Ele realmente me abriu os olhos.
O guia explica que o event loop não é o agendador mágico que eu imaginava, mas sim um loop bastante simples, com várias fases. Veja este diagrama ASCII das principais fases, copiado do guia:

(Mais tarde, descobri que esse loop é implementado de forma bem clara em libuv, com funções que têm os mesmos nomes do diagrama: https://github.com/libuv/libuv/blob/v1.22.0/src/unix/core.c#L359)
O guia explica as diferentes fases e apresenta a seguinte visão geral:
timers: esta fase executa os callbacks agendados por
setTimeout()esetInterval().pending callbacks: executa callbacks de I/O adiados para a próxima iteração do loop.
idle, prepare: usados apenas internamente.
poll: busca novos eventos de I/O e executa callbacks relacionados a I/O.
check: os callbacks de
setImmediate()são chamados aqui.close callbacks: alguns callbacks de fechamento, por exemplo,
socket.on('close', ...).
Esta citação começa a responder à nossa pergunta:
“Em geral, quando o event loop entra em uma determinada fase, ele realiza as operações específicas daquela fase e, em seguida, executa os callbacks na fila dessa fase até esvaziá-la… Como qualquer uma dessas operações pode agendar novas operações…”.
Isso sugere (ainda que de forma vaga) que, quando um callback coloca na fila outro callback que pode ser tratado na mesma fase, ele será executado antes de o loop avançar para a próxima fase.
O Node.js verifica o socket de rede em busca de novas requisições apenas na fase poll. Então, será que o que fizemos em /compute-async impediu o event loop de sair de determinada fase e, assim, evitou que ele passasse pela fase poll para receber a requisição /healthcheck?
Precisamos investigar mais para encontrar respostas melhores, mas agora está claro por que setTimeout() ajudou: toda vez que colocamos um callback de timer na fila, depois que a fila da fase atual se esgota, o event loop precisa percorrer todas as fases até chegar à fase “Timers”, passando pela fase poll e atendendo as requisições de rede pendentes.
Microtarefas
As palavras-chave async/await que usamos em /compute-async são conhecidas por serem uma forma sintática mais simples de escrever Promises. Na prática, a cada iteração do loop, criamos uma Promise (que é uma operação assíncrona) para calcular um hash e, quando ela é resolvida, o loop passa para a próxima iteração. O guia oficial mencionado não explica como as Promises são tratadas nas fases do event loop, mas nossos experimentos sugerem que tanto o callback da Promise quanto o callback de resolve foram chamados sem que o loop passasse pela fase poll.
Depois de pesquisar mais no Google, encontrei uma série de 5 artigos sobre o event loop, muito detalhada e bem escrita, de Deepal Jayasekara. Ela também traz informações sobre como as Promises são tratadas!
Ela contém este diagrama útil:

As caixas ao redor do círculo representam as filas das diferentes fases que vimos antes, e as duas caixas no centro representam filas especiais que precisam ser esvaziadas antes de passar de uma fase para outra. A “fila de next tick” processa os callbacks registrados por nextTick(), e a “fila de outras microtarefas” responde à nossa pergunta sobre as Promises.
E, como no nosso caso, se uma Promise resolvida criar outra Promise, ela será tratada na mesma fase antes de avançar para a próxima? Como vimos, sim! Você pode conferir como isso acontece no código-fonte do V8. O link mostra a implementação de RunMicrotask(). Veja um trecho da implementação com as linhas relevantes:
Este código em C parece estranho e faz loops usando as funções/macros mágicas e de baixo nível Goto() e Branch(), porque foi escrito com o “CodeStubAssembly” do V8, um “assembler personalizado e independente de plataforma que oferece primitivas de baixo nível como uma abstração simples sobre assembly”.
No gist, vemos um loop externo chamado init_queue_loop e um loop interno chamado loop. O loop externo verifica o número real de microtarefas pendentes. Em seguida, o loop interno processa todas as tarefas uma a uma. Quando termina, como mostra a linha 62 do gist acima, ele repete o loop externo, que verifica novamente quantas microtarefas estão pendentes. A função só retorna se nenhuma nova tarefa tiver sido adicionada.
Para entender como as microtarefas são tratadas ao final de cada fase do event loop, recomendo outro ótimo artigo de Deepal Jayasekara.
(Aliás, lembra do flame graph que criamos para /compute-async? Se você voltar para conferir, vai reconhecer RunMicrotasks() na pilha.)
Vale lembrar também que microtarefas são um recurso do V8, então isso também se aplica ao Chrome, onde as Promises são tratadas da mesma forma — sem fazer o event loop do navegador girar.
nextTick() não avança
O guia do Node.js diz:
os callbacks passados para
process.nextTick()serão resolvidos antes de o event loop continuar. Isso pode causar problemas, pois permite que você “mate de fome” as operações de I/O ao fazer chamadas recursivas aprocess.nextTick()
Desta vez, o texto é bem claro, então vou poupar você de outra captura de tela do terminal. Mas você pode testar fazendo GET /compute-with-next-tick no node async block.
A fila de nextTick é processada aqui.
Fica claro que, se um callback chamar outro process.nextTick(), o loop processará o próximo callback na mesma iteração e só vai parar quando a fila de nextTick estiver vazia.
setImmediate() vem ao resgate?
Então, Promises são vermelhas, Timers são azuis — o que mais podemos fazer?
Observando os diagramas acima, ambos mencionam a fila de setImmediate(), que é processada durante a fase “check”, logo após a fase “poll”. O guia oficial do Node.js também diz:
setImmediate()esetTimeout()são semelhantes, mas se comportam de maneiras diferentes, dependendo de quando são chamados…
A principal vantagem de usar
setImmediate()em vez desetTimeout()é quesetImmediate()sempre será executado antes de qualquer timer se for agendado durante um ciclo de I/O, independentemente de quantos timers existam.
Parece bom, mas a citação mais promissora é:
Se a fase poll ficar ociosa e houver scripts agendados com
setImmediate(), o event loop poderá avançar para a fase check em vez de esperar.
Lembra que, quando usamos setTimeout(), o processo ficou quase todo ocioso (ou seja, esperando), usando ~10% da CPU? Vamos ver se setImmediate() resolve isso:

Boa! Isso não bloqueia o servidor, e a CPU está em 100%! Vamos ver quanto tempo leva para concluir:

Muito bom. A execução foi concluída em 1:07 minuto, cerca de 50% mais do que /compute-sync e 34% mais do que /compute-async — mas é utilizável, ao contrário de /compute-with-set-timeout. Como este é um exemplo simples, em que usamos setImmediate() em cada uma das 10⁷ pequenas iterações, o que provavelmente resultou em 10⁷ ciclos completos do event loop, essa lentidão é bastante compreensível. Então, agendar um setImmediate() a partir de um callback de setImmediate() não faz com que ele permaneça na mesma fase? Isso mesmo: a documentação do Node.js diz claramente:
Se um timer immediate for colocado na fila dentro de um callback em execução, ele só será acionado na próxima iteração do event loop.
Ao comparar setImmediate() com process.nextTick(), o guia do Node.js diz:
Recomendamos que os desenvolvedores usem
setImmediate()em todos os casos…
Você já se acostumou a ver referências no código, então, para não decepcionar, acredito que este seja o código que executa os callbacks de setImmediate() — aliás, um detalhe interessante mencionado na Parte 3 da série de Deepal: o Bluebird, uma popular biblioteca de Promise não nativa, usa setImmediate() para agendar callbacks de Promise.
Considerações finais
(Quase: há uma seção bônus sobre setTimeout() a seguir.) Então, setImmediate() parece ser uma solução eficaz para dividir código síncrono de longa duração. Mas nem sempre é fácil fazer isso — e fazer da maneira certa. Cada transferência de controle para setImmediate() tem uma sobrecarga, e quanto mais solicitações estiverem pendentes, mais tempo a tarefa demorada levará para ser concluída. Por isso, dividir demais o código é problemático, e dividir de menos pode causar bloqueios prolongados. Em alguns casos, o código que bloqueia não é um loop simples como o do nosso exemplo, mas uma recursão que percorre uma estrutura complexa, o que dificulta encontrar o ponto e a condição certos para transferir o controle. Uma ideia possível em alguns casos é acompanhar por quanto tempo o código bloqueia antes de transferir o controle para setImmediate(). Este trecho só fará essa transferência se tiverem passado pelo menos 10 ms desde a última vez:
Em outros casos, um código de terceiros sobre o qual você tem menos controle pode bloquear — até mesmo funções nativas, como JSON.parse(), podem bloquear.
Uma solução mais radical é transferir o código que pode causar bloqueios para outro serviço (que não seja Node.js), para subprocessos (usando pacotes como tiny-worker) ou para threads reais, com pacotes como webworker-threads ou o recurso integrado worker-threads do Node.js (ainda experimental). Em muitos casos, essa é a solução certa, mas todas essas opções também trazem o desafio de transferir dados serializados, já que o código executado fora do processo não consegue acessar o contexto da thread principal (e única) de JavaScript.
Um desafio comum a todas essas soluções é que a responsabilidade de evitar bloqueios fica nas mãos de cada desenvolvedor, em vez de ser do sistema operacional, como ocorre na maioria das linguagens com threads, ou da máquina virtual de execução, como em Erlang. Além de ser difícil garantir que todos os desenvolvedores estejam sempre cientes disso, muitas vezes também não é ideal transferir o controle explicitamente pelo código, que não tem o contexto nem a visibilidade sobre outras rotinas e operações de E/S pendentes.
A alta capacidade de processamento que o Node.js oferece, se não for usada com cuidado, pode deixar muitas solicitações pendentes na fila por causa de uma única operação bloqueante. Para avisar o balanceador de carga o quanto antes que um servidor está com uma longa fila de solicitações atrasadas e fazer com que ele direcione as solicitações para outros nós, você pode experimentar pacotes como toobusy-js nos endpoints de verificação de integridade (isso não ajudará durante o bloqueio em si…). Também é importante monitorar o event loop durante a execução, usando pacotes como blocked-at ou soluções como N|Solid e New-Relic.
Bônus: por que o 0 em setTimeout(0) não é exatamente 0
Eu queria muito entender por que setTimeout(0) deixou o processo ocioso em 90%, em vez de usar toda a capacidade para fazer os cálculos. Uma CPU ociosa provavelmente significa que a thread principal do node fez uma chamada de sistema que suspendeu sua execução (ou seja, colocou-a para dormir) por tempo suficiente até que o kernel a agendasse para continuar — o que aponta para nosso setTimeout().
Ao ler o código-fonte de libuv relacionado ao tratamento de timers, parece que a “espera” propriamente dita acontece em uv__io_poll(), como parte da chamada de sistema poll(), por meio do parâmetro timeout.
A página de manual de poll() diz:
O argumento
timeoutespecifica o número de milissegundos durante os quais poll() deve bloquear enquanto aguarda que um descritor de arquivo fique pronto… especificar um valor negativo emtimeoutsignifica que o tempo limite é infinito. Especificar umtimeoutigual a zero faz com que poll() retorne imediatamente.
Portanto, a menos que o tempo limite seja exatamente 0, o processo ficará em espera. Suspeitamos que, apesar de passarmos 0 para setTimeout(), o tempo limite passado para poll() seja maior. Podemos tentar confirmar isso depurando o próprio node com o gdb.
Podemos rastrear todas as chamadas a uv__io_poll() usando o comando dprintf do gdb:
Vamos também exibir o horário atual a cada registro:


Quando nosso servidor inicia, uv__io_poll() é chamado com um tempo limite de -1, que significa “tempo limite infinito” — já que não há nada a fazer além de aguardar uma solicitação HTTP. Agora, vamos fazer uma requisição GET ao endpoint /compute-with-set-timeout:
Como podemos ver, o tempo limite às vezes é 1 e, mesmo quando é 0, geralmente passa pelo menos 1 ms entre duas chamadas consecutivas. Ao depurar da mesma forma enquanto fazemos uma requisição GET para /compute-with-set-immediate, o resultado sempre mostra timeout=0.

Para uma CPU, 1 milissegundo é bastante tempo. O loop apertado em /compute-sync concluiu 10⁷ iterações em 43 segundos, ou seja, executou cerca de 233 cálculos aleatórios de hash por milissegundo. Esperar 1 ms em cada iteração significa esperar 10.000 segundos — 2 horas e 45 minutos!
Vamos tentar entender por que o tempo limite passado para uv__io_poll() não é 0. O tempo limite é calculado em uv_backend_timeout() e depois passado para uv__io_poll(), como mostra o loop principal do libuv. Em alguns casos (como quando há handles ativos ou solicitações pendentes), uv_backend_timeout() retorna 0. Caso contrário, retorna o resultado de uv__next_timeout(), que escolhe o tempo limite mais próximo na “fila de timers” e retorna o tempo restante até seu vencimento.
A função usada para adicionar “tempos limite” à “fila de timers” é uv_timer_start(). Essa função é chamada em vários lugares, então podemos tentar definir um breakpoint nela para identificar melhor o chamador que, no nosso caso, passa um valor diferente de zero. Ao executar o comando info stack no GDB quando o breakpoint é atingido, temos:
TimerWrap::Start é apenas um wrapper simples em torno de uv_timer_start(), e os endereços irreconhecíveis no topo da pilha indicam que o código que procuramos provavelmente é implementado em JavaScript. Pesquisando usos de TimerWrap:Start no código-fonte do Node ou simplesmente avançando passo a passo pela implementação de setTimeout() no lado do JS com um depurador de JS, podemos localizar rapidamente o construtor Timeout em https://github.com/nodejs/node/blob/v10.9.0/lib/internal/timers.js#L53:
Por fim, vemos aqui que um tempo limite igual a 0 é ajustado para 1.
Espero que este artigo tenha sido útil. Obrigado pela leitura.
Referências:
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
https://jsblog.insiderattack.net/event-loop-and-the-big-picture-nodejs-event-loop-part-1-1cb67a182810 (série de 5 artigos)
https://github.com/libuv/libuv (também incluído no nodejs/node)
https://github.com/v8/v8 (também incluído no nodejs/node)
Mais leituras sobre o tema:
https://javabeginnerstutorial.com/node-js/event-loop-and-asynchronous-non-blocking-in-node-js/
https://stackoverflow.com/questions/34824460/why-does-a-while-loop-block-the-event-loop
https://stackoverflow.com/questions/46004290/will-async-await-block-a-thread-node-js
https://stackoverflow.com/questions/51694299/node-js-socket-io-async-and-blocking-event-loop
http://voidcanvas.com/setimmediate-vs-nexttick-vs-settimeout/
http://dtrace.org/blogs/brendan/2012/11/14/dtracing-in-anger/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ - navegadores
https://blog.risingstack.com/node-js-at-scale-understanding-node-js-event-loop/
https://humanwhocodes.com/blog/2013/07/09/the-case-for-setimmediate/
