Golang

Armadilhas da concorrência: race conditions e como detectá-las Já leu

11 min de leitura

Armadilhas da concorrência: race conditions e como detectá-las
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 int

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) // raramente 1000 — valor imprevisível
}

O que parece uma operação simples — contador++ — é na verdade três operações:

  1. Ler o valor atual de contador
  2. Incrementar o valor lido
  3. 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 entre 5 e 20 vezes mais lentamente e consomem mais memória. 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

    // Goroutine A: trava mu1, depois tenta travar mu2
    go func() {
        mu1.Lock()
        defer mu1.Unlock()
        // ... faz algo
        mu2.Lock() // espera mu2 ser liberado pela goroutine B
        defer mu2.Unlock()
    }()

    // Goroutine B: trava mu2, depois tenta travar mu1
    mu2.Lock()
    defer mu2.Unlock()
    // ... faz algo
    mu1.Lock() // espera mu1 ser liberado pela goroutine A
    defer mu1.Unlock()

    // Deadlock: A espera B, B espera A
}
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, respondendo umas às outras, mas sem progredir:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Agente struct {
    nome   string
    mu     sync.Mutex
    ocupado bool
}

func (a *Agente) tentarTrabalhar(outro *Agente) {
    for {
        a.mu.Lock()
        if a.ocupado {
            a.mu.Unlock()
            fmt.Printf("%s: esperando o outro terminar\n", a.nome)
            time.Sleep(1 * time.Millisecond)
            continue
        }

        outro.mu.Lock()
        if outro.ocupado {
            outro.mu.Unlock()
            a.mu.Unlock()
            fmt.Printf("%s: cortesia — cedendo ao outro\n", a.nome)
            time.Sleep(1 * time.Millisecond)
            continue
        }

        fmt.Printf("%s: trabalhando!\n", a.nome)
        a.mu.Unlock()
        outro.mu.Unlock()
        return
    }
}

func main() {
    agente1 := &Agente{nome: "A"}
    agente2 := &Agente{nome: "B"}

    // Ambos ficam cedendo um ao outro indefinidamente
    var wg sync.WaitGroup
    wg.Add(2)
    go func() { defer wg.Done(); agente1.tentarTrabalhar(agente2) }()
    go func() { defer wg.Done(); agente2.tentarTrabalhar(agente1) }()

    // wg.Wait() — ficaria esperando para sempre
    time.Sleep(100 * time.Millisecond)
    fmt.Println("livelock demonstrado")
}

Livelocks são difíceis de detectar porque o programa parece estar executando normalmente. A solução geralmente envolve aleatoriedade no tempo de espera — backoff exponencial com jitter — para que as goroutines não entrem em ciclos sincronizados.


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)
}

Para mitigar starvation, considere sync.RWMutex para leituras concorrentes, filas de trabalho com distribuição justa ou o pacote golang.org/x/sync/semaphore que implementa filas de espera FIFO.


Goroutine leak: o vazamento silencioso

Como discutido no Artigo 19, 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 mutex para operações compostas
var mu sync.Mutex
func estatisticasSeguras() (int64, int64) {
    mu.Lock()
    defer mu.Unlock()
    return leituras, escritas
}


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 }()
// Panic: concurrent map writes

// 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)
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))
}()

Resumo do que foi coberto

Este artigo catalogou as principais armadilhas da concorrência em Go: race conditions com detecção via -race, deadlocks com estratégia de ordem global de locks, livelocks, starvation, goroutine leaks com detecção via goleak, a variável de loop capturada e sua correção no Go 1.22, composição incorreta de operações atômicas, maps concorrentes sem proteção, e um checklist de revisão. Com este artigo, o Módulo 4 de Concorrência está completo.


Referências e leituras complementares

  • 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

Comentários

Mais em Golang

GORM: ORM para Go, migrações e relacionamentos
GORM: ORM para Go, migrações e relacionamentos

ORM — Object-Relational Mapper — é uma camada que mapeia structs Go para tabe...

O pacote context: propagação de cancelamento e prazos
O pacote context: propagação de cancelamento e prazos

Imagine um servidor HTTP recebendo uma requisição que desencadeia três operaç...

Artigo 43 — Profiling e otimização de performance em Go
Artigo 43 — Profiling e otimização de performance em Go

Artigo 43 — Profiling e otimização de performance em Go Otimizar sem medir é...