Limitando a Concorrência em Go: errgroup, Pools de Workers e Backpressure

Limite o trabalho em andamento, não apenas cancele-o.

Conteúdo da página

O fan-out ilimitado é o modo de falha padrão na concorrência em Go: cada declaração go é mais uma tarefa em execução, sem limite e sem responsável. Este artigo trata de fornecer ambos.

Cancelamento e limitação são controles separados. O cancelamento decide quando o trabalho para; a limitação decide quanta parte dele existe ao mesmo tempo. Um serviço pode cancelar perfeitamente e ainda assim falhar porque dez mil goroutines decidiram, independentemente, que agora era um bom momento para chamar o banco de dados.

funil de vidro limitando um fluxo de partículas brilhantes através de uma porta regulada

As páginas ao redor desta cobrem a outra metade do problema. Go context.Context feito da forma certa trata o contexto como o plano de controle para parar o trabalho; aqui o assunto é o plano de capacidade — errgroup.SetLimit, canais semáforo, pools de workers e filas limitadas — além da única forma de vazamento que sobrevive ao errgroup.Wait mesmo quando todos os caminhos de cancelamento parecem configurados.

Cinco primitivos, uma tabela de decisão

Primitivo Resultados parciais Falha rápida Backpressure Trabalho dinâmico Melhor para
sync.WaitGroup + errors.Join sim não não não lotes de espera total onde todos os resultados contam
errgroup.WithContext não sim não não fan-out onde o primeiro erro encerra a requisição
errgroup.SetLimit(n) não sim sim (bloqueia Go) não fan-out de falha rápida limitado
Canal semáforo sim manual sim (bloqueia na aquisição) sim controle de admissão personalizado
Pool de workers sim manual sim (fila enche) sim fluxos contínuos, workers de longa duração

Duas perguntas escolhem a linha. Primeira: quando uma tarefa falha, você ainda quer os resultados das outras? Se sim, a semântica de grupo de falha rápida descarta trabalho de que você precisava. Segunda: o trabalho chega continuamente, ou é um lote fixo que você pode contar antes de lançar? Grupos lidam com lotes; pools e filas lidam com fluxos.

errors.Join é o que torna a linha simples com WaitGroup viável — ele coleta todos os erros de cada worker em vez de manter apenas o primeiro, o que se combina com as regras de tradução de fronteira em Arquitetura de Tratamento de Erros em Go.

O vazamento que sobrevive ao errgroup.Wait

O modo de falha mais conhecido do errgroup não tem nada a ver com limitação. É uma lacuna de cancelamento, e ela se esconde atrás de código que parece correto:

g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // sem buffer

go func() { // produtor — ninguém é dono desta goroutine
    for i := 0; i < 100; i++ {
        results <- i
    }
    close(results)
}()

g.Go(func() error { // consumidor — membro do grupo
    for r := range results {
        if r == 5 {
            return fmt.Errorf("falha ao salvar")
        }
    }
    return nil
})

err := g.Wait() // retorna no erro do consumidor

Quando o consumidor retorna, gctx é cancelado e Wait retorna. O produtor está estacionado em results <- i — e o cancelamento de contexto não desbloqueia um envio em canal. Ninguém é dono daquela goroutine, então ela permanece estacionada pela vida do processo. Execute essa forma em um loop e o vazamento é exatamente linear:

Variante Goroutines vivas após 500 iterações
Envio sem um braço de ctx.Done 501 (+500 vazadas, uma por iteração)
Envio envolto em select com <-gctx.Done() 502 (+1 de base)
// a correção: toda operação em canal dentro de uma tarefa
// cancelável recebe um braço de Done
select {
case results <- i:
case <-gctx.Done():
    return
}

A mesma lacuna existe no lado do recebimento. A regra é mecânica: toda operação em canal dentro de código que pode ser cancelado recebe um braço de <-ctx.Done(), e toda goroutine tem um dono que espera por ela ou a cancela. O goleak no CI pega os casos que a revisão de código perde — ele se encaixa na mesma porta de qualidade automatizada como as ferramentas em Go Linters: Ferramentas Essenciais para Qualidade de Código.

