Node.js Essencial: Runtime, processo e modelo de concorrência

Capítulo 1

Tempo estimado de leitura: 10 minutos

+ Exercício

O que significa “executar JavaScript fora do navegador”

No Node.js, o JavaScript roda dentro do motor V8 (o mesmo que existe em navegadores), mas o ambiente ao redor é diferente: em vez de APIs de DOM e eventos do navegador, você tem APIs de sistema operacional (arquivos, rede, processos) expostas por módulos como fs, net, http e child_process. Esse “ambiente” é implementado em C/C++ e integra o V8 com o sistema operacional, principalmente por meio da biblioteca libuv.

Na prática, isso permite construir back-ends e CLIs com JavaScript/TypeScript, com um modelo de concorrência baseado em: (1) uma thread principal executando JavaScript, (2) um loop de eventos (event loop) e (3) um conjunto de threads auxiliares (thread pool) para certas operações.

Processo do Node.js: o que é e por que importa

Quando você executa node app.js, o sistema operacional cria um processo. Esse processo tem memória própria, descritores de arquivo, sockets, variáveis de ambiente e um identificador (PID). Dentro desse processo, o Node inicia o V8 e o loop de eventos.

Observando o processo em um script CLI

Crie um arquivo process-info.js:

console.log({ pid: process.pid, ppid: process.ppid, platform: process.platform });
console.log({ node: process.version, arch: process.arch });
console.log({ cwd: process.cwd() });
console.log({ memory: process.memoryUsage() });

Execute:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

node process-info.js

O objetivo aqui é reconhecer que cada execução é um processo isolado e que, em back-ends, você pode ter múltiplos processos (por exemplo, em cluster/containers) para escalar CPU e isolar falhas.

Thread principal, event loop e o modelo de concorrência

O Node.js é frequentemente descrito como “single-threaded”, mas isso é um atalho: o JavaScript roda em uma thread principal, porém o processo pode usar outras threads internamente (principalmente via libuv).

Thread principal (main thread)

  • Executa seu código JavaScript.
  • Se você fizer uma tarefa pesada de CPU nessa thread, você bloqueia o processamento de timers, callbacks e requisições.
  • É por isso que “não bloquear o event loop” é um critério central no desenho de back-ends em Node.

Event loop (loop de eventos)

O event loop é um mecanismo que coordena: timers (setTimeout), I/O (rede/arquivos), callbacks, Promises/microtasks e outras fontes de eventos. A ideia é: você inicia operações assíncronas, e quando elas terminam, o loop agenda seus callbacks para execução na thread principal.

libuv e thread pool

libuv é a biblioteca que fornece abstrações multiplataforma para I/O assíncrono e o event loop. Além disso, ela mantém um thread pool usado para operações que não são naturalmente não-bloqueantes em todas as plataformas (por exemplo, várias operações de fs, DNS via certas APIs, compressão/crypto em alguns casos). Enquanto essas tarefas rodam no pool, a thread principal pode continuar atendendo eventos.

Um detalhe importante: o thread pool tem tamanho limitado (por padrão, 4). Se você disparar muitas tarefas que usam esse pool, elas entram em fila e podem aumentar latência. Você pode ajustar com UV_THREADPOOL_SIZE (com cuidado, porque aumentar demais pode causar contenção e piorar desempenho).

Impacto direto no desenho de back-ends

  • Excelente para I/O: APIs HTTP, acesso a banco, cache, filas e chamadas a serviços externos são I/O-bound; o Node lida bem com muitas conexões simultâneas quando o trabalho por requisição é pequeno e assíncrono.
  • Risco em CPU: processamento pesado (hashing em massa, compressão grande, parsing/transformações extensas, relatórios complexos) pode bloquear o event loop e degradar todo o servidor.
  • Latência previsível: manter a thread principal livre tende a reduzir “picos” de latência (tail latency) em endpoints.
  • Escala horizontal e isolamento: para CPU, é comum usar workers/filas ou separar em serviços dedicados.

Experimento 1: I/O assíncrono não bloqueia o loop

Vamos criar um script que faz uma leitura de arquivo assíncrona e, ao mesmo tempo, mantém um “batimento” (heartbeat) com setInterval para mostrar que o loop continua vivo.

Passo a passo

  1. Crie um arquivo grande para leitura (ex.: 200MB). Em macOS/Linux:
dd if=/dev/zero of=bigfile.bin bs=1m count=200

No Windows (PowerShell), uma alternativa simples é criar um arquivo com tamanho fixo:

fsutil file createnew bigfile.bin 209715200
  1. Crie io-async.js:
const fs = require('fs');

const file = process.argv[2] || 'bigfile.bin';

let ticks = 0;
const start = Date.now();

