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
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
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
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
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
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
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
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
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
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()
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
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
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)
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
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
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
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
1go test -race ./...
2go run -race main.goExecute sempre com -race em testes. Essa ferramenta já me salvou de subir bugs graves para produção inúmeras vezes.
Go vet e staticcheck
1go vet ./...
2staticcheck ./...Detectam problemas comuns como loop variables não capturadas.
pprof para detectar goroutine leaks
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:
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:
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:
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
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:
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:
- Análise manual
- Revisão da IA
- Race detector
- 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:
- Nem tudo precisa ser concorrente - código sequencial é mais simples
- Use patterns estabelecidos - worker pools, errgroup
- Context em tudo que faz I/O
- Limite concorrência - nunca crie goroutines ilimitadas
- Feche seus channels com defer
- 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.