Duas formas relacionadas valem a pena serem nomeadas. Se o produtor é um membro do grupo, o mesmo código produz não um vazamento, mas um deadlock — Wait bloqueia para sempre esperando pelo envio estacionado, o que pelo menos falha de forma barulhenta. E um produtor que envia com select mas recebe em um canal cujo consumidor saiu vaza da mesma forma; os braços de Done pertencem a ambos os lados.

errgroup.SetLimit: fan-out de falha rápida limitado

SetLimit transforma um errgroup em um grupo com controle de admissão. Cada chamada Go além do limite bloqueia até que um slot se liberte:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
    g.Go(func() error {
        return process(ctx, item)
    })
}
err := g.Wait()

Três semânticas importam em produção. Go bloqueia, então o loop acima aplica backpressure ao produtor — isso é geralmente o que você quer, mas significa que a goroutine do chamador pode travar, então um handler de requisição alimentando um lote grande precisa do seu próprio orçamento de timeout ao redor de todo o loop. SetLimit(0) bloqueia toda chamada Go para sempre. E mudar o limite enquanto goroutines estão ativas causa panic — o limite é fixo pela vida do grupo.

SetLimit herda a semântica de grupo: o primeiro erro cancela o contexto e Wait retorna o primeiro erro, descartando resultados de trabalho ainda em execução. É a primitiva certa quando falhar a operação inteira na primeira falha é correto — buscar uma página de dados, chamar um conjunto de verificações de saúde independentes, enviar uma requisição para shards.

Canais semáforo: backpressure como recurso

Um canal com buffer de capacidade n é um semáforo sem dependências:

sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    sem <- struct{}{} // bloqueia quando 8 tarefas estão em execução
    go func() {
        defer wg.Done()
        defer func() { <-sem }()
        _ = process(ctx, item)
    }()
}
wg.Wait()

A aquisição acontece antes da declaração go, então goroutines só são criadas quando um slot existe. O tamanho do buffer é o contrato: é simultaneamente o teto de concorrência e a fila de admissões pendentes.

A primitiva justifica seu lugar quando você precisa de controle de admissão que os grupos não expressam — aquisição consciente de contexto, pools por downstream, ou resultados parciais em falha:

select {
case sem <- struct{}{}: // slot adquirido
case <-ctx.Done():      // chamador desistiu antes de entrar
    return ctx.Err()
}

Medido na mesma carga de trabalho, o canal semáforo e SetLimit(8) terminam dentro de 1,5% um do outro — a escolha entre eles é semântica, não performance.

Pools de workers: para fluxos, não para lotes

Um pool fixo de workers de longa duração separa a admissão (a fila) da execução (os workers):

func pool(ctx context.Context, workers int) chan<- func() {
    jobs := make(chan func())
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case f, ok := <-jobs:
                    if !ok {
                        return
                    }
                    f()
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    return jobs
}

Pools se encaixam em trabalho que chega continuamente — consumidores de fila, loops de polling, workers de requisição — onde um grupo por lote seria criado e destruído sem fim. Desde o Go 1.25, sync.WaitGroup.Go remove o boilerplate do par Add/Done para a forma comum. O contrato de desligamento é a parte que precisa estar certa: fechar jobs termina os workers após a fila drenar; cancelar ctx abandona o trabalho enfileirado. Ambos são legítimos, mas apenas um pode ser o comportamento documentado. O Go 1.27 continua na mesma direção — métodos genéricos, novos modernizadores go fix, e um httptest.NewTestServer em memória — tudo coberto no guia de upgrade do Go 1.27.

Filas limitadas: bloquear ou descartar

Um canal com buffer também é uma fila com um teto. Uma vez que o buffer enche, o produtor bloqueia — e esse momento é a decisão de design. Bloquear propaga a pressão a montante para quem alimenta a fila; descartar reduz a carga ao custo de trabalho perdido. Um pipeline de monitoramento pode descartar; um pipeline de pedidos deve bloquear ou despejar em armazenamento durável.