const timer = setInterval(() => {
  ticks++;
  const elapsed = Date.now() - start;
  console.log(`tick=${ticks} elapsed=${elapsed}ms`);
}, 200);

fs.readFile(file, (err, data) => {
  if (err) throw err;
  const elapsed = Date.now() - start;
  console.log(`readFile done: bytes=${data.length} elapsed=${elapsed}ms`);
  clearInterval(timer);
});
  1. Execute:
node io-async.js bigfile.bin

O que observar

  • Os tick=... continuam aparecendo enquanto o arquivo é lido.
  • Isso indica que a operação de I/O foi delegada (via libuv/OS) e o event loop continuou processando timers.

Experimento 2: I/O síncrono bloqueia o loop

Agora vamos repetir, mas com leitura síncrona, que roda na thread principal e bloqueia o event loop.

Passo a passo

  1. Crie io-sync.js:
const fs = require('fs');

const file = process.argv[2] || 'bigfile.bin';

let ticks = 0;
const start = Date.now();

const timer = setInterval(() => {
  ticks++;
  const elapsed = Date.now() - start;
  console.log(`tick=${ticks} elapsed=${elapsed}ms`);
}, 200);

// Bloqueia a thread principal
const data = fs.readFileSync(file);
const elapsed = Date.now() - start;
console.log(`readFileSync done: bytes=${data.length} elapsed=${elapsed}ms`);

clearInterval(timer);
  1. Execute:
node io-sync.js bigfile.bin

O que observar

  • Os ticks não aparecem (ou aparecem só depois), porque o readFileSync impede o loop de executar o callback do setInterval.
  • Em um servidor HTTP, isso equivaleria a “congelar” o atendimento de outras requisições durante a operação.

Experimento 3: CPU-bound bloqueia mesmo com APIs assíncronas ao redor

Mesmo que você use APIs assíncronas para I/O, uma tarefa pesada de CPU no meio do caminho bloqueia o event loop. Vamos simular com um loop de cálculo.

Passo a passo

  1. Crie cpu-block.js:
function burnCpu(ms) {
  const end = Date.now() + ms;
  let x = 0;
  while (Date.now() < end) {
    x = Math.sqrt(Math.random() * 1e9) + x;
  }
  return x;
}

let ticks = 0;
const start = Date.now();

setInterval(() => {
  ticks++;
  console.log(`tick=${ticks} elapsed=${Date.now() - start}ms`);
}, 200);

console.log('Starting CPU burn...');
const result = burnCpu(3000);
console.log('CPU burn done', { result, elapsed: Date.now() - start });
  1. Execute:
node cpu-block.js

O que observar

  • Durante ~3s, os ticks param de aparecer.
  • Isso é o sintoma clássico de endpoint CPU-bound em Node: o servidor fica “vivo”, mas não responde com baixa latência.

Experimento 4: medindo atraso do event loop (event loop lag)

Uma forma prática de detectar bloqueios é medir o atraso de timers. Se você agenda um timer para rodar a cada 100ms, mas ele roda com 500ms, algo bloqueou a thread principal.

Passo a passo

  1. Crie event-loop-lag.js:
function burnCpu(ms) {
  const end = Date.now() + ms;
  while (Date.now() < end) {}
}

const interval = 100;
let expected = Date.now() + interval;

setInterval(() => {
  const now = Date.now();
  const lag = now - expected;
  expected = now + interval;
  console.log(`lag=${lag}ms`);
}, interval);

setTimeout(() => {
  console.log('Blocking CPU for 2000ms...');
  burnCpu(2000);
  console.log('Done blocking');
}, 1000);
  1. Execute:
node event-loop-lag.js

O que observar

  • Antes do bloqueio, o lag tende a ficar baixo (varia com o sistema).
  • Durante o bloqueio, o lag dispara (próximo de 2000ms).
  • Em back-ends, lag alto costuma se correlacionar com latência alta e timeouts.

Experimento 5: saturando o thread pool do libuv

Algumas operações assíncronas usam o thread pool. Se você disparar muitas ao mesmo tempo, elas competem por poucas threads e começam a enfileirar. Vamos observar isso com crypto.pbkdf2, que usa o pool.

Passo a passo

  1. Crie threadpool-saturation.js:
const crypto = require('crypto');

const start = Date.now();
const tasks = Number(process.argv[2] || 8);

for (let i = 0; i < tasks; i++) {
  crypto.pbkdf2('password', 'salt', 200000, 64, 'sha512', () => {
    const elapsed = Date.now() - start;
    console.log(`task ${i} done at ${elapsed}ms`);
  });
}
  1. Execute com o padrão (pool ~4):
node threadpool-saturation.js 8
  1. Agora execute aumentando o pool (ex.: 8):
UV_THREADPOOL_SIZE=8 node threadpool-saturation.js 8

