Goroutines vs Event Loop: a comparação errada entre dois modelos de concorrência

Node.js com Event Loop e Go com goroutines não resolvem o mesmo problema do mesmo jeito. O erro comum é confundir concorrência com paralelismo.

A tese é simples: Node.js com Event Loop e Go com goroutines não resolvem o mesmo tipo de problema do mesmo jeito. A comparação fica ruim quando a gente trata os dois como concorrentes diretos em qualquer cenário. Na prática, o erro mais comum é confundir concorrência com paralelismo.

Concorrência é organizar várias tarefas que podem estar em andamento ao mesmo tempo. Paralelismo é executar trabalho de fato ao mesmo tempo, usando múltiplos núcleos de CPU. Essa diferença parece acadêmica até aparecer em produção.

O Event Loop do Node.js é muito bom quando o gargalo está em espera: API externa, banco de dados, WebSocket, input de usuário, filas e eventos. Enquanto uma operação aguarda resposta, o loop continua atendendo outras tarefas. É o dono da bodega no balcão: ele não para porque pediu a alguém para buscar a rapadura no estoque.

Goroutines, por outro lado, começam a ficar mais interessantes quando o trabalho é CPU bound, divisível e pode aproveitar múltiplos núcleos com controle explícito de concorrência. Elas são unidades leves de execução gerenciadas pelo runtime do Go. Com elas, dá para quebrar uma tarefa em partes menores, distribuir a execução e sincronizar o resultado no final.

É mais parecido com uma barraca cheia no São João de Caruaru: uma pessoa assa o milho, outra mexe a canjica, outra corta o bolo de rolo. O trabalho avança ao mesmo tempo, com cada pessoa cuidando de uma parte.

O ponto onde o Node.js começa a sofrer

Eu vi isso de forma bem concreta em um processo de cálculo de comissão em uma casa de apostas. A aplicação lidava com milhões de apostas por dia, e parte do fluxo envolvia calcular comissão sobre vários lotes de apostas.

No começo, o processo em Node.js funcionava. Sequencialmente era correto, mas lento. Quando tentei paralelizar com a lógica comum de várias tarefas ao mesmo tempo, o limite apareceu: o gargalo era CPU.

Não era só esperar banco, API ou evento externo. Era cálculo em cima de um buffer grande de apostas.

Esse é o tipo de cenário em que Promise.all pode enganar. Ele passa a sensação de paralelismo, mas não transforma automaticamente trabalho pesado de CPU em execução paralela real. Se as tarefas são computação intensa e rodam no mesmo thread principal, o Event Loop fica ocupado.

O resultado pode ser pior do que o esperado: bloqueio do loop, aumento de latência, pior responsividade e maior pressão sobre CPU e memória.

O problema não era Node.js ser ruim. O problema era usar o modelo padrão do Node para uma carga que exigia outro tipo de execução.

Onde Go entrou melhor

A solução foi reescrever esse processo em Go usando goroutines. A ideia era dividir o cálculo em chunks menores, processar esses pedaços em paralelo e sincronizar apenas no final.

Esse desenho encaixava melhor no problema porque o trabalho era CPU bound e podia ser dividido. Em vez de um fluxo centralizado tentando coordenar várias operações pesadas, o processamento passou a ser distribuído em unidades menores de execução.

Com um worker pool, por exemplo, dá para controlar o número de goroutines, limitar o fan-out, usar melhor os cores disponíveis e evitar que o sistema dispare trabalho sem limite.

O ganho apareceu. O tempo total caiu cerca de 25%. O processo que ficava na casa dos 30 segundos passou a rodar em algo próximo de 22,5 segundos. Também houve melhor uso de CPU.

Mas a parte importante da história não é “Go resolveu”. A parte importante é que Go resolveu um lado do problema e revelou outro.

O gargalo pode mudar de lugar

A primeira dificuldade foi garantir que o resultado em Go fosse igual ao resultado em Node.js. Isso é menos glamouroso do que falar sobre concorrência, mas é o que separa otimização real de regressão mascarada.

Se o cálculo fica mais rápido e muda o resultado financeiro, a melhoria não vale nada.

