Orquestrando Goroutines: Como fazer o código esperar sem travar tudo
O Go nos dá um poder absurdo com as goroutines. O runtime resolve a maior parte da dor de cabeça de concorrência por baixo dos panos, mas essa mesma liberdade frequentemente nos entrega corda suficiente para nos enforcarmos.
Se você trabalha em sistemas sob alta carga, já deve ter esbarrado num problema clássico: você tem uma worker thread rodando em background e as threads de requisição dos usuários. Como você coordena as duas sem criar gargalos?
O Cenário Prático: A API de Pagamentos sob Pressão
Vamos sair da teoria e ir para o dia a dia. Imagine que você mantém uma API de pagamentos que processa transações Pix. Por questões de segurança, você mantém uma blocklist de chaves associadas a fraudes no banco de dados, e um worker atualiza esse cache em memória a cada X segundos.
De repente, a aplicação recebe um webhook avisando que a lista de fraudes está defasada e precisa de uma recarga urgente. Nesse exato milissegundo, uma enxurrada de clientes tenta fazer um Pix.
Nós não queremos que cada requisição faça um fetch direto no PostgreSQL (o famoso "efeito manada"). O que nós realmente queremos é que as requisições dos usuários esperem o nosso worker terminar a atualização.
Como fazemos as threads esperarem de forma elegante? A primeira resposta que vem à cabeça raramente é a melhor.
1. A Força Bruta (Trabalho Duplicado)
A saída mais intuitiva é a menos eficiente: deixar que a própria requisição vá buscar os dados se perceber que o cache está desatualizado.
- O impacto: Se 10.000 pessoas fizerem um pagamento ao mesmo tempo, você terá 10.000 goroutines disputando conexões no banco para atualizar exatamente o mesmo dado.
- O veredito: É a receita perfeita para derreter o seu sistema.
2. O Gargalo Silencioso (Spin-Lock com Atomics)
Aí você decide ser um pouco mais sofisticado. Usa um ponteiro atômico (como atomic.Uint64) para marcar as "versões" do cache. A requisição do cliente fica presa num laço for infinito rodando no vazio, apenas checando se o número da versão mudou.
- O impacto: Funciona, mas você drena ciclos da sua CPU à toa. Spin-locks só servem para pausas de curtíssima duração. Para operações atreladas a banco de dados ou I/O, a latência do endpoint vai flutuar bastante de forma imprevisível.
3. O Truque Sujo (Abusando de ctx.Done)
No ecossistema Go, aprendemos a amar o pacote context. Uma abordagem engenhosa que vejo bastante é o worker criar um contexto isolado e disparar a função cancel() propositalmente assim que terminar, apenas para sinalizar aos clientes que o trabalho acabou.
- O impacto: É inteligente, mas desvirtua o propósito da ferramenta. Quando o listener é acordado pelo sinal, ele não sabe por que o contexto morreu. Foi um timeout? A aplicação está desligando? Ocorreu um erro no banco? Fica impossível tratar os fluxos de erro com clareza.
4. A Primitiva Clássica (sync.Cond)
Se você for vasculhar a biblioteca padrão, vai achar o sync.Cond. Ele foi desenhado exatamente para fazer broadcasts: várias goroutines ficam "dormindo" chamando Wait() até que o worker grite um Broadcast(). Parece perfeito, certo?
- O problema: O
sync.Condnão conversa com contextos. No desenvolvimento de microsserviços modernos, nós precisamos respeitar os limites de tempo. Se você colocar a requisição do usuário bloqueada numWait(), você não consegue usar um blocoselectpara ouvir octx.Done()ao mesmo tempo. Se o cliente sofrer um timeout, a goroutine continuará presa na memória aguardando o sinal, sobrecarregando o Garbage Collector (GC).
5. O Caminho do Sênior: Filas Customizadas com Canais
Depois de muito bater cabeça (e ver código antigo atrapalhando a leitura do time em incidentes de madrugada), percebi que a melhor forma de acoplar o mundo dos workers assíncronos com as requisições síncronas de usuários é criar uma fila de canais.
Em vez de depender de primitivas rígidas, o worker mantém uma lista (geralmente uma lista encadeada simples ou slice) de listeners. Quando a requisição do cliente precisa esperar pelo cache, ela cria seu próprio canal, se adiciona à lista do worker e entra num bloco select.
O trecho de código no lado do handler HTTP fica cirúrgico e fácil de escanear:
// A beleza de usar canais é o casamento perfeito com o Context
select {
case <-ctx.Done():
// Se o usuário abortar ou a requisição der timeout,
// morremos graciosamente e liberamos os recursos.
return ctx.Err()
case <-listenChan:
// O worker sinalizou que a blocklist foi atualizada e fechou o canal!
// Podemos seguir o fluxo feliz.
return processPayment(userData)
}
E o que o worker faz? Quando ele termina o fetch no banco, ele simplesmente itera sobre essa fila de canais aguardando e envia o sinal (ou apenas os fecha).
- Controle total: Podemos criar estruturas ricas para enviar eventos específicos pelo canal (ex:
leSuccess,leError). - O cliente é "bobo": A goroutine da requisição não sabe os detalhes pesados de sincronização; ela apenas aguarda em um canal, deixando o código absurdamente habitável para quem for dar manutenção.
Conclusão: O Preço do Design
Quando eu era mais novo e assumia desafios complexos, frequentemente me orgulhava de entregar rápido demais, tentando resolver todos os gargalos de concorrência simplesmente empilhando blocos de sync.Mutex.
Hoje, olho para essas escolhas e entendo que código de sênior não é o que utiliza a primitiva mais obscura da linguagem para impressionar no PR. É aquele que estrutura a concorrência de uma forma em que qualquer desenvolvedor que entrar hoje consiga entender o fluxo de dados.
A liberdade do Go nos convida a sermos arquitetos. O sync.Cond pode até brilhar em benchmarks acadêmicos isolados, mas na trincheira do dia a dia, o casamento bem desenhado entre Channels e Contexts é a verdadeira aliança que traz paz de espírito para o time.
E na sua equipe? Vocês também já tiveram problemas com goroutines zumbis segurando recursos em processos de background?
