Concorrência bem implementada torna sistemas mais rápidos, mais responsivos e mais eficientes. Concorrência mal implementada produz bugs dos mais difíceis de diagnosticar na engenharia de software — bugs que aparecem intermitentemente, que somem quando você adiciona um log para investigá-los, que só se manifestam sob carga específica em produção.
Este artigo cataloga as armadilhas mais comuns da concorrência em Go, mostra como cada uma se manifesta, e apresenta as ferramentas e técnicas para detectá-las e corrigi-las.
Race condition: o bug mais comum
Uma race condition ocorre quando o resultado de um programa depende da ordem de execução de goroutines, e essa ordem não está garantida. O exemplo clássico é o incremento de um contador compartilhado sem sincronização:
package main
import (
"fmt"
"sync"
)
func main() {
contador := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
contador++ // RACE CONDITION — leitura + incremento + escrita não são atômicas
}()
}
wg.Wait()
fmt.Println(contador) // às vezes 1000, às vezes menos — valor imprevisível
}
O que parece uma operação simples — contador++ — é na verdade três operações:
- Ler o valor atual de
contador - Incrementar o valor lido
- Escrever o novo valor
Se duas goroutines executam esse ciclo simultaneamente, ambas podem ler o mesmo valor, incrementar para o mesmo resultado e escrever o mesmo valor — perdendo um incremento.
Correção com Mutex:
type ContadorSeguro struct {
mu sync.Mutex
valor int
}
func (c *ContadorSeguro) Incrementar() {
c.mu.Lock()
defer c.mu.Unlock()
c.valor++
}
func (c *ContadorSeguro) Valor() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.valor
}
Correção com atomic:
Para operações simples em tipos escalares, o pacote sync/atomic oferece operações atômicas sem o overhead de um mutex:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var contador int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&contador, 1) // atômico — seguro sem mutex
}()
}
wg.Wait()
fmt.Println(atomic.LoadInt64(&contador)) // sempre 1000
}
O detector de race conditions
Go inclui um detector de race conditions integrado ao toolchain. Ele instrumenta o binário para registrar todos os acessos à memória e detectar acessos concorrentes não sincronizados:
go test -race ./...
go run -race main.go
go build -race -o app ./...
O detector produz relatórios detalhados quando detecta uma race:
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
main.main.func1()
/home/usuario/projeto/main.go:14 +0x3c
Previous read at 0x00c0000b4010 by goroutine 6:
main.main.func1()
/home/usuario/projeto/main.go:14 +0x2e
Goroutine 7 (running) created at:
main.main()
/home/usuario/projeto/main.go:12 +0x74
==================
O relatório indica exatamente onde a race ocorreu: arquivo, linha, goroutine envolvida e onde a goroutine foi criada. Com essa informação, a correção é direta.
O detector tem custo — programas instrumentados executam de 2 a 20 vezes mais lentamente e usam de 5 a 10 vezes mais memória, segundo a documentação do Go. Use em desenvolvimento e CI, nunca em binários de produção.
Deadlock: travamento mútuo
Um deadlock ocorre quando duas ou mais goroutines esperam indefinidamente umas pelas outras. Go detecta deadlocks totais — quando todas as goroutines estão bloqueadas — e encerra o programa com uma mensagem diagnóstica:
package main
import "sync"
func main() {
var mu1, mu2 sync.Mutex
var pronto sync.WaitGroup
pronto.Add(2)
// Goroutine A: trava mu1 e depois tenta mu2
go func() {
mu1.Lock()
pronto.Done()
pronto.Wait() // espera B já estar segurando mu2
mu2.Lock()
}()
// Goroutine principal (B): trava mu2 e depois tenta mu1
mu2.Lock()
pronto.Done()
pronto.Wait() // espera A já estar segurando mu1
mu1.Lock() // A espera B, B espera A
}
O WaitGroup chamado pronto está ali só para tornar a demonstração determinística: ele garante que cada goroutine já segura o primeiro lock antes de pedir o segundo. Sem ele, o que é exatamente o caso real, o impasse depende do escalonamento — na maioria das execuções uma goroutine pega os dois locks antes da outra começar e tudo termina bem, e é por isso que esse defeito passa nos testes e aparece em produção.
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
...
goroutine 6 [semacquire]:
...
Prevenção: estabeleça uma ordem global de aquisição de locks e sempre adquira-os nessa ordem em todas as goroutines:
// Convenção: sempre travar mu1 antes de mu2
func operacao() {
mu1.Lock()
defer mu1.Unlock()
mu2.Lock()
defer mu2.Unlock()
// seção crítica
}
Livelock: movimento sem progresso
Um livelock é similar a um deadlock, mas as goroutines não estão bloqueadas — estão ativas, reagindo umas às outras, sem progredir. O padrão típico é a cortesia simétrica: duas goroutines que, ao perceberem que a outra também quer o recurso, recuam, esperam o mesmo intervalo fixo e tentam de novo ao mesmo tempo, indefinidamente. O mesmo acontece com serviços que repetem uma operação conflitante, como uma transação que colide e é refeita no mesmo ritmo que a concorrente.
Na prática, dentro de um único processo Go, o livelock puro é raro, porque o próprio escalonador introduz ruído suficiente para quebrar a simetria: numa simulação com dois agentes corteses e espera fixa de 1 ms, rodada no Go 1.27, os dois conseguiram passar já depois do primeiro recuo. Onde ele aparece de verdade é entre processos e máquinas, que não dividem escalonador — clientes que repetem requisições em ondas sincronizadas, nós que disputam uma eleição e recomeçam juntos. Livelocks são difíceis de detectar porque o programa parece estar executando normalmente, e a solução é sempre a mesma: aleatoriedade no tempo de espera — backoff exponencial com jitter —, para que os participantes não voltem a coincidir.
Starvation: inanição de goroutines
Starvation ocorre quando uma goroutine nunca consegue acesso a um recurso compartilhado porque outras goroutines monopolizam o acesso:
package main
import (
"fmt"
"sync"
"time"
)
type RecursoCompartilhado struct {
mu sync.Mutex
}
func trabalhoIntensivo(r *RecursoCompartilhado, nome string, duracao time.Duration) {
for i := 0; i < 5; i++ {
r.mu.Lock()
fmt.Printf("%s: usando recurso (iteração %d)\n", nome, i)
time.Sleep(duracao)
r.mu.Unlock()
// goroutine libera e tenta readquirir imediatamente
// goroutines de baixa prioridade raramente conseguem o lock
}
}
func main() {
r := &RecursoCompartilhado{}
// Goroutines "rápidas" monopolizam o recurso
go trabalhoIntensivo(r, "rápido-1", 10*time.Millisecond)
go trabalhoIntensivo(r, "rápido-2", 10*time.Millisecond)
// Goroutine "lenta" pode ficar em starvation
go trabalhoIntensivo(r, "lento", 50*time.Millisecond)
time.Sleep(2 * time.Second)
}
O exemplo acima, rodado no Go 1.27, não produz inanição: a goroutine lenta usou o recurso 5 vezes, tanto quanto as rápidas. Desde o Go 1.9, o sync.Mutex tem um modo contra inanição: quando uma goroutine espera o lock por mais de 1 ms, ele passa a ser entregue por ordem de chegada. A inanição continua possível em outros lugares — uma fila de trabalho em que itens novos sempre furam a fila, um select que é alimentado muito mais por um canal que pelos outros, uma política de prioridade escrita à mão. Para esses casos, filas com distribuição justa ou o pacote golang.org/x/sync/semaphore, que atende os pedidos em ordem FIFO, são o remédio.
Goroutine leak: o vazamento silencioso
Como discutido no artigo Goroutines: concorrência leve e o modelo de execução do Go, goroutines que nunca terminam são um problema sério. Os casos mais comuns:
Channel sem receptor:
func vazamento1() {
ch := make(chan int)
go func() {
resultado := calcularAlgo()
ch <- resultado // bloqueia para sempre — ninguém lê
}()
// função retorna sem ler ch
}
Goroutine esperando channel que nunca será fechado:
func vazamento2() {
ch := make(chan struct{})
go func() {
<-ch // bloqueia para sempre se ch nunca for fechado
fmt.Println("nunca executado")
}()
// ch nunca é fechado
}
Goroutine em loop infinito sem saída:
func vazamento3(ctx context.Context) {
go func() {
for {
// processamento
time.Sleep(1 * time.Second)
// sem verificação de ctx.Done() — nunca termina
}
}()
}
Detecção com goleak:
package minhasoperacoes_test
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestOperacao(t *testing.T) {
defer goleak.VerifyNone(t)
operacaoQuePodeMinar()
}
Detecção manual com runtime:
func monitorarGoroutines(t *testing.T) func() {
antes := runtime.NumGoroutine()
return func() {
time.Sleep(10 * time.Millisecond) // dá tempo para goroutines terminarem
depois := runtime.NumGoroutine()
if depois > antes {
t.Errorf("possível goroutine leak: antes=%d depois=%d", antes, depois)
}
}
}
func TestSemVazamento(t *testing.T) {
verificar := monitorarGoroutines(t)
defer verificar()
// ... código do teste
}
O problema da variável de loop capturada
Um dos bugs mais clássicos em Go — tão comum que o Go 1.22 mudou o comportamento padrão para corrigi-lo:
// Go 1.21 e anteriores — BUG
func main() {
var wg sync.WaitGroup
valores := []string{"a", "b", "c"}
for _, v := range valores {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(v) // captura v por referência — todas imprimem "c"
}()
}
wg.Wait()
}
// Correção para Go 1.21 e anteriores
for _, v := range valores {
v := v // cria nova variável local a cada iteração
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(v) // captura a cópia local
}()
}
// Go 1.22+ — comportamento correto por padrão
// Cada iteração cria automaticamente uma nova variável v
Uso incorreto de sync/atomic
Operações atômicas garantem atomicidade individual, mas não compõem automaticamente:
var (
leituras int64
escritas int64
)
// ERRADO — as duas operações não são atômicas em conjunto
func estatisticas() (int64, int64) {
l := atomic.LoadInt64(&leituras)
e := atomic.LoadInt64(&escritas)
// outro goroutine pode modificar entre as duas leituras
return l, e
}
// CORRETO — usar o mesmo mutex nas escritas e nas leituras
var mu sync.Mutex
func registrarLeitura() {
mu.Lock()
defer mu.Unlock()
leituras++
}
func estatisticasSeguras() (int64, int64) {
mu.Lock()
defer mu.Unlock()
return leituras, escritas
}
O detalhe decisivo é que todas as escritas passam a usar o mesmo mutex. Travar só a leitura enquanto as escritas continuam com atomic.AddInt64 não dá uma fotografia consistente e ainda cria uma corrida de dados entre a leitura comum e a escrita atômica. Desde o Go 1.19, os tipos atomic.Int64, atomic.Bool e atomic.Pointer[T] tornam o uso atômico mais seguro, porque o valor só pode ser acessado pelos métodos atômicos.
Map concorrente sem proteção
// ERRADO — map não é thread-safe
m := make(map[string]int)
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
// fatal error: concurrent map writes — não é pânico, recover não intercepta
// CORRETO — opção 1: RWMutex
type MapaSeguro struct {
mu sync.RWMutex
dados map[string]int
}
// CORRETO — opção 2: sync.Map para padrões específicos
var m sync.Map
go func() { m.Store("a", 1) }()
go func() { m.Store("b", 2) }()
Checklist de revisão de código concorrente
Ao revisar código que usa goroutines, use esta lista como guia:
□ Toda goroutine tem uma condição clara de encerramento?
□ Channels são fechados apenas pelo produtor?
□ Channels com goroutines potencialmente bloqueadas têm buffer ou cancelamento?
□ Mutexes nunca são copiados após o primeiro uso?
□ Receivers de métodos com mutex são sempre ponteiros?
□ Não há tentativa de adquirir o mesmo lock duas vezes na mesma goroutine?
□ Operações I/O e lentas estão fora de seções críticas (dentro de locks)?
□ Context é propagado para todas as operações que podem bloquear?
□ Variáveis de loop capturadas em closures são copiadas (Go < 1.22)?
□ O código passa em go test -race?
□ Testes verificam ausência de goroutine leaks?
Ferramentas de diagnóstico
# Detector de race conditions
go test -race ./...
go run -race main.go
# Perfil de goroutines em execução
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Stack trace de todas as goroutines (via SIGQUIT) — encerra o processo!
kill -SIGQUIT <pid>
# Análise estática
go vet ./...
staticcheck ./...
# Goleak para testes
go get go.uber.org/goleak
O endpoint de profiling requer adicionar o servidor de debug ao programa:
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Defeitos de concorrência têm uma característica em comum: dependem da ordem em que as goroutines rodam, e essa ordem muda a cada execução. Por isso um teste que passa diz pouco — o exemplo de deadlock deste artigo terminou normalmente cinco vezes seguidas antes de ser reescrito para travar de propósito. O que protege de verdade são regras de desenho, como ordem fixa de locks, uma saída pelo contexto para todo envio e posse clara de cada dado, somadas às ferramentas que observam a execução real: go test -race, o go vet e o perfil de goroutines.
Fontes e leituras recomendadas
-
Go Race Detector — Documentação oficial do detector de race conditions. https://go.dev/doc/articles/race_detector
-
The Go Memory Model — Especificação formal de como acessos à memória funcionam entre goroutines. https://go.dev/ref/mem
-
goleak — Uber — Biblioteca para detecção de goroutine leaks em testes. https://github.com/uber-go/goleak
-
Go by Example: Atomic Counters — Exemplos práticos de sync/atomic. https://gobyexample.com/atomic-counters
-
Concurrency in Go — Katherine Cox-Buday — Capítulo dedicado a armadilhas e antipadrões. https://www.oreilly.com/library/view/concurrency-in-go/9781491941294/
-
staticcheck — Analisador estático que detecta problemas comuns incluindo uso incorreto de sync. https://staticcheck.dev
Exercícios
Exercício 1
A primeira versão do exemplo de deadlock deste artigo, com duas goroutines travando mu1 e mu2 em ordens opostas, terminou normalmente nas cinco vezes em que foi executada. Isso prova que o código estava correto? Por que esse tipo de defeito passa nos testes, e qual regra o elimina de verdade?
Ver resposta
✓ Resposta: Não prova. O impasse só acontece se cada goroutine conseguir o seu primeiro lock antes de a outra pedir o segundo. No exemplo original, a goroutine principal pegava mu2 e mu1 em sequência antes de a outra goroutine sequer começar, e o programa terminava; nas cinco execuções medidas, nenhuma travou. O defeito continuava lá: bastava uma pausa entre os dois Lock, como uma chamada de rede ou uma goroutine a mais disputando CPU, para o impasse aparecer. É por isso que a versão atual do exemplo usa um WaitGroup para forçar a intercalação e travar sempre. Em produção, sob carga, a intercalação ruim acontece sozinha, cedo ou tarde. A regra que elimina o problema é estrutural, e não de teste: uma ordem global de aquisição, em que todo código que precisa de mu1 e mu2 os trava sempre na mesma ordem. Melhor ainda é desenhar para nunca precisar de dois locks ao mesmo tempo, o que costuma ser possível juntando os dados protegidos sob um único mutex ou passando a posse por canal.
Exercício 2
Um laço espera outra goroutine ligar uma flag comum, com for !pronto {}, sem nenhuma sincronização. No teste, o programa imprime "viu a flag" e termina. O revisor bloqueia o PR mesmo assim. Quem tem razão?
Ver resposta
✓ Resposta: O revisor. O programa funcionou no teste, e num experimento com o Go 1.27 ele também terminou, mas go run -race acusou WARNING: DATA RACE na mesma hora. Pelo modelo de memória do Go, uma escrita feita numa goroutine só tem garantia de ser vista por outra se houver uma relação de sincronização entre as duas, como um canal, um mutex ou uma operação atômica. Sem ela, o compilador e o processador podem reordenar ou manter o valor num registrador, e nada impede que uma versão futura do compilador, ou outra arquitetura, faça o laço girar para sempre. "Funciona na minha máquina" é exatamente o tipo de evidência que corridas de dados produzem. A correção depende do que a flag significa: se é um aviso de uma vez, fechar um canal e esperar com <-pronto é o idiomático e não gasta CPU; se é um estado consultado repetidamente, atomic.Bool com Load e Store torna o acesso sincronizado. O laço vazio, além disso, queima um núcleo inteiro enquanto espera.
Exercício 3
Um time adota go test -race ./... no CI e, por meses, nenhuma corrida é reportada. Então aparece em produção um fatal error: concurrent map writes. Como o detector deixou passar, e o que aumenta a chance de ele encontrar o problema antes?
Ver resposta
✓ Resposta: O detector de corridas é dinâmico: ele instrumenta o programa e observa os acessos à memória que de fato acontecem durante a execução. Ele não analisa o código em busca de corridas possíveis. Se nenhum teste exercita o caminho em que duas goroutines escrevem no map ao mesmo tempo, por exemplo porque os testes chamam o handler uma vez de cada vez, o detector nunca vê a corrida e não tem nada a reportar. Três hábitos aumentam a cobertura. Primeiro, testes que de fato criam concorrência: disparar várias goroutines contra o mesmo objeto, com t.Parallel() em subtestes que compartilham estado, ou com um teste de carga curto. Segundo, repetir, com -count, porque a intercalação muda a cada execução. Terceiro, rodar partes do sistema com -race em ambientes de homologação sob tráfego realista, aceitando o custo de 2 a 20 vezes no tempo. Nenhum deles transforma o detector numa prova de ausência de corridas; eles só diminuem a distância entre o que os testes fazem e o que a produção faz.
Exercício 4
Um exemplo clássico de inanição, com duas goroutines "rápidas" e uma "lenta" disputando o mesmo sync.Mutex, é rodado no Go 1.27, e a lenta consegue o recurso tantas vezes quanto as outras. O exemplo estava errado desde o início? Onde a inanição ainda pode acontecer num programa Go?
Ver resposta
✓ Resposta: O exemplo ilustra um risco real, mas que o sync.Mutex atual já mitiga. Na medição com o código do artigo, as três goroutines usaram o recurso 5 vezes cada. Desde o Go 1.9, o mutex tem dois modos. No modo normal, quem acabou de soltar o lock e o pede de novo costuma levá-lo antes de quem estava esperando, o que é bom para a vazão. Quando uma goroutine espera mais de 1 ms, o mutex entra em modo de inanição e passa a entregar o lock por ordem de chegada. O RWMutex também evita que escritores fiquem esperando para sempre: um escritor pendente impede novos leitores de entrar. A inanição continua possível no desenho do programa: numa fila de prioridades em que itens urgentes chegam sem parar e os comuns nunca são atendidos; num select em que um canal está sempre pronto e o código, depois de tratá-lo, volta ao laço antes de dar chance aos outros; num limitador que libera vagas sempre para o mesmo tipo de cliente. Nesses casos, a justiça precisa ser parte do desenho, com filas separadas, cotas ou atendimento em ordem de chegada.
Exercício 5
Um contador de requisições é declarado como var total int64 numa struct e incrementado com atomic.AddInt64(&s.total, 1). Num build para ARM de 32 bits, o programa entra em pânico nessa linha. Por que só nessa arquitetura, e como o tipo atomic.Int64 evita o problema?
Ver resposta
✓ Resposta: As operações atômicas de 64 bits exigem que a variável esteja alinhada em 8 bytes na memória. Em arquiteturas de 64 bits isso acontece naturalmente; em plataformas de 32 bits como ARM, 386 e MIPS de 32 bits, o compilador só garante alinhamento de 4 bytes para campos de struct, e um int64 que caia depois de um campo de 4 bytes fica desalinhado. A documentação do sync/atomic descreve essa limitação na seção de defeitos conhecidos e pede que o chamador garanta o alinhamento, por exemplo pondo o campo no início da struct. O tipo atomic.Int64, do Go 1.19, resolve de outro jeito: ele carrega a exigência de alinhamento no próprio tipo, e o compilador o posiciona corretamente em qualquer plataforma. Além disso, como o valor só é acessível pelos métodos Add, Load, Store e CompareAndSwap, fica impossível misturar, por descuido, uma leitura comum com escritas atômicas, que é o outro defeito apontado no artigo. Em código novo, os tipos atômicos são a escolha padrão.