Concorrência em Go: Patterns que funcionam e armadilhas

Data de publicação: 30 de novembro de 2025

Concorrência em Go: Patterns que funcionam e armadilhas

Trabalho com Go há alguns anos, e se tem uma feature que ao mesmo tempo impressiona e assusta é a concorrência. Goroutines são incrivelmente poderosas quando bem usadas. Mas quando mal implementadas, viram um pesadelo de debugging em produção.

Este artigo reúne os patterns que uso no dia a dia e, mais importante, as armadilhas que já me custaram horas de investigação. Tudo aqui vem de código real, não de exemplos de tutorial.

Goroutines são baratas, mas não ilimitadas

Uma das primeiras coisas que você aprende em Go é que goroutines são leves. Você pode criar milhares delas sem problemas. A parte que ninguém enfatiza: isso não significa que deveria.

O erro comum

go
1func processarPedidos(pedidos []Pedido) {
2    for _, pedido := range pedidos {
3        go processar(pedido)
4    }
5}

Esse código parece inocente. Mas imagine receber 50 mil pedidos simultaneamente. Você acabou de criar 50 mil goroutines de uma vez. O consumo de memória dispara, o scheduler fica sobrecarregado, e o servidor pode crashar.

Já vi esse padrão causar incidentes em produção mais de uma vez. A solução não é complicada, mas exige disciplina.

Worker Pool: controle sobre concorrência

go
1func processarPedidosComWorkerPool(pedidos []Pedido, numWorkers int) error {
2    jobs := make(chan Pedido, len(pedidos))
3    results := make(chan error, len(pedidos))
4    
5    // Número fixo de workers
6    for w := 0; w < numWorkers; w++ {
7        go worker(jobs, results)
8    }
9    
10    // Envia os jobs
11    for _, pedido := range pedidos {
12        jobs <- pedido
13    }
14    close(jobs)
15    
16    // Coleta resultados
17    var errs []error
18    for i := 0; i < len(pedidos); i++ {
19        if err := <-results; err != nil {
20            errs = append(errs, err)
21        }
22    }
23    
24    return errors.Join(errs...)
25}
26
27func worker(jobs <-chan Pedido, results chan<- error) {
28    for pedido := range jobs {
29        err := processar(pedido)
30        results <- err
31    }
32}

Esse pattern resolve o problema. Você define exatamente quantos workers quer (geralmente entre 10 e 50, dependendo do caso), e eles processam uma fila de jobs. Controle total, comportamento previsível.

Channels precisam ser fechados

Goroutine leak por channel aberto

go
1func buscarDados() <-chan Data {
2    ch := make(chan Data)
3    go func() {
4        for i := 0; i < 10; i++ {
5            ch <- fetchData(i)
6        }
7        // Esqueci de close(ch)
8    }()
9    return ch
10}

O problema: quem está lendo do outro lado nunca sabe que os dados acabaram. O loop fica bloqueado esperando mais dados que nunca chegam. A goroutine continua rodando indefinidamente. É um leak de memória e recursos.

Sempre feche channels

go
1func buscarDados(ctx context.Context) <-chan Data {
2    ch := make(chan Data)
3    go func() {
4        defer close(ch)
5        
6        for i := 0; i < 10; i++ {
7            select {
8            case <-ctx.Done():
9                return
10            case ch <- fetchData(i):
11            }
12        }
13    }()
14    return ch
15}

O defer close(ch) garante que o channel será fechado independente de como a função terminar. E o select com ctx.Done() permite cancelamento externo.

Context: a ferramenta que demorei para valorizar

Levei tempo para entender a importância do Context. Hoje, qualquer função que faz I/O ou pode demorar recebe um Context como primeiro parâmetro.

Sem Context, sem controle

go
1func processarArquivo(url string) error {
2    resp, err := http.Get(url)
3    if err != nil {
4        return err
5    }
6    defer resp.Body.Close()
7    
8    return processarConteudo(resp.Body)
9}

Já tive um caso onde um arquivo corrompido travou esse código por horas. A goroutine ficou bloqueada até reiniciar o servidor. Não havia forma de cancelar ou adicionar timeout.

Context dá controle

go
1func processarArquivo(ctx context.Context, url string) error {
2    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
3    defer cancel()
4    
5    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
6    if err != nil {
7        return err
8    }
9    
10    client := &http.Client{Timeout: 10 * time.Second}
11    resp, err := client.Do(req)
12    if err != nil {
13        return err
14    }
15    defer resp.Body.Close()
16    
17    return processarConteudoComContext(ctx, resp.Body)
18}