Depois disso, o problema principal virou a divisão dos chunks. A estratégia inicial consumia memória demais. Em testes locais, com datasets menores, o crescimento proporcional chegou perto de 15% em alguns momentos.

O incômodo vinha de uma expectativa errada: eu achei que mudar para Go automaticamente resolveria o problema. Na prática, eu só tinha movido o gargalo de lugar. Antes o limite estava mais claro na CPU. Depois, a estratégia de particionamento começou a pressionar memória.

Isso pode acontecer por vários motivos: cópias desnecessárias, buffers grandes, slices mantendo referência para arrays maiores, filas internas grandes demais ou excesso de trabalho sendo preparado antes de ser processado.

No resultado final, a memória ainda cresceu cerca de 5%. Nesse caso, o ganho de tempo e CPU compensou a perda. Mas isso não é uma regra universal. Se a carga de produção fosse muito maior, ou se o serviço estivesse rodando com margem pequena de memória, essa troca poderia deixar de ser aceitável.

Paralelismo custa coordenação, alocação, sincronização e observabilidade. Não existe execução paralela grátis.

Quando eu manteria Node.js

Eu manteria Node.js sem incômodo para orquestração de I/O: comunicação por WebSocket, chamadas para várias APIs, consultas em banco, disparo de eventos, integração entre serviços e fluxos onde o tempo morto está na espera.

Nesses casos, o Event Loop é uma excelente escolha. Ele permite alto volume de operações concorrentes sem criar uma thread por requisição. Para aplicações orientadas a evento, isso é simples, produtivo e fácil de encaixar no ecossistema JavaScript.

O erro é tentar empurrar esse mesmo modelo para um cálculo pesado e achar que concorrência de I/O vira paralelismo de CPU. Não vira.

Quando eu olharia para Go

Eu começaria a olhar para Go quando a tarefa fosse claramente CPU bound: cálculo em alto volume de dados, processamento de imagem em lote, agregações pesadas, compressão, transformação grande de buffers, simulações ou qualquer rotina em que a máquina passa mais tempo calculando do que esperando resposta externa.

Nesse tipo de cenário, goroutines com worker pool dão um controle melhor sobre uso de CPU. Também deixam mais explícita a separação entre unidades de trabalho, sincronização e coleta de resultado.

Mas Go também cobra preço. É preciso pensar em granularidade dos chunks, consumo de memória, cancelamento, tratamento de erro, backpressure, limites de workers, contenção e consistência do resultado.

Se a divisão do trabalho for ingênua, o ganho de CPU pode vir acompanhado de estouro de memória ou complexidade desnecessária.

Contraargumento: Node.js também tem worker threads

Existe um contraargumento justo: Node.js não está limitado ao Event Loop para tudo. Worker threads existem justamente para executar trabalho pesado fora do thread principal. Também há estratégias com filas, processos separados, serviços auxiliares e native addons.

Então a comparação honesta não é “Node.js não consegue”. Consegue.

A questão é custo de implementação, maturidade da equipe, observabilidade, integração com o sistema existente e quanto esforço vale a pena investir para manter aquele processamento dentro do ecossistema Node.

Em alguns times, usar worker threads pode ser suficiente e mais barato do que introduzir Go. Em outros, separar o processamento CPU bound em um serviço Go pode ser mais simples de operar e escalar.

A decisão não deveria nascer de preferência por linguagem. Deveria nascer da natureza da carga.

A regra prática que ficou

Depois desse caso, a regra que ficou para mim é esta: se o problema é esperar muita coisa ao mesmo tempo, Node.js com Event Loop tende a orquestrar muito bem. Se o problema é calcular muita coisa ao mesmo tempo, eu considero Go com goroutines mais cedo.

Mas eu também passei a desconfiar de migração que promete resolver tudo. Trocar tecnologia pode só deslocar o gargalo.

No meu caso, melhorou CPU e tempo, mas obrigou a reanalisar memória e chunking.

No fim, a briga entre goroutines e Event Loop é menos sobre qual modelo é superior e mais sobre qual gargalo você está tentando atacar.

Para I/O, o balcão da bodega funciona muito bem. Para CPU, às vezes você precisa mesmo colocar mais gente na cozinha da festa.


Publicado originalmente no LinkedIn em 24 de junho de 2026.