Executando verificação de segurança...
9

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).

NodeJs Kitchen

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?

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 servidor example.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.

  1. timers: Executa callbacks agendados por setTimeout() e setInterval().
  2. 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.
  3. idle, prepare: Usadas internamente pelo Node.js para tarefas de preparação. Não há callbacks de usuário diretamente associados a estas fases.
  4. 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 de poll estiver vazia, o Node.js pode:
    • Verificar se há timers cujo tempo expirou e ir para a fase timers.
    • Verificar se há callbacks em check (setImmediate) e ir para a fase check.
    • Se não houver nada disso, ele aguardará (bloqueará) por novos eventos de I/O.
  5. check: Executa callbacks agendados com setImmediate(). Estes rodam imediatamente após a fase de poll ter completado seus callbacks e antes de timers do próximo ciclo.
  6. close callbacks: Executa callbacks de eventos de fechamento, como socket.on('close', ...) ou process.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!

Carregando publicação patrocinada...
4

Excelente artigo, André!

Você tocou no ponto chave: "delegar ao ambiente". E é muito importante a gente lembrar o tem por trás dessa "delegação mágica":

A ideia original do Node era bem "simples" e genial: conectar o V8, o interpretador de JavaScript do Chrome, com o libev – uma biblioteca em C, feita pra I/O assíncrono em sistemas Linux. O V8 cuidava do JavaScript e o libev fazia o trabalho sujo de falar com o sistema operacional de forma assincrona!

Aí, para portar essa maravilha para o Ruindows, que acabou substituindo o libev até no Linux. Claro, hoje Node.js é um projeto muito mais complexo, mas era fundamentalte:

  1. O V8 um intetrepetador de JS escrito em C++ executa seu JS
    Encontrou um fetch() ou fs.readFile()? Ele não sabe fazer isso.

  2. Ele entrega a tarefa pro libev.
    libuv (em C) se vira com as chamadas de sistema do SO (em C) pra fazer a requisição de rede ou ler o arquivo.

  3. Quando o SO terminar o libev entrega o resultado de volta para o V8.

E é aqui que está a beleza da coisa, o motivo pelo qual ele se tornou esse gigante. O Node.js não tentou reinventar a roda. Pelo contrário, ele foi o eixo perfeito que conectou duas rodas que já eram incrivelmente potentes e testadas em batalha: de um lado, o V8, otimizado ao extremo pelo Google; do outro, as capacidades de I/O assíncrono que existe e é refinado há décadas em C no Linux.

É por isso que essa "camada de abstração" foi tão disruptiva. O sucesso estrondoso do Node.js não veio de uma complexidade, mas sim de uma simplicidade genial. Ele é a prova de que a melhor engenharia, muitas vezes, é sobre ser a "cola" certa no lugar certo.

Ele deu superpoderes a milhões de desenvolvedores JavaScript, permitindo-lhes construir servidores de alta performance e resto, como dizem, é história. Mas isso só aconteceu por que alguém entendia de C, C++ e dos fundamentos e que acreditou que uma gambiarra simples era melhor que o status quo.

1