O que observar

  • Com pool menor, as tarefas terminam em “ondas” (primeiro um grupo, depois outro), indicando fila.
  • Com pool maior, pode haver mais paralelismo, mas nem sempre melhora: depende de CPU disponível e contenção.

Exercícios práticos (diagnóstico e interpretação)

1) Identificar tarefas bloqueantes

Objetivo: classificar cada trecho como “bloqueia o event loop” ou “não bloqueia”.

  • fs.readFileSync(...)
  • fs.readFile(...)
  • Um loop while que roda por 2 segundos
  • crypto.pbkdf2(..., callback)
  • JSON.parse de um JSON muito grande (dezenas de MB)

Critério: se roda na thread principal por muito tempo, bloqueia; se delega e retorna rápido, tende a não bloquear (mas pode saturar pool).

2) Medir tempo de execução e comparar abordagens

Objetivo: medir e comparar readFile vs readFileSync e interpretar impacto no loop.

  • Rode io-async.js e anote: tempo total e quantos ticks ocorreram.
  • Rode io-sync.js e anote: tempo total e quantos ticks ocorreram.
  • Explique em 2 frases por que os ticks mudaram.

3) Estimar o “custo” de CPU no seu ambiente

Objetivo: calibrar o que é “pesado” no seu computador/servidor.

  • Altere burnCpu(3000) para 100ms, 500ms, 1000ms, 3000ms.
  • Observe a diferença no lag do script event-loop-lag.js.
  • Defina um limite prático: “acima de X ms de CPU por requisição, a latência começa a ficar inaceitável”.

4) Interpretar saturação do thread pool

Objetivo: entender fila vs paralelismo.

  • Rode threadpool-saturation.js com 4, 8, 16 tarefas.
  • Compare o padrão de conclusão das tasks.
  • Teste UV_THREADPOOL_SIZE com 4 e 8 e descreva quando melhora e quando piora.

Critérios objetivos para decidir: async, filas, workers ou serviços separados

Quando uma abordagem assíncrona (event loop) é suficiente

  • A tarefa é majoritariamente I/O-bound (rede, banco, cache, disco) e o código retorna ao loop rapidamente.
  • O tempo de CPU por requisição é baixo e estável (ex.: poucos ms a dezenas de ms) e o event loop lag permanece baixo sob carga.
  • Você consegue limitar concorrência de chamadas externas (ex.: não abrir milhares de conexões simultâneas sem controle).

Quando usar filas (background jobs)

  • A tarefa não precisa responder no tempo da requisição (ex.: gerar relatório, enviar e-mails, processar imagens, conciliações).
  • Você precisa de retries, backoff, idempotência e controle de throughput.
  • O tempo de execução é alto ou variável e você quer proteger a latência do tráfego online.

Quando usar Workers (worker_threads) ou processos separados

  • A tarefa é CPU-bound e consome tempo significativo (centenas de ms a segundos) e você quer manter o event loop responsivo.
  • Você precisa paralelizar em múltiplos núcleos dentro do mesmo serviço (workers) ou com isolamento maior (processos).
  • Você observou event loop lag elevado durante picos e correlacionou com trechos CPU-bound.

Quando separar em um serviço dedicado

  • O componente CPU-bound escala de forma diferente do restante (ex.: precisa de máquinas otimizadas para CPU/GPU).
  • Você precisa de isolamento de falhas e deploy independente (um bug no processamento não pode derrubar a API).
  • Há dependências específicas (bibliotecas nativas, runtime diferente) ou requisitos de segurança/limites de recursos.
  • O volume/complexidade exige observabilidade e tuning próprios (fila, concorrência, rate limit, circuit breaker).

Tabela de decisão rápida

CenárioSintomaAbordagem recomendada
Muitas requisições leves com chamadas a DB/cacheCPU baixa, I/O altoAssíncrono no event loop + limites de concorrência
Endpoint faz transformação pesada (CPU)Lag alto, latência sobe para todosWorker/processo para CPU-bound
Tarefa longa não precisa ser síncronaTimeouts, picos de latênciaFila + worker de background
Processamento especializado e escalável separadamenteDeploy/escala conflitam com APIServiço separado
Muitas operações que usam thread poolConclusão em ondas, fila internaAjustar concorrência, considerar pool/arquitetura

Agora responda o exercício sobre o conteúdo:

Ao notar que timers e requisições começam a atrasar (event loop lag alto) durante uma tarefa de processamento pesado, qual abordagem é a mais adequada para manter a API responsiva?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Lag alto indica bloqueio da thread principal por trabalho CPU-bound. Delegar esse processamento a workers ou processos mantém o event loop livre para timers, callbacks e requisições, reduzindo a latência do servidor.

Próximo capitúlo

Node.js Essencial: Event loop, timers e fluxo assíncrono na prática

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
6%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.