Node.Js event loop e concorrência
Node.Js é single-threaded? Desvendando o event loop e a concorrência
Quando se pensa em desenvolver aplicações backend com JavaScript, Node.js surge como uma escolha popular e poderosa. E em 99% das conversas sobre sua arquitetura, uma frase é quase um mantra: "Node.js é single-threaded". Mas o que isso realmente significa e como, mesmo assim, ele lida com tantas conexões?
Por que o Node.js é "Single-Threaded"?
Para entender o multithreading (ou a ausência tradicional dele) no Node.js, precisamos primeiro falar sobre seu coração: o Event Loop e a arquitetura orientada a eventos e I/O (Entrada/Saída) não bloqueante.
- Thread Única Principal: De fato, seu código JavaScript em uma aplicação Node.js roda, por padrão, em uma única thread principal. Isso significa que apenas uma instrução do seu código está sendo executada em um determinado momento.
- I/O Não Bloqueante: Imagine um garçom em um restaurante movimentado. Se ele fosse "bloqueante", ele anotaria o pedido de uma mesa, iria à cozinha, esperaria o prato ficar pronto, entregaria e só então atenderia a próxima mesa. Ineficiente, certo? O Node.js age como um garçom "não bloqueante": ele anota o pedido (uma operação de I/O, como ler um arquivo ou fazer uma requisição à API), passa para a cozinha (o sistema operacional ou outras threads internas do libuv, que é uma biblioteca C++ que gerencia o I/O assíncrono) e imediatamente vai atender outras mesas.
- O Event Loop: Quando a cozinha avisa que um prato (operação de I/O) está pronto, o garçom (Event Loop) pega o prato e entrega à mesa correspondente (executa a função de callback associada).
Essa arquitetura é fantástica para aplicações I/O-bound, ou seja, aplicações que passam a maior parte do tempo esperando por operações de rede, leitura/escrita de arquivos, ou consultas a bancos de dados. O Node.js consegue lidar com dezenas de milhares de conexões simultâneas com baixo consumo de memória, pois não precisa criar uma nova thread para cada conexão (como em alguns modelos tradicionais).
Código síncrono continua síncrono (e bloqueante)
Se uma função síncrona demora muito (ex: um loop complexo, cálculos pesados), ela bloqueia a Call Stack. Nenhuma outra instrução JavaScript pode ser executada até que essa função termine. Isso leva a um servidor Node.js que não responde.
console.log('Início (síncrono)');
function tarefaSincronaDemorada() {
let soma = 0;
for (let i = 0; i < 1e9; i++) {
soma += i;
}
console.log('Tarefa síncrona demorada concluída');
}
tarefaSincronaDemorada();
console.log('Fim (síncrono)');
/**
Este será o output:
-------------------
Início (síncrono)
Tarefa síncrona demorada concluída
Fim (síncrono)
-------------------
*/
O código assíncrono pode rodar em segundo plano e faz tudo parecer mágico!
E desta forma, por exemplo quando se utilizam chamadas de I/O, as coisas podem acontecer em segundo plano.
console.log('--- Início do Script (Síncrono) ---');
const API_ENDPOINT_1 = 'https://example.com?request=1';
const API_ENDPOINT_2 = 'https://example.com?request=2';
const API_ENDPOINT_3 = 'https://example.com?request=3';
function fazerChamadaAPI(url, nomeTarefa) {
console.log(`🚀 Iniciando ${nomeTarefa} para ${url}...`);
return fetch(url).then((response) => {
console.log(
`✅ ${nomeTarefa} CONCLUÍDA. Tamanho da resposta: ${
response.text().length
} caracteres.`
);
});
}
// Tarefas Assíncronas: Chamada à API
fazerChamadaAPI(API_ENDPOINT_1, 'API Call 1');
fazerChamadaAPI(API_ENDPOINT_2, 'API Call 2');
fazerChamadaAPI(API_ENDPOINT_3, 'API Call 3');
console.log('--- Todas as chamadas de API foram INICIADAS (Síncrono) ---');
console.log('--- O script principal continua executando sem bloquear... ---');
for (let i = 1; i <= 3; i++) {
console.log(`Loop síncrono rápido no final: ${i}`);
}
console.log('--- Fim do Script Principal (Síncrono) ---');
/**
Este é um exemplo do output:
--- Início do Script (Síncrono) ---
🚀 Iniciando API Call 1 para https://example.com?request=1...
🚀 Iniciando API Call 2 para https://example.com?request=2...
🚀 Iniciando API Call 3 para https://example.com?request=3...
--- Todas as chamadas de API foram INICIADAS (Síncrono) ---
--- O script principal continua executando sem bloquear... ---
Loop síncrono rápido no final: 1
Loop síncrono rápido no final: 2
Loop síncrono rápido no final: 3
--- Fim do Script Principal (Síncrono) ---
✅ API Call 2 CONCLUÍDA. Tamanho da resposta: 1256 caracteres.
✅ API Call 3 CONCLUÍDA. Tamanho da resposta: 1256 caracteres.
✅ API Call 1 CONCLUÍDA. Tamanho da resposta: 1256 caracteres.
*/
Por que isso parece paralelo?
- Chamada de I/O: A função
fetch()
inicia uma requisição HTTP, mas ela não espera a resposta do servidor para continuar. Ela retorna uma Promise imediatamente. - Delegação ao Ambiente: A tarefa de realizar a comunicação de rede (enviar a requisição, esperar a resposta, baixar os dados) é delegada ao motor de rede do ambiente (neste caso, o Node.js). Isso acontece em background, fora da thread principal do JavaScript.
- Continuação Imediata: O script principal JavaScript continua sua execução. Por isso, os logs "Todas as chamadas de API foram INICIADAS", o loop síncrono e "Fim do Script Principal" aparecem antes de qualquer resposta da API.
- Promises e Microtask Queue:
- Quando a resposta da rede chega (ou ocorre um erro), a Promise retornada por
fetch
é resolvida ou rejeitada. - Os callbacks fornecidos aos métodos
.then()
(para sucesso) ou.catch()
(para erro) são colocados na Microtask Queue.
- Quando a resposta da rede chega (ou ocorre um erro), a Promise retornada por
- Event Loop: Assim que a Call Stack do JavaScript fica vazia (após a execução de todo o código síncrono), o Event Loop começa a processar os callbacks da Microtask Queue. Ele pega cada callback de Promise resolvido (ou rejeitado) e o executa.
- Concorrência de Rede: As três requisições de rede para
example.com
estão "em trânsito" ao mesmo tempo. O seu computador pode enviar e receber dados para múltiplas conexões de rede concorrentemente. A ordem em que as respostas chegam pode variar dependendo de muitos fatores (latência da rede, carga do servidorexample.com
, etc.), o que reforça a natureza assíncrona e a aparência de paralelismo.
As fases do event loop
O event loop do Node.js é o mecanismo central que gerencia a execução de código assíncrono e o tratamento de eventos, permitindo sua característica assíncrona e não bloqueante. Essencialmente um laço de repetição, ele opera em múltiplas fases distintas. Cada fase possui uma fila de callbacks (operações a serem executadas), organizada no formato FIFO (First-In, First-Out), que é processada integralmente (ou até que se atinja o limite de callbacks) antes que o loop avance para a fase seguinte.
timers
: Executa callbacks agendados porsetTimeout()
esetInterval()
.pending callbacks
: Executa callbacks de operações de I/O (como erros de rede) que foram adiados para a próxima iteração do loop. Por exemplo, se um erro TCP acontece durante uma operação, alguns sistemas operacionais aguardam para reportá-lo, e esse callback é enfileirado aqui.idle, prepare
: Usadas internamente pelo Node.js para tarefas de preparação. Não há callbacks de usuário diretamente associados a estas fases.poll
: Fase principal para processar eventos de I/O. Recupera novos eventos de I/O (ex: leitura de arquivo completa, dados recebidos em uma conexão de rede) e executa seus callbacks. Se a fila depoll
estiver vazia, o Node.js pode:- Verificar se há
timers
cujo tempo expirou e ir para a fasetimers
. - Verificar se há callbacks em
check
(setImmediate
) e ir para a fasecheck
. - Se não houver nada disso, ele aguardará (bloqueará) por novos eventos de I/O.
- Verificar se há
check
: Executa callbacks agendados comsetImmediate()
. Estes rodam imediatamente após a fase depoll
ter completado seus callbacks e antes detimers
do próximo ciclo.close callbacks
: Executa callbacks de eventos de fechamento, comosocket.on('close', ...)
ouprocess.on('exit', ...)
.
Por que isso é importante?
Entender a natureza assíncrona do NodeJs (e do javascript) é extremamente importante na hora de projetar um sistema escalável, podendo assim extrair o máximo da concorrência quando necessário e até mesmo entender quando pode ser necessário a utilização das worker threads (conteúdo pra um próximo artigo ;]).
E tu, acha que já utiliza da concorrência do JavaScript da forma desejável? Fico também ao dispor caso tenham surgidas mais dúvidas com relação ao texto!