Agora há timeout definido. Se algo demorar demais, o context cancela automaticamente. E o cancelamento se propaga para todas as operações que usam esse context.

Select: orquestrando múltiplos channels

Fan-in: agregando de múltiplas fontes

go
1func buscarDeMultiplasFontes(ctx context.Context) ([]Resultado, error) {
2    c1 := buscarDeBancoDados(ctx)
3    c2 := buscarDeCache(ctx)
4    c3 := buscarDeAPI(ctx)
5    
6    resultados := make([]Resultado, 0)
7    
8    for i := 0; i < 3; i++ {
9        select {
10        case r := <-c1:
11            resultados = append(resultados, r)
12        case r := <-c2:
13            resultados = append(resultados, r)
14        case r := <-c3:
15            resultados = append(resultados, r)
16        case <-ctx.Done():
17            return nil, ctx.Err()
18        }
19    }
20    
21    return resultados, nil
22}

Esse pattern é útil quando você precisa buscar dados de várias fontes em paralelo e agregar os resultados.

Pipeline: processamento em etapas

go
1func pipeline(ctx context.Context, nums []int) <-chan int {
2    // Stage 1: Gerar números
3    gen := func() <-chan int {
4        out := make(chan int)
5        go func() {
6            defer close(out)
7            for _, n := range nums {
8                select {
9                case out <- n:
10                case <-ctx.Done():
11                    return
12                }
13            }
14        }()
15        return out
16    }
17    
18    // Stage 2: Multiplicar por 2
19    mult := func(in <-chan int) <-chan int {
20        out := make(chan int)
21        go func() {
22            defer close(out)
23            for n := range in {
24                select {
25                case out <- n * 2:
26                case <-ctx.Done():
27                    return
28                }
29            }
30        }()
31        return out
32    }
33    
34    // Stage 3: Filtrar múltiplos de 4
35    filter := func(in <-chan int) <-chan int {
36        out := make(chan int)
37        go func() {
38            defer close(out)
39            for n := range in {
40                if n%4 == 0 {
41                    select {
42                    case out <- n:
43                    case <-ctx.Done():
44                        return
45                    }
46                }
47            }
48        }()
49        return out
50    }
51    
52    return filter(mult(gen()))
53}

Pipelines são elegantes quando você precisa processar dados em etapas e quer tudo rodando em paralelo. Mas admito que na maioria dos casos é over-engineering. Use quando realmente fizer sentido.

WaitGroup: a ordem importa

Erro clássico

go
1func processarEmParalelo(items []Item) {
2    var wg sync.WaitGroup
3    
4    for _, item := range items {
5        go func(i Item) {
6            wg.Add(1) // ERRADO
7            defer wg.Done()
8            processar(i)
9        }(item)
10    }
11    
12    wg.Wait()
13}

O problema: o Wait() pode executar antes da goroutine chamar Add(1). Resultado: panic ou término prematuro.

Sempre Add() antes de Go()

go
1func processarEmParalelo(items []Item) {
2    var wg sync.WaitGroup
3    
4    for _, item := range items {
5        wg.Add(1)
6        go func(i Item) {
7            defer wg.Done()
8            processar(i)
9        }(item)
10    }
11    
12    wg.Wait()
13}

A ordem é crítica. Sempre adicione ao contador antes de criar a goroutine.

Errgroup: WaitGroup com tratamento de erros

go
1import "golang.org/x/sync/errgroup"
2
3func processarLoteComErros(ctx context.Context, items []Item) error {
4    g, ctx := errgroup.WithContext(ctx)
5    g.SetLimit(10)
6    
7    for _, item := range items {
8        item := item
9        g.Go(func() error {
10            return processarComContext(ctx, item)
11        })
12    }
13    
14    return g.Wait()
15}

Errgroup é WaitGroup com superpoderes. Gerencia erros automaticamente e cancela tudo no primeiro erro. O SetLimit controla quantas goroutines rodam simultaneamente.

Rate Limiting: respeitando limites de APIs

Com Ticker

go
1func chamarAPIComRateLimit(ctx context.Context, requests []Request) error {
2    limiter := time.NewTicker(100 * time.Millisecond)
3    defer limiter.Stop()
4    
5    for _, req := range requests {
6        select {
7        case <-limiter.C:
8            if err := fazerRequest(ctx, req); err != nil {
9                return err
10            }
11        case <-ctx.Done():
12            return ctx.Err()
13        }
14    }
15    
16    return nil
17}