select {
case queue <- event: // admitido
default:            // fila cheia: a política de descarte vive aqui
    dropped.Add(1)
}

Independentemente da política, faça-a explícita e medida. Um ramo default silencioso é para onde os eventos vão desaparecer.

O que limitação custa — medido

A mesma carga de trabalho, 10.000 tarefas de 5 ms de I/O simulado cada, Go 1.27.1:

Estratégia Pico de goroutines Tempo de execução
go por tarefa ilimitado ~10.000 lançadas 18 ms
errgroup.SetLimit(8) 8 6,9 s
Canal semáforo (cap 8) 8 6,6 s

A execução ilimitada vence em tempo de execução porque nada no experimento empurra de volta — dez mil goroutines de timer simplesmente dormem em paralelo. Essa é exatamente a armadilha. O custo do fan-out ilimitado nunca é CPU no seu próprio processo; são dez mil conexões simultâneas, um downstream que começa a dar timeout sob o burst, e um gráfico de memória que segue a profundidade da fila de quem você está chamando. Em uma dependência real, a execução ilimitada não termina em 18 ms — ela dá timeout. As execuções limitadas pagam 6,6 segundos porque 10.000 tarefas ÷ 8 slots × 5 ms são 6,25 s de serialização inevitável, e o teto é o ponto: ele transforma um burst não controlado em um dreno previsível de 6,25 segundos.

Um refinamento importa quando o teto é maior que um punhado de slots: mantenha o teto no máximo o que o downstream pode sustentar concorrentemente, e derive-o desse limite (tamanho do pool de conexões, limite de taxa, capacidade de worker) em vez de de uma sensação sobre paralelismo “razoável”.

Medindo e testando código limitado

Três sinais cobrem a maior parte disso. Tendências de contagem de goroutines (/sched/goroutines:goroutines exportado via coletor Go do Prometheus) pegam vazamentos como uma inclinação. Latência do agendador (/sched/latencies:seconds) pega saturação — goroutines executáveis esperando por tempo de CPU é pressão real independentemente da contagem. Um delta pprof de dois despejos de goroutines aponta onde as goroutines estacionadas vivem.

Para os testes, workers limitados são o caso para o qual Testando Código Go Concorrente com synctest foi construído: tempo falso faz um dreno completo de 10.000 tarefas rodar em milissegundos, synctest.Wait substitui sleep-and-hope para quiescência, e uma bolha falha rápido quando um worker permanece bloqueado de forma durável — a reprodução do vazamento acima morre em uma bolha de teste em vez de em produção.

Em escala de serviço, a mesma pergunta de limitação reaparece entre serviços, onde o semáforo se torna um limite de taxa e o circuit breaker protege o downstream — Padrão Circuit Breaker em Go cobre essa camada, e Microsserviços Go para Orquestração de IA/ML mostra as formas baseadas em fila que a camada de orquestração assume.

Escolhendo

flowchart TD A[Trabalho a ser executado concorrentemente] --> B[Lote fixo ou fluxo contínuo?] B -- fluxo contínuo --> C[Pool de workers ou fila limitada] B -- lote fixo --> D[Resultados parciais necessários
na primeira falha?] D -- sim --> E[WaitGroup + errors.Join
ou canal semáforo] D -- não --> F[errgroup.WithContext] F --> G[Precisa de um teto de concorrência?] G -- sim --> H[errgroup.SetLimit] G -- não --> I[grupo sem limite
apenas quando o lote é comprovadamente pequeno] C --> J[Fila cheia: bloquear ou descartar?] J -- bloquear --> K[Backpressure a montante] J -- descartar --> L[Reduzir carga, contar os descartes]

A tabela e o fluxograma comprimem-se em um único hábito: antes de escrever go, nomeie o teto e o dono. O teto é SetLimit, um semáforo, ou uma fila limitada; o dono é um Wait, um braço de ctx.Done em toda operação de canal, ou ambos. Todo local de criação de goroutine que possa responder esses dois nomes num relance é um que não fará ninguém acordar às 3 da manhã.

Subscrever

Receba novos artigos sobre sistemas, infraestrutura e engenharia de IA.