Event loop ou thread por requisição: como escolher
A escolha entre os dois modelos não é de gosto nem de moda. É determinada pelo perfil da sua carga, e tem uma conta que decide.
Existe uma discussão recorrente sobre qual modelo de concorrência é melhor, e ela costuma ser conduzida como preferência estética. Não é. A resposta depende de uma pergunta objetiva: a sua carga é dominada por espera ou por cálculo?
De onde vem o problema
I/O bloqueante é o modelo simples: você chama read, e a thread para até o dado chegar. Fácil de escrever, fácil de depurar, a pilha de erro faz sentido.
O custo é que cada conexão precisa de uma thread parada esperando.
Com cem conexões, tudo bem. Com dez mil, você tem dez mil threads, cada uma com sua pilha reservada, e o escalonador do sistema operacional gastando tempo significativo só trocando de contexto.
Esse é literalmente o problema que ficou conhecido como C10K, no começo dos anos 2000: como atender dez mil conexões simultâneas numa máquina só.
A solução foi inverter a lógica. Em vez de uma thread por conexão perguntando "já chegou?", você registra todas as conexões num mecanismo do kernel (epoll no Linux, kqueue no BSD e no macOS) e faz uma pergunta única: "quais dessas dez mil estão prontas agora?".
O kernel devolve a lista. Uma thread atende milhares de conexões, sem ninguém parado.
Isso é o que está por baixo do event loop do Node, do asyncio do Python, do Netty no Java, do runtime do Go. Cada um embrulha de um jeito diferente; por baixo é a mesma ideia.
Thread por requisição: o que você ganha e perde
Ganha: código linear. Você lê de cima para baixo. O depurador funciona. A pilha de exceção mostra o caminho real. Bibliotecas bloqueantes funcionam sem adaptação.
Perde: escala limitada pelo número de threads.
E existe uma conta que dá esse número, a Lei de Little:
threads ocupadas = vazão × latênciaMil requisições por segundo com cem milissegundos cada = cem threads ocupadas o tempo todo. Tranquilo.
Se a latência subir para um segundo (porque uma dependência degradou), precisa de mil. E aí começa a doer: memória, troca de contexto, e provavelmente o pool esgota antes disso.
Note o que essa conta revela: você não precisa de mais tráfego para esgotar o pool. Basta a latência subir. É por isso que uma dependência lenta derruba um serviço cujo tráfego não mudou.
Event loop: o que você ganha e perde
Ganha: escala lindamente para carga de I/O. Milhares de conexões simultâneas com uma ou poucas threads, memória baixa, sem custo de troca de contexto.
Perde: um modo de falha específico e brutal.
Se você fizer qualquer coisa demorada de CPU dentro do laço, você trava tudo, para todos os clientes. Não é uma requisição lenta: é o servidor inteiro parado.
Isso se chama event-loop lag, e em Node uma função de hash ou uma serialização pesada mal colocada derruba a latência do serviço inteiro. É a métrica que você precisa monitorar nessas plataformas, e quase ninguém monitora.
E o código é mais difícil: callbacks aninhados, async/await por toda parte, pilha de erro que não conta a história, e a necessidade de que toda a cadeia de bibliotecas seja não bloqueante. Uma única chamada bloqueante escondida numa dependência anula o modelo.
Thread por requisição
- Código linear: lê de cima para baixo
- Depurador funciona, pilha de erro faz sentido
- Biblioteca bloqueante funciona sem adaptação
- Escala limitada pelo número de threads
- Latência que sobe esgota o pool sem tráfego novo
Event loop
- Milhares de conexões com poucas threads
- Memória baixa, sem custo de troca de contexto
- Cálculo pesado no laço trava o servidor inteiro
- Toda a cadeia de bibliotecas precisa ser não bloqueante
- Pilha de erro que não conta a história
A regra de decisão
Carga dominada por espera (chamar banco, chamar API, ler arquivo, servir websocket): event loop ou threads leves ganham fácil. A CPU fica ociosa de qualquer jeito; o que você quer é não ter ninguém parado.
Carga dominada por cálculo (processar imagem, comprimir, criptografar, compilar, treinar): você precisa de paralelismo de verdade. Mais núcleos e um pool de threads ou processos. Event loop aqui é contraproducente.
Carga mista, que é o caso comum: event loop para o I/O, com um pool separado para as tarefas de CPU. É exatamente o que os worker threads do Node e o run_in_executor do Python existem para fazer.
O que mudou nos últimos anos
A discussão perdeu boa parte da relevância por causa das threads leves.
Goroutines (Go) e threads virtuais (Java 21+) dão a ergonomia do código linear com o custo do event loop. O runtime multiplexa milhares de threads leves sobre poucas threads do sistema operacional, e quando uma bloqueia em I/O, o runtime troca por outra automaticamente.
Você escreve código bloqueante simples e recebe escala de event loop.
Se você está começando um projeto hoje numa dessas plataformas, essa é a escolha padrão, e a discussão acabou.
Uma ressalva: threads leves não resolvem carga de CPU. Se o seu trabalho é cálculo, você continua limitado pelo número de núcleos, e continua precisando pensar em paralelismo de verdade.
O que medir para decidir
Se você está em dúvida sobre o seu próprio sistema, três números resolvem:
1. Utilização de CPU durante o pico. Se está baixa e o serviço está lento, você é limitado por espera, e event loop ou threads leves ajudam.
2. Threads ocupadas versus tamanho do pool. Calcule pela Lei de Little e compare com o configurado. Se o pool vive esgotado, você tem o problema clássico.
3. Tempo gasto em espera versus em cálculo. Um perfil resolve. Se noventa por cento é espera, a resposta é clara.
O erro que atravessa os dois modelos
Independentemente do modelo escolhido, existe um erro que aparece em ambos: não limitar a concorrência com o mundo externo.
Event loop que dispara dez mil requisições simultâneas para uma API que aguenta cem vai derrubar a API, e depois a si mesmo, quando as dez mil respostas de erro chegarem juntas.
O modelo te dá capacidade de concorrência. Ele não te dá permissão para usá-la toda. Semáforo, bulkhead e limite explícito continuam sendo necessários.
Leia depois disto
- EngenhariaPasso 16Diagnosticar rede em dez minutosUm roteiro de seis comandos que transforma "deve ser a rede" em "é a camada X, no trecho Y".Ler artigo
- EngenhariaPasso 15Por que o seu serviço piora com o tempoO serviço sobe bem e piora ao longo de dias. Três causas explicam quase todos os casos, e as três têm sinais distintos.Ler artigo
- EngenhariaPasso 13Concorrência: race condition, lock e deadlockOs bugs que não acontecem na sua máquina, não acontecem no teste, e acontecem em produção. Como reconhecê-los lendo código.Ler artigo