Com channel (mais flexível)

go
1func criarRateLimiter(requestsPerSecond int) chan struct{} {
2    limiter := make(chan struct{}, requestsPerSecond)
3    
4    go func() {
5        ticker := time.NewTicker(time.Second / time.Duration(requestsPerSecond))
6        defer ticker.Stop()
7        
8        for range ticker.C {
9            select {
10            case limiter <- struct{}{}:
11            default:
12            }
13        }
14    }()
15    
16    return limiter
17}

Rate limiters já me salvaram de estourar quotas de APIs externas várias vezes.

Armadilhas que aparecem em code review

Loop variable não capturada

go
1// BUG - aparece toda semana em code review
2for _, user := range users {
3    go func() {
4        processarUser(user) // user será sempre o último do loop
5    }()
6}
7
8// CORRETO - opção 1
9for _, user := range users {
10    user := user
11    go func() {
12        processarUser(user)
13    }()
14}
15
16// CORRETO - opção 2 (prefiro esta)
17for _, user := range users {
18    go func(u User) {
19        processarUser(u)
20    }(user)
21}

Esse é provavelmente o bug mais comum em código Go concorrente. A variável do loop é reutilizada em cada iteração.

Deadlock com channel sem buffer

go
1// Trava para sempre
2func buscarDados() Data {
3    ch := make(chan Data)
4    ch <- fetchData() // Bloqueia esperando um receiver
5    return <-ch
6}
7
8// Com buffer funciona (mas é desnecessário usar channel aqui)
9func buscarDados() Data {
10    ch := make(chan Data, 1)
11    ch <- fetchData()
12    return <-ch
13}
14
15// Melhor solução: sem channel
16func buscarDados() Data {
17    return fetchData()
18}

Channels sem buffer bloqueiam até ter alguém do outro lado. Se você está sozinho, espera para sempre.

Race condition em append

go
1func processarLote(items []Item) error {
2    var wg sync.WaitGroup
3    errs := make([]error, 0)
4    
5    for _, item := range items {
6        wg.Add(1)
7        go func() {
8            defer wg.Done()
9            if err := processar(item); err != nil {
10                errs = append(errs, err) // Race condition
11            }
12        }()
13    }
14    
15    wg.Wait()
16    return errors.Join(errs...)
17}

Múltiplas goroutines escrevendo no mesmo slice simultaneamente causa race condition. Use um channel para coletar erros de forma segura.

Ferramentas essenciais

Race Detector

bash
1go test -race ./...
2go run -race main.go

Execute sempre com -race em testes. Essa ferramenta já me salvou de subir bugs graves para produção inúmeras vezes.

Go vet e staticcheck

bash
1go vet ./...
2staticcheck ./...

Detectam problemas comuns como loop variables não capturadas.

pprof para detectar goroutine leaks

go
1import _ "net/http/pprof"

Deixo isso rodando em produção. Quando o número de goroutines não para de crescer, você sabe que tem problema.

Cursor com Claude Sonnet 4.5

Recentemente comecei a usar o Cursor com Claude Sonnet 4.5 para revisar código concorrente antes de commitar. A ferramenta se mostrou surpreendentemente eficaz.

Como uso no dia a dia

Seleciono o código no Cursor e peço:

code
1Revise este código Go para race conditions, goroutine leaks e deadlocks. 
2Seja específico sobre os problemas.

A IA identifica bem:

  • Channels não fechados
  • WaitGroup.Add() após Go()
  • Loop variables não capturadas
  • Ausência de context
  • Deadlocks potenciais

Exemplo real

Escrevi este código:

go
1func processarLote(items []Item) error {
2    var wg sync.WaitGroup
3    errs := make([]error, 0)
4    
5    for _, item := range items {
6        wg.Add(1)
7        go func() {
8            defer wg.Done()
9            if err := processar(item); err != nil {
10                errs = append(errs, err)
11            }
12        }()
13    }
14    
15    wg.Wait()
16    return errors.Join(errs...)
17}

O Cursor identificou imediatamente dois problemas: loop variable não capturada e race condition no append. Sugeriu a correção:

go
1func processarLote(items []Item) error {
2    var wg sync.WaitGroup
3    errCh := make(chan error, len(items))
4    
5    for _, item := range items {
6        item := item
7        wg.Add(1)
8        go func() {
9            defer wg.Done()
10            if err := processar(item); err != nil {
11                errCh <- err
12            }
13        }()
14    }
15    
16    wg.Wait()
17    close(errCh)
18    
19    var errs []error
20    for err := range errCh {
21        errs = append(errs, err)
22    }
23    return errors.Join(errs...)
24}

