Por que o event loop é importante
O Node.js executa seu JavaScript em uma única thread principal, mas um único servidor consegue lidar com milhares de conexões simultâneas. Isso é possível porque o event loop nunca espera. Quando uma operação seria bloqueante, o Node a entrega ao sistema operacional ou a um thread pool e continua com outras tarefas. Quando o resultado está pronto, um callback é colocado na fila e o loop o executa.
Entender o loop explica muito do comportamento do Node: por que a leitura síncrona de um arquivo congela todo o servidor, por que uma promise gera um log antes de um timer com atraso zero e por que tarefas pesadas de CPU precisam de um worker. Este é o modelo mental mais importante para escrever aplicações Node rápidas.
O loop e suas fases
O event loop é um ciclo de fases. Cada fase possui uma fila de callbacks, e o loop as processa antes de prosseguir.
- Timers — callbacks agendados por
setTimeoutesetIntervalque já venceram. - Pending callbacks — callbacks de sistema adiados, como alguns erros de TCP.
- Idle, prepare — manutenção interna.
- Poll — recupera novos eventos de I/O e executa seus callbacks. Se nada estiver pronto, ele pode bloquear brevemente aqui.
- Check — executa callbacks de
setImmediate. - Close callbacks — lida com eventos como o fechamento de sockets.
O loop continua ciclando enquanto houver trabalho a ser feito. Quando não há mais timers, nenhum I/O pendente e nenhum handle, o processo é encerrado.
Microtasks: nextTick e promises
Entre as fases, e após cada callback, o Node esvazia duas filas de alta prioridade:
- Callbacks de
process.nextTick, que são executados primeiro. - Callbacks de Promise, que são executados após o nextTick.
Ambas são completamente esvaziadas antes que o loop avance, o que gera duas consequências. Primeiro, elas rodam antes de timers e callbacks de I/O agendados anteriormente. Segundo, um nextTick recursivo ou uma cadeia de promises infinita pode causar starvation no loop, impedindo que o I/O seja executado.
// order.js
console.log("1: synchronous");
setTimeout(() => console.log("5: timers"), 0);
setImmediate(() => console.log("6: check"));
Promise.resolve().then(() => console.log("3: promise"));
process.nextTick(() => console.log("2: nextTick"));
A saída é 1, 2, 3, depois 5 e 6. A ordem de 5 e 6 pode variar no nível superior (top level) porque depende de quanto tempo leva para chegar à fase de timers; dentro de um callback de I/O, setImmediate sempre roda antes de setTimeout.
O thread pool do libuv
Algumas operações não podem ser realizadas de forma assíncrona pelo SO de maneira portátil: acesso ao sistema de arquivos, consultas de DNS e algumas funções criptográficas. O libuv executa essas tarefas em um thread pool, com o padrão de quatro threads, configurável via UV_THREADPOOL_SIZE.
É por isso que fs.readFile não bloqueia o seu código, embora não seja orientado a eventos internamente. Isso também significa que um pico de operações de arquivo pode ficar na fila atrás de um pool de quatro threads. As APIs async mantêm a thread principal livre; o pool cuida da espera.
Bloqueando o event loop
Qualquer operação síncrona e lenta na thread principal bloqueia tudo: outras requisições, timers e I/O. Os culpados mais comuns incluem:
readFileSynce outras chamadas de arquivo síncronas em request handlers.JSON.parseouJSON.stringifyextensos em payloads grandes.- Criptografia síncrona, como
pbkdf2Synccom alto número de iterações. - Loops intensos, backtracking de regex e transformações pesadas de dados.
- Bibliotecas bloqueantes que realizam processamento de CPU na thread principal.
Meça em vez de adivinhar: o event loop delay e o event loop utilization indicam quanto tempo o loop permanece bloqueado. Um aumento no delay sob carga é o sinal claro.
Tirando o trabalho do loop
Quando o trabalho é genuinamente CPU-bound, mova-o:
- worker_threads para computação no mesmo processo, utilizando a passagem de mensagens.
- child processes para tarefas mais pesadas ou isoladas.
- Um serviço separado para as cargas de trabalho mais pesadas, escalando-as de forma independente.
- Chunking: divida tarefas grandes em pedaços menores e utilize o yield entre eles.
Para I/O, utilize as APIs async e streams para que o loop permaneça livre.
Melhores práticas
- Use APIs async em request handlers; nunca bloqueie o loop.
- Ceda o controle para I/O com
setImmediateem vez denextTickrecursivos. - Mova tarefas CPU-bound para worker threads ou child processes.
- Faça stream de grandes volumes de dados em vez de fazer o buffer de tudo na memória.
- Ajuste o
UV_THREADPOOL_SIZEapenas após realizar medições. - Monitore o event loop delay e a utilização em produção.
- Mantenha as cadeias de callback curtas para que as microtasks não causem starvation de I/O.
Erros comuns
- Usar
readFileSyncdentro de um hot path. process.nextTickrecursivos que consomem todos os recursos do loop.- Assumir que o Node.js é totalmente single-threaded e ignorar o thread pool.
- Realizar computações pesadas inline em vez de delegá-las.
- Esperar que
setTimeout(fn, 0)seja executado antes de callbacks de I/O. - Ignorar o atraso do event loop até que problemas de latência apareçam.
Próximos passos
O event loop é o coração do Node.js. Continue com Streams para I/O em pedaços (chunked) e File System para acesso assíncrono a arquivos. Reveja os Fundamentos do Node.js se o runtime ainda for novo para você e, em seguida, meça o atraso do event loop em um serviço que você execute.