Entendendo e solucionando o bloqueio do Event Loop no NodeJs [Parte 1]
Ronaldo Modesto
Posted on January 30, 2022
Olá 😀.
Espero que estejam todos bem nesses tempos difÃceis.
Com o passar dos anos o volume de informação disponÃvel para consulta na internet aumentou exponencialmente. Falando especialmente de programação, o número de comunidades e locais de consultas que estão disponÃveis para que possam ser acessados a fim de tentar solucionar os mais diversos tipos de problemas cresceu absurdos.
Isso é muito bom porque para nós, programadores, perder tempo com um problema é muito frustante e prejudicial também. Comunidades como StackOverflow por exemplo possuem um vasto conteúdo com descrições e soluções dos mais diversos tipos de problemas. É de fato uma mão na roda.
No entanto, essa grande disponibilidade de informações acabou tornando as pessoas preguiçosas. A maioria dos programadores, quando se deparam com um bug, correm para o Stackoverflow ou Quora e pesquisam pelo problema, acham uma solução e a copiam deliberadamente, sem nem mesmo tentar entender o que foi feito ou porquê aquela solução funciona. Esse hábito tem gerado códigos com uma qualidade cada vez pior.
Por isso é importante entendermos o que estamos fazendo e porquê, pois assim além de conseguirmos produzir códigos melhores, conseguiremos resolver uma gama maior de problemas.
Como eu tentei ser em didático durante o artigo ele acabou ficando um tanto quanto grande então ele será dividido em duas partes. Ao final desse aqui você vai encontrar um link para a segunda parte.
Então bora entender o que é o bloqueio do loop de eventos do NodeJs e como podemos resolver esse problema ?
Event Loop: Uma breve introdução e como funciona
O Event Loop é o mecanismo que possibilita o NodeJs executar operações que poderiam demorar muito de forma assÃncrona, não prejudicando assim o desempenho geral do sistema. Uma vez que o processo do node se inicia inicia-se também o Event Loop que roda na thread principal ou main thread, a partir disso ele fica rodando enquanto o processo do node viver.
Ele é formado, não somente, mas principalmente por 5 fases. Em cada fase ele realiza operações especÃficas visando o não comprometimento da thread principal, delegando tarefas que demandam mais tempo para serem executadas para a libuv.
A libuv é a biblioteca escrita em C que permite ao node executar tarefas relacionadas ao kernel do SO de forma assÃncrona. Ela é a responsável por lidar com Thread Pool. O Thread Pool(como o nome já sugere) é um conjunto de threads que ficam disponÃveis para executar tarefas que serão entregues a elas pela libuv.
Pera pera pera, parou tudo!!!
Como assim conjunto de threads ??? Não havia uma thread só ?
Calma jovem padawan, eu explico. Ser single thread é uma caracterÃstica do javascript. Isso se deve à história por trás do Javascript e como e para o quê ele foi concebido. Não vou entrar em detalhes aqui, mas deixarei nas referências onde você pode ler mais sobre isso.
Então, voltando ao assunto principal. O javascript é single thread e o NodeJs utiliza essa única thread que o javascript possui para executar o Event Loop.
Ele por sua vez entrega as tarefas para a libuv e fica ouvindo as respostas, esperando que as tarefas fiquem prontas, quando as tarefas terminam de executar, como por exemplo uma leitura de arquivos, o Event Loop então executa a callback associada àquela tarefa.
Isso é o que chamamos de Event-Driven Patern, o que é muito forte no node devido à essa caracterÃstica de ele executar o loop de eventos em uma única thread. Event-Driven é um padrão de projetos baseado em eventos, onde uma tarefa é disparada após o término de outra. Mais ou menos assim, "Pegue essa tarefa demorada/pesada e mande processar, e assim que terminar, dispare um evento informando do fim dessa tarefa".
Um conceito importante que precisamos ter em mente para entender o problema que será mostrado, é a CallStack. A CallStack é uma fila do tipo LIFO (Last In Firt Out) ou (Último a entrar, Primeiro a sair). O Loop de eventos checa a todo instante a CallStack verificando se existe algo para ser processado, e caso tenha, ele a processa e então segue para a próxima função, caso exista.
O Event Loop pode ser dividido, principalmente mas não somente, em 5 fases. São elas ( explicação retirada da documentação oficial: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/ )
Timers:
Nesta fase são executadas as callbacks agendadas por setTimeout e setInterval
Pedinding Calbacks:
Nesta fase estão as callbacks que foram agendadas para a próxima iteração do loop
idle, prepare:
Esta fase é usada internamente pelo Node. Ou seja, é uma fase que realiza operações internas ao node e não interfere de forma geral no fluxo de execução das tasks que é o que nos interessa para entende o problema de bloqueio do loop de eventos.
poll:
É nessa fase que o NodeJs checa por eventos de IO, como entrada de novas requisições por exemplo. Essa fase é muito importante para entendermos o impacto do bloqueio de eventos na aplicação como um todo.
check:
Nesta fase as callbacks que são agendadas com a função setImediate são executadas. Note que existe uma fase do loop de eventos somente para executar as callbacks agendadas por essa função, e de fato, ela é extremamente importante, inclusive a usaremos para desbloquear o loop de ventos.
close callbacks:
Nesta fase são executadas as callbacks de fechamento, por exemplo quando fechamos um socket com socket.on('close').
Isso foi um breve resumo mas já será o suficiente para entendermos o problema que quero mostrar e principalmente entender as soluções que serão apresentadas, ou seja, entender o porquê e como cada uma dessas soluções age no NodeJs permitindo o desbloqueio do loop de eventos.
No entanto deixarei na seção referências artigos e links da documentação contento explicações muito mais detalhadas sobre o NodeJs como um todo e principalmente sobre o Event Loop.
Recomendo fortemente a leitura de cada um deles pois esse é um dos principais e mais importantes conceitos sobre o NodeJs, além é claro de conter explicações sobre outros conceitos extremanente importantes como a MessageQueue, Libuv, web_workers, micro e macro tasks dentre outros.
Como ocorre o bloqueio do Event Loop ?
Em suma, esse bloqueio ocorre quando realizamos descuidadamente alguma operação bloqueante na thread principal, ou seja na main thread, que por sua vez é a thread sobre a qual o Event Loop executa. Quando bloqueamos essa thread o loop de eventos não consegue avançar para as outras fases, e com isso ele fica travado, ou seja, bloqueado, em uma única parte. Isso compromete toda a sua aplicação.
Lembra que dissemos que a fase de poll é a responsável por processar as requisições que chegam para a sua aplicação ? Pois então, imagine que a sua aplicação fique travada uma fase antes dela, se a fase de Pool não puder ser atingida, novas requisições nunca serão processadas, assim como respostas de outras possÃveis requisições que ficaram prontas nesse meio tempo em que o loop estava bloqueado também não serão enviadas de volta para os usuários que as solicitaram.
Vamos ver na prática como podemos simular o bloqueio de Event Loop. Para demonstrar isso vamos utilizar as seguintes ferramentas:
NodeJs
VsCode ( ou qualquer outro editor de sua preferência). Lembrando que deixarei o projeto completo e do VsCode.
O projeto de testes
De forma resumida,essa é a estrutura do projeto que vamos utilizar
Projeto Node:
Vamos utilizar o express para servir 5 rotas. São elas:
/rota-bloqueante: Rota que vai bloquear todo o nosso sistema, será a nossa grande vilã.
/rota-bloqueante-com-chield-process: Executa a mesma operação da rota acima, porém de forma a não bloquear o loop de events se valendo de child_process para isso. É uma das soluções que vamos analisar.
/rota-bloqueante-com-setImediate: Assim como a rota anterior, executa uma opreção bloqueante, mas se utilizando da função setImediate para impedir o bloqueio do event-loop.
/rota-bloqueante-com-worker-thread: Executa a mesma operação bloqueante, mas se utiliza de workers_threads para evitar o bloqueio do event-loop.
/rota-nao-bloqueante: Rota que possui retorno imediato, será utilizada para testar a responsividade do nosso servidor.
Bloqueando o Event Loop
Para começar vamos simular uma situação na qual ocorre o bloqueio do loop de eventos. Com ele bloqueado vamos ver o que acontece com o resto do sistema.
Primeiro vamos fazer a requisição que não oferece bloqueio.
Repare que esta rota leva apenas 22 ms em média para responder.
Agora vamos bloquear o event-loop e ver o que acontece se eu tentar chamar essa rota novamente.
Primeiro chamamos a rota /rota-bloqueante, ela leva mais ou menos 2 minutos e 50 segundos para responder.
E para nossa surpresa(ou não rss), se tentamos fazer uma requisição para a rota nao-bloqueante, que a princÃpio deveria levar apenas alguns milissegundos para responder, temos uma desagradável surpresa.
Como podemos perceber, a requisição não-bloqueante demorou 2 minutos e 53 segundos para responder, isso é mais ou menos 7879 vezes mais lento do que deveria 😯.
Vamos trazer esse problema para uma situação real. Imagine que /rota-nao-bloqueante é uma rota de pagamento em sua api. Se nesse momento milhares de usuários tentassem efetuar um pagamento eles não iriam conseguir e você poderia perder milhares de vendas. Nada legal certo ?
Mas afinal, o que aconteceu ?
Vamos analisar o código atrás de respostas.
//Esse é a declaração da nossa rota bloqueante, ou seja,a //rota que compromete nosso sistema
router.get('/rota-bloqueante', async (request, response) => {
const generatedString = operacaoLenta();
response.status(200).send(generatedString);
});
Vamos analisar o código dessa função chamada operação lenta
function operacaoLenta() {
const stringHash = crypto.createHash('sha512');
// eslint-disable-next-line no-plusplus
for (let i = 0; i < 10e6; i++) {
stringHash.update(generateRandomString()); // operação extremamente custosa
}
return `${stringHash.digest('hex')}\n`;
}
Vamos por partes.
const stringHash = crypto.createHash('sha512');
Nesta linha nós criamos um hash vazio utilizando o algoritmo SHA512.
for (let i = 0; i < 10e6; i++) {
stringHash.update(generateRandomString()); // operação extremamente custosa
}
Nesta linha nós fazemos 10^6 iterações atualizando o hash que criamos com uma função generateRandomString que gera uma string aleatória em hexadecimal. Aqui utilizamos a função randomBytes do módulo Crypto do NodeJs para deixar o processamento ainda mais pesado. Só por curiosidade esse é o código da função.
function generateRandomString() {
return crypto.randomBytes(200).toString('hex');
}
Claramente esse loop é o grande culpado pela lentidão. Mas vamos entender o porque esse loop aparentemente inofensivo afetou tão negativamente nosso sistema.
O problema aqui é que esse loop extremamente custoso, tanto em tempo como em processador, está rodando na Main Thead.
Lembra que dissemos que o Javascript possui apenas uma única thread e que era essa thread que o NodeJs utilizava para executar o event-loop ? Pois então, ao fazer essa operação, nós ocupamos essa thread totalmente, e isso impediu o Event Loop de seguir para as próximas fases, e por consequência ele não conseguiu processar a nossa requisição da rota /rota-nao-bloqueante.
Com isso dizemos que o Event Loop ficou bloqueado, ou seja incapaz de fazer qualquer outra coisa até que o trabalho que ocupava a thread principal terminasse.
Por isso que da segunda vez nossa requisição que deveria ser rápida levou 2 minutos e 53 segundos, porque a requisição que enviamos para essa rota ficou esperando até que o Event Loop chegasse na fase de Poll para que ele pegasse essa requisição e colocasse ela na fila para ser processada.
Beleza! Já vimos o que pode acontecer se não respeitarmos essas caracterÃstica do NodeJs. No próximo artigo vamos ver como resolver esse problema!
Segue o link para a segunda parte e te aguardo lá 😃 😃 😃
Segunda parte
Posted on January 30, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.