Template de prompt que uso

md
1Analise este código Go e identifique:
2
31. Race conditions
42. Goroutine leaks
53. Deadlocks
64. Falta de context
75. Concorrência sem limite
86. Problemas com channels
97. Loop variables não capturadas
10
11Para cada problema, mostre a linha e sugira correção.
12
13[código aqui]

Arquivo .cursorrules para Go

O Cursor permite criar um arquivo .cursorrules na raiz do projeto com regras específicas. Isso melhora significativamente a qualidade das sugestões.

Meu .cursorrules para projetos Go:

md
1# Go Development Rules
2
3## Concurrency Rules (CRITICAL)
4- ALWAYS use context.Context as first parameter for I/O or long operations
5- NEVER create unbounded goroutines - use worker pools with fixed size
6- ALWAYS close channels with defer close() in the goroutine that writes
7- Call WaitGroup.Add() BEFORE launching goroutines, never inside them
8- Capture loop variables before using in goroutines: item := item
9- Use errgroup instead of WaitGroup when functions return errors
10- Add timeouts to all HTTP calls and database operations
11- Ensure every goroutine has a way to terminate
12
13## Code Style
14- Use short variable names (i, j for indices, err for errors, ctx for context)
15- Prefer table-driven tests
16- Use early returns to reduce nesting
17- Don't use else after return
18- Keep functions small and focused
19
20## Error Handling
21- Always check errors, never use _ to ignore them
22- Wrap errors with context: fmt.Errorf("failed to X: %w", err)
23- Use errors.Is() and errors.As() for error comparison
24- Don't panic in library code, return errors
25
26## Performance
27- Use sync.Pool for frequently allocated objects
28- Prefer value receivers unless modifying the receiver
29- Be careful with defer in loops
30- Use buffered channels when appropriate
31
32## Testing
33- Run tests with -race flag ALWAYS
34- Use t.Parallel() for independent tests
35- Mock external dependencies
36- Use testify/assert for cleaner assertions
37
38## When Suggesting Concurrency
39- Ask if concurrency is needed before adding it
40- Start simple, add concurrency only if clear benefit
41- Explain tradeoffs (complexity vs performance)

Depois de adicionar esse arquivo, as sugestões melhoraram consideravelmente. A IA para de sugerir código que eu rejeitaria em code review.

Limitações importantes

A IA não substitui o race detector. Ela pode perder race conditions sutis.

Às vezes sugere soluções complexas demais. Sempre valido se a sugestão faz sentido.

Minha abordagem:

  1. Análise manual
  2. Revisão da IA
  3. Race detector
  4. Code review do time

Checklist para code review

Quando reviso código com concorrência, verifico:

  • Toda goroutine tem forma de terminar?
  • Todos os channels são fechados?
  • Há limite de concorrência?
  • Context usado em operações de I/O?
  • WaitGroup.Add() antes de Go()?
  • Loop variables capturadas corretamente?
  • Timeouts definidos?
  • Buffer nos channels está correto?

O que aprendi

Concorrência em Go é poderosa mas exige disciplina. Os patterns e armadilhas que mostrei aqui vêm de experiência real - bugs em produção, madrugadas debugando, incidentes que poderiam ter sido evitados.

Regras que sigo:

  1. Nem tudo precisa ser concorrente - código sequencial é mais simples
  2. Use patterns estabelecidos - worker pools, errgroup
  3. Context em tudo que faz I/O
  4. Limite concorrência - nunca crie goroutines ilimitadas
  5. Feche seus channels com defer
  6. Sempre rode testes com -race

Go torna concorrência acessível. Mas acessível não significa que você pode sair fazendo qualquer coisa. Esses patterns funcionam. As armadilhas são reais.


Se você tem experiências com concorrência em Go, seria interessante trocar ideias. Deixe um comentário.

Orlando Cechlar Bitencourt

Orlando Cechlar Bitencourt

Tech Lead | Senior Software Development Analyst

Compartilhe este artigo

Você também pode gostar de:

Feature Flags: Muito além do if/else

24 de dez. de 2025

Feature Flags: Muito além do if/else

5 anos usando Flagr me ensinaram que feature flags não são só sobre código - são sobre poder dormir tranquilo depois de um deploy

Feature FlagsArchitecture+2