E se o Node.js tivesse Workers persistentes?
Tenho estudado bastante o comportamento do Event Loop do Node.js e, principalmente, o que acontece quando colocamos tarefas CPU-bound no meio de uma aplicação que também precisa atender HTTP, WebSocket, banco de dados etc.
Foi desse estudo que nasceu um projeto experimental:
Persistent Worker Runtime for Node.js
https://github.com/FelipeMiiller/persistent-worker-runtime
A ideia é relativamente simples:
O Event Loop coordena. Workers persistentes executam.
O problema
Uma das grandes vantagens do Node.js é o modelo assíncrono baseado no Event Loop.
Para operações de I/O, isso funciona muito bem.
O problema aparece quando colocamos uma operação síncrona e pesada no mesmo thread:
HTTP Request
│
▼
Event Loop
│
├── consulta ao banco
├── chamada HTTP
└── cálculo CPU pesado
│
▼
BLOQUEADO
Imagine, por exemplo:
- processamento de áudio;
- criptografia;
- geração de documentos;
- parsing de AST;
- processamento de imagens;
- cálculos matemáticos;
- embeddings;
- processamento de dados;
- algumas operações relacionadas a IA.
Se o trabalho for suficientemente pesado, o Event Loop fica ocupado executando CPU e deixa de responder rapidamente às outras coisas.
Um health check pode atrasar.
Uma requisição pode esperar.
Uma conexão WebSocket pode sofrer latência.
E aumentar a quantidade de Promise não resolve esse problema.
Então por que não usar worker_threads?
Podemos.
O Node.js já possui node:worker_threads, e essa API resolve a parte fundamental do problema.
Mas existe uma diferença entre:
new Worker(...)
para cada trabalho
e ter um conjunto de workers persistentes:
Node.js
│
Event Loop
│
Task Queue
│
┌──────────┼──────────┐
▼ ▼ ▼
Worker 1 Worker 2 Worker 3
│ │ │
L1 Heap L1 Heap L1 Heap
Em vez de criar e destruir um Worker constantemente, o runtime mantém os workers vivos e distribui as tarefas entre eles.
Foi essa ideia que comecei a explorar.
O que o projeto faz?
O persistent-worker-runtime é uma camada construída sobre node:worker_threads.
Ele atualmente trabalha com alguns conceitos:
- Worker Pool persistente;
- fila de tarefas;
- backpressure;
- concorrência limitada;
- worker affinity;
- estado local persistente no worker;
- retries com backoff;
- supervisor para workers que falham;
AsyncResourcepara propagação de contexto;transferListpara transferência de dados;- execução interativa com
execute(); - execução em background com
dispatch().
O projeto não possui dependências externas e utiliza APIs nativas do Node.js. O pacote está configurado para Node.js >= 22.
execute() vs dispatch()
Uma das decisões que achei mais interessante foi separar dois modelos de execução.
Execução interativa
Quando preciso do resultado:
const result = await runtime.execute({
type: 'heavy_math',
payload: { value: 42 },
fn: (p) => {
return p.value * 2;
},
});
O trabalho vai para um Worker e a aplicação aguarda o resultado.
Execução em background
Quando não preciso esperar pelo processamento:
const task = runtime.dispatch({
type: 'send_email',
payload: {
email: 'felipe@example.com'
},
retries: 3,
fn: async (p) => {
return await mailer.send(p.email);
},
});
A aplicação pode continuar o fluxo enquanto o Worker processa a tarefa.
Isso também abriu espaço para experimentar o padrão Transactional Outbox dentro do processo.
Outbox sem começar com Redis
Um exemplo seria um cadastro:
TRANSACTION
│
┌────────────┴────────────┐
│ │
Criar usuário Criar Outbox
│ │
└────────────┬────────────┘
│
COMMIT
│
▼
Worker Runtime
│
▼
Enviar e-mail
A ideia é separar a confirmação da operação principal do processamento do efeito colateral.
Isso não significa que um worker em memória substitua uma fila durável.
Se o processo morrer, o estado que estiver apenas na memória pode ser perdido.
Por isso o projeto trata a memória do worker e o armazenamento durável como responsabilidades diferentes.
Uma coisa que me chamou bastante atenção: memória L1
Workers do Node.js possuem seus próprios heaps.
Então comecei a pensar:
E se esse estado pudesse continuar aquecido entre as execuções?
Por exemplo:
Worker
└── L1
├── modelo carregado
├── cache
├── AST
└── estruturas pré-processadas
Em vez de:
Task 1 → carregar → executar → destruir
Task 2 → carregar → executar → destruir
Task 3 → carregar → executar → destruir
teríamos:
Worker
│
├── carrega uma vez
│
├── Task 1
├── Task 2
├── Task 3
└── Task N
Nos benchmarks do projeto, um cenário de consultas sobre um dataset mantido na memória apresentou uma diferença significativa entre recriar o estado a cada tarefa e reutilizar o heap aquecido.
O benchmark é de um cenário específico, então não considero o número como uma promessa de performance para aplicações reais. A intenção é demonstrar o efeito de manter estado quente.
E a concorrência?
Outro problema interessante é o clássico:
await Promise.all(
tasks.map(task => process(task))
);
Promise.all() controla a coordenação das Promises, mas não significa que devemos executar trabalho CPU-bound sem limite.
Se tenho quatro núcleos disponíveis e disparo dezenas de tarefas pesadas simultaneamente, posso transformar concorrência em contenção.
Por isso o runtime possui uma forma de executar lotes com concorrência limitada:
const results = await runtime.executeAll([
task1,
task2,
task3,
task4,
]);
A ideia é que a fila e o pool sejam responsáveis por controlar quanto trabalho realmente está sendo executado simultaneamente.
Backpressure
Isso também me levou ao problema de backpressure.
Uma fila sem limite pode virar simplesmente uma forma diferente de consumir memória.
Por isso o runtime possui mecanismos para aguardar espaço na fila e também configurar timeout:
Producer
│
▼
┌──────────────┐
│ TaskQueue │
│ │
│ T1 T2 T3 T4 │
└──────┬───────┘
│
▼
Worker Pool
Se os workers não conseguem acompanhar a velocidade de entrada, o produtor precisa sentir essa pressão.
Caso contrário:
10.000 req/s
│
▼
10.000.000 tarefas na memória
│
▼
💥
E se um Worker morrer?
Outro componente do projeto é um Supervisor.
A ideia é não considerar o Worker como algo eterno.
Um Worker pode falhar.
Então o runtime monitora os workers e pode criar um substituto para preservar a capacidade do pool.
A arquitetura acaba ficando aproximadamente assim:
Application
│
▼
Event Loop
│
▼
Worker Runtime
┌──────┼──────┐
│ │ │
▼ ▼ ▼
Queue Scheduler Supervisor
│
┌───────────┼───────────┐
▼ ▼ ▼
Worker 1 Worker 2 Worker 3
E os dados grandes?
worker_threads também traz uma questão importante: comunicação entre isolates.
Se simplesmente enviarmos grandes objetos entre threads, existe custo de serialização/cópia.
Por isso o projeto também suporta transferList para objetos transferíveis como ArrayBuffer.
Por exemplo:
const buffer = new ArrayBuffer(50 * 1024 * 1024);
await runtime.execute({
type: 'process_audio',
payload: { buffer },
transferList: [buffer],
fn: (p) => {
return new Uint8Array(p.buffer).byteLength;
},
});
A intenção é evitar cópias desnecessárias quando o modelo de dados permitir transferência de ownership.
A arquitetura de memória
Acabei organizando a ideia em três níveis:
Worker Runtime
│
┌──────────┼──────────┐
▼ ▼ ▼
L1 L2 L3
Worker-local Shared Durable
Heap Memory Storage