Pular para o conteúdo
Voltar para o acervo
Engenharia5 min de leituraPasso 14 de 22

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ência

Mil 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 pergunta não é qual modelo é melhor, e sim se a sua carga espera ou calcula.

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

Fala comigo

Dúvida sobre o artigo? Me chama no WhatsApp

Sem formulário e sem lista de e-mail. Se você discorda de alguma coisa que eu escrevi, ou quer contar como resolveu aí, a conversa é direta comigo.

Abrir conversa