sync.WaitGroup e sync.Mutex: coordenando goroutines

sync.WaitGroup e sync.Mutex: coordenando goroutines

WaitGroup e Mutex em Go: Add antes de go e o wg.Go do Go 1.25, Mutex e RWMutex e quando cada um compensa, Once e OnceValues, que guardam até o erro, sync.Map e sync.Pool, a corrida que às vezes dá o resultado certo, cópias de locks que o go vet acusa e o cache stampede.
Golang

• • 17 min de leitura

Channels são elegantes para comunicação entre goroutines, mas nem toda situação de concorrência envolve comunicação. Às vezes o problema é simplesmente: esperar que um grupo de goroutines termine, ou proteger uma estrutura de dados compartilhada de acesso simultâneo.

Para esses casos, o pacote sync oferece primitivas de sincronização clássicas — WaitGroup, Mutex, RWMutex, Once e Map — implementadas de forma eficiente e idiomática no Go. Elas não substituem channels, complementam. A escolha entre channels e sync depende do problema: channels para comunicação e transferência de dados, sync para coordenação de acesso a estado compartilhado.

sync.WaitGroup: aguardando grupos de goroutines

WaitGroup resolve o problema de esperar que N goroutines terminem antes de prosseguir. Três métodos formam sua API completa:

  • Add(n int) — incrementa o contador em n
  • Done() — decrementa o contador em 1
  • Wait() — bloqueia até o contador chegar a zero
package main

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

func processarArquivo(nome string, wg *sync.WaitGroup) {
    defer wg.Done() // garante decremento mesmo se a função entrar em pânico

    fmt.Printf("iniciando: %s\n", nome)
    time.Sleep(100 * time.Millisecond) // simula processamento
    fmt.Printf("concluído: %s\n", nome)
}

func main() {
    arquivos := []string{
        "relatorio_jan.csv",
        "relatorio_fev.csv",
        "relatorio_mar.csv",
        "relatorio_abr.csv",
    }

    var wg sync.WaitGroup

    for _, arquivo := range arquivos {
        wg.Add(1)
        go processarArquivo(arquivo, &wg)
    }

    wg.Wait()
    fmt.Println("todos os arquivos processados")
}

Três regras críticas para usar WaitGroup corretamente:

Sempre passe por ponteiro. WaitGroup não deve ser copiado após o primeiro uso. Passar por valor copia o estado interno e quebra a sincronização.

Chame Add antes de go. Se Add for chamado dentro da goroutine, pode não executar antes do Wait, causando retorno prematuro.

Use defer wg.Done() na primeira linha da goroutine. Isso garante que Done seja chamado mesmo em caso de pânico ou retorno antecipado.

Desde o Go 1.25, o próprio WaitGroup resolve as duas últimas regras: wg.Go(func() { processarArquivo(arquivo) }) faz o Add, dispara a goroutine e chama o Done ao final, sem espaço para esquecer nenhum dos dois ou para chamá-los na ordem errada.

WaitGroup com coleta de resultados

O padrão mais comum combina WaitGroup com um channel para coletar resultados:

package main

import (
    "fmt"
    "sync"
)

type Resultado struct {
    Arquivo string
    Linhas  int
    Erro    error
}

func contarLinhas(arquivo string, wg *sync.WaitGroup, resultados chan<- Resultado) {
    defer wg.Done()

    // Simula contagem
    linhas := len(arquivo) * 10
    resultados <- Resultado{Arquivo: arquivo, Linhas: linhas}
}

func main() {
    arquivos := []string{"dados.csv", "usuarios.csv", "pedidos.csv"}
    resultados := make(chan Resultado, len(arquivos))

    var wg sync.WaitGroup

    for _, arquivo := range arquivos {
        wg.Add(1)
        go contarLinhas(arquivo, &wg, resultados)
    }

    // Fecha o channel quando todas as goroutines terminarem
    go func() {
        wg.Wait()
        close(resultados)
    }()

    total := 0
    for r := range resultados {
        if r.Erro != nil {
            fmt.Printf("erro em %s: %v\n", r.Arquivo, r.Erro)
            continue
        }
        fmt.Printf("%-20s %d linhas\n", r.Arquivo, r.Linhas)
        total += r.Linhas
    }
    fmt.Printf("total: %d linhas\n", total)
}

sync.Mutex: exclusão mútua

Um Mutex (mutual exclusion lock) garante que apenas uma goroutine execute uma seção crítica por vez. Quando uma goroutine chama Lock(), as demais que também chamarem Lock() bloquearão até que Unlock() seja chamado:

package main

import (
    "fmt"
    "sync"
)

type Contador struct {
    mu    sync.Mutex
    valor int
}

func (c *Contador) Incrementar() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.valor++
}

func (c *Contador) Valor() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.valor
}

func main() {
    contador := &Contador{}
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            contador.Incrementar()
        }()
    }

    wg.Wait()
    fmt.Println("valor final:", contador.Valor()) // sempre 1000
}

Sem o mutex, múltiplas goroutines poderiam ler o valor atual, incrementar localmente e escrever de volta simultaneamente — causando perdas de incremento. Com go test -race, esse tipo de problema é detectado automaticamente.

O defer c.mu.Unlock() imediatamente após o Lock() é o padrão idiomático — garante que o lock seja liberado mesmo se a função retornar antecipadamente ou entrar em pânico.

sync.RWMutex: reads concorrentes, writes exclusivos

Quando um recurso é lido com muito mais frequência do que é escrito, RWMutex oferece melhor performance que Mutex. Múltiplas goroutines podem ler simultaneamente — mas um write exclusivo bloqueia todos os readers e writers:

package main

import (
    "fmt"
    "sync"
)

type Cache struct {
    mu    sync.RWMutex
    dados map[string]string
}

func NovoCache() *Cache {
    return &Cache{dados: make(map[string]string)}
}

func (c *Cache) Set(chave, valor string) {
    c.mu.Lock()         // write lock — exclusivo
    defer c.mu.Unlock()
    c.dados[chave] = valor
}

func (c *Cache) Get(chave string) (string, bool) {
    c.mu.RLock()         // read lock — concorrente com outros reads
    defer c.mu.RUnlock()
    valor, existe := c.dados[chave]
    return valor, existe
}

func (c *Cache) Delete(chave string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.dados, chave)
}

func (c *Cache) Tamanho() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.dados)
}

func main() {
    cache := NovoCache()
    var wg sync.WaitGroup

    // 10 writers
    for i := 0; i < 10; i++ {
        wg.Add(1)
        i := i
        go func() {
            defer wg.Done()
            cache.Set(fmt.Sprintf("chave%d", i), fmt.Sprintf("valor%d", i))
        }()
    }

    // 100 readers concorrentes
    for i := 0; i < 100; i++ {
        wg.Add(1)
        i := i
        go func() {
            defer wg.Done()
            cache.Get(fmt.Sprintf("chave%d", i%10))
        }()
    }

    wg.Wait()
    fmt.Println("tamanho do cache:", cache.Tamanho())
}

sync.Once: inicialização garantidamente única

sync.Once garante que uma função seja executada exatamente uma vez, independentemente de quantas goroutines a chamem. É o mecanismo ideal para inicialização lazy e segura:

package main

import (
    "fmt"
    "sync"
)

type ConexaoDB struct {
    host string
}

var (
    instanciaDB *ConexaoDB
    once        sync.Once
)

func obterConexao() *ConexaoDB {
    once.Do(func() {
        fmt.Println("inicializando conexão com o banco...")
        // Operação custosa — executada apenas uma vez
        instanciaDB = &ConexaoDB{host: "localhost:5432"}
        fmt.Println("conexão inicializada")
    })
    return instanciaDB
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            conn := obterConexao()
            fmt.Println("usando conexão:", conn.host)
        }()
    }

    wg.Wait()
    // "inicializando conexão com o banco..." aparece apenas uma vez
}

sync.Once é thread-safe por design. Mesmo que 100 goroutines chamem once.Do simultaneamente, a função passada executa apenas uma vez — as demais aguardam a conclusão antes de prosseguir. Desde o Go 1.21, sync.OnceFunc, sync.OnceValue e sync.OnceValues embrulham o mesmo mecanismo numa função pronta: obterConexao := sync.OnceValue(func() *ConexaoDB { ... }) dispensa as duas variáveis de pacote do exemplo.

sync.Map: map thread-safe

O map nativo do Go não é seguro para uso concorrente. Para casos onde um map precisa ser acessado por múltiplas goroutines, sync.Map oferece uma implementação thread-safe otimizada para dois casos específicos: entradas escritas uma vez e lidas muitas vezes, e goroutines trabalhando em conjuntos de chaves disjuntos.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var m sync.Map

    var wg sync.WaitGroup

    // Escrita concorrente
    for i := 0; i < 5; i++ {
        wg.Add(1)
        i := i
        go func() {
            defer wg.Done()
            m.Store(fmt.Sprintf("usuario_%d", i), i*100)
        }()
    }

    wg.Wait()

    // Leitura
    valor, existe := m.Load("usuario_3")
    if existe {
        fmt.Println("usuario_3:", valor)
    }

    // LoadOrStore — carrega se existir, armazena se não existir
    anterior, carregado := m.LoadOrStore("usuario_5", 500)
    fmt.Printf("carregado=%v valor=%v\n", carregado, anterior)

    // Delete
    m.Delete("usuario_0")

    // Iteração — ordem não garantida
    m.Range(func(chave, valor any) bool {
        fmt.Printf("%v → %v\n", chave, valor)
        return true // retornar false interrompe a iteração
    })
}

Para a maioria dos casos de uso, um map protegido por sync.RWMutex é mais previsível e performático. sync.Map tem casos de uso específicos — não é um substituto geral para maps com mutex.

sync.Pool: reuso de objetos alocados

sync.Pool é um pool de objetos temporários que podem ser reutilizados entre goroutines, reduzindo a pressão sobre o garbage collector:

package main

import (
    "bytes"
    "fmt"
    "sync"
)

var poolBuffers = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func processarRequisicao(dados string) string {
    // Obtém buffer do pool em vez de alocar novo
    buf := poolBuffers.Get().(*bytes.Buffer)
    buf.Reset() // limpa o buffer antes de usar
    defer poolBuffers.Put(buf) // devolve ao pool ao terminar

    buf.WriteString("processado: ")
    buf.WriteString(dados)
    return buf.String()
}

func main() {
    var wg sync.WaitGroup
    resultados := make([]string, 10)

    for i := 0; i < 10; i++ {
        wg.Add(1)
        i := i
        go func() {
            defer wg.Done()
            resultados[i] = processarRequisicao(fmt.Sprintf("dados_%d", i))
        }()
    }

    wg.Wait()
    for _, r := range resultados {
        fmt.Println(r)
    }
}

O pool é ideal para objetos grandes e frequentemente alocados — buffers de bytes, conexões temporárias, estruturas de parsing. Objetos no pool podem ser coletados pelo GC a qualquer momento, então nunca armazene estado importante neles.

Armadilhas comuns com sync

Copiar Mutex. Um Mutex nunca deve ser copiado após o primeiro uso. Sempre use ponteiro para structs que contêm mutex:

// ERRADO — copia o mutex
func (c Contador) Valor() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.valor
}

// CORRETO — receiver por ponteiro
func (c *Contador) Valor() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.valor
}

Deadlock por lock dentro de lock. Tentar adquirir um Mutex que já está locked pela mesma goroutine causa deadlock imediato — Go não suporta locks reentrantes:

func (c *Contador) metodoA() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.metodoB() // DEADLOCK — metodoB tenta lock novamente
}

func (c *Contador) metodoB() {
    c.mu.Lock() // bloqueia para sempre
    defer c.mu.Unlock()
}

Lock por tempo excessivo. Manter um lock enquanto realiza I/O ou operações lentas bloqueia todas as outras goroutines que precisam do mesmo lock. Minimize o código dentro de seções críticas:

// ERRADO — I/O dentro do lock
func (c *Cache) AtualizarComDB(chave string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    dados := consultarBancoDados(chave) // bloqueia todas as outras goroutines
    c.dados[chave] = dados
}

// CORRETO — I/O fora do lock
func (c *Cache) AtualizarComDB(chave string) {
    dados := consultarBancoDados(chave) // sem lock durante I/O

    c.mu.Lock()
    defer c.mu.Unlock()
    c.dados[chave] = dados
}

Exemplo completo: worker pool

Combinando WaitGroup, Mutex e channels em um worker pool — um dos padrões mais usados em Go para processamento paralelo com controle de concorrência:

package main

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

type WorkerPool struct {
    tarefas    chan func()
    wg         sync.WaitGroup
    resultados []string
    mu         sync.Mutex
}

func NovoWorkerPool(workers int) *WorkerPool {
    pool := &WorkerPool{
        tarefas: make(chan func(), 100),
    }

    for i := 0; i < workers; i++ {
        pool.wg.Add(1)
        i := i
        go func() {
            defer pool.wg.Done()
            for tarefa := range pool.tarefas {
                tarefa()
                fmt.Printf("worker %d: tarefa concluída\n", i)
            }
        }()
    }

    return pool
}

func (p *WorkerPool) Enviar(tarefa func()) {
    p.tarefas <- tarefa
}

func (p *WorkerPool) AdicionarResultado(r string) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.resultados = append(p.resultados, r)
}

func (p *WorkerPool) Aguardar() []string {
    close(p.tarefas)
    p.wg.Wait()
    return p.resultados
}

func main() {
    pool := NovoWorkerPool(3)

    for i := 1; i <= 9; i++ {
        i := i
        pool.Enviar(func() {
            time.Sleep(100 * time.Millisecond)
            pool.AdicionarResultado(fmt.Sprintf("item_%d processado", i))
        })
    }

    resultados := pool.Aguardar()
    fmt.Printf("\n%d resultados coletados\n", len(resultados))
}

O pacote sync cobre o que os canais não resolvem bem: esperar um grupo de goroutines e proteger estado compartilhado. Quase todos os defeitos com ele vêm de três descuidos — copiar o que não pode ser copiado, segurar o lock enquanto se espera algo lento e confiar num teste que passou em vez de rodar o detector de corridas. O go vet pega o primeiro, a disciplina de manter a seção crítica curta evita o segundo, e o -race no CI transforma o terceiro de sorte em verificação.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O contador do artigo, sem o mutex, é incrementado por mil goroutines. Um colega roda o programa e obtém 1000, conclui que "nesse caso não precisa de lock" e remove a proteção. O raciocínio procede? Que evidência o convenceria do contrário?

Ver resposta

✓ Resposta: Não procede. n++ é ler, somar e gravar, três passos que outras goroutines podem intercalar, e dois incrementos simultâneos que leem o mesmo valor produzem um só. Se isso acontece ou não numa execução depende do sorteio do scheduler, e é por isso que um resultado correto não prova nada. Rodando a versão sem mutex três vezes seguidas, os resultados foram 994, 1000 e 999: o acerto do colega foi sorte. A evidência que não depende de sorte é o detector de corridas. Com go run -race, o mesmo programa imprime WARNING: DATA RACE junto com as duas pilhas que acessaram a variável, mesmo nas execuções em que o valor final sai certo, porque ele detecta o acesso não sincronizado, e não o resultado errado. Em Go, uma corrida de dados não é só "valor às vezes errado": pelo modelo de memória da linguagem, o comportamento do programa deixa de ser garantido. As saídas corretas são o mutex do artigo ou, para um contador simples, atomic.Int64, do pacote sync/atomic, que faz o incremento inteiro numa operação só.

Exercício 2

Uma função auxiliar recebe wg sync.WaitGroup, por valor, e chama wg.Done() no fim. O programa que a usa fica esperando para sempre no wg.Wait(). O que aconteceu, e qual ferramenta teria apontado o defeito antes da execução?

Ver resposta

✓ Resposta: A função recebeu uma cópia do WaitGroup, com o contador copiado junto. O Done() decrementou o contador da cópia, que é descartada quando a função termina, e o WaitGroup original, onde está o Wait(), continua esperando um decremento que nunca virá. É a primeira regra do artigo, "sempre passe por ponteiro", em forma de defeito. O go vet pega nas duas pontas: trabalho passes lock by value: sync.WaitGroup contains sync.noCopy na declaração e call of trabalho copies lock value na chamada. O campo noCopy existe dentro do WaitGroup justamente para o vet reconhecer essas cópias; ele não ocupa espaço nem muda nada na execução. A mesma verificação vale para Mutex, RWMutex, Once e qualquer struct que os contenha. A correção é o parâmetro *sync.WaitGroup ou, desde o Go 1.25, nem passar o WaitGroup: wg.Go(func() { trabalho() }) deixa o Done a cargo do próprio WaitGroup.

Exercício 3

Uma aplicação inicializa a conexão com o banco usando sync.OnceValues, para abri-la uma vez só. No primeiro acesso, o banco está reiniciando e a conexão falha. Um minuto depois o banco volta, mas a aplicação continua respondendo "banco fora do ar" até ser reiniciada. Por quê? E o que acontece com um sync.Once cuja função entra em pânico?

Ver resposta

✓ Resposta: Porque o Once garante que a função roda uma vez, e ponto: ele não sabe distinguir sucesso de falha. sync.OnceValues guarda o par de retorno da primeira chamada, erro incluído, e o devolve em todas as seguintes. Num teste com uma função que devolvia banco fora do ar, três chamadas receberam o mesmo erro e a função executou uma única vez. Com pânico acontece o mesmo: se a função passada a once.Do entra em pânico, o Once a considera concluída, e a chamada seguinte a Do não executa nada. No teste, a segunda função nunca rodou, e o contador de execuções ficou em 1. O Once é a ferramenta certa para inicializações que não falham, como montar uma tabela ou compilar uma expressão regular fixa. Para recursos que podem falhar e se recuperar, a inicialização precisa de outra forma: um mutex com uma verificação de "já tenho uma conexão válida?", repetindo a tentativa em caso de erro, ou abrir a conexão no início do programa e falhar logo ali, onde o erro é visível.

Exercício 4

Um time troca todos os sync.Mutex do projeto por sync.RWMutex "porque é mais rápido para leitura". Em que situação a troca de fato ajuda, e em que situação ela piora as coisas ou não faz diferença? Use os números de um benchmark de leitura de um map pequeno como referência.

Ver resposta

✓ Resposta: O RWMutex ajuda quando há muitas leituras concorrentes, porque elas não esperam umas pelas outras. Num benchmark de leitura de um map de uma entrada com 8 goroutines em paralelo, o Mutex custou 34 ns por operação e o RWMutex, 18 ns. Com uma só CPU, porém, os dois ficaram em torno de 10 ns: sem concorrência, não há leitores para liberar em paralelo, e o RWMutex só acrescenta a contabilidade de leitores. Ele piora as coisas quando as escritas são frequentes, porque um escritor espera todos os leitores saírem e bloqueia os novos, e quando a seção crítica é tão curta que o custo do próprio lock domina. Há ainda uma armadilha de correção: RLock não pode ser "promovido" a Lock, e chamar Lock enquanto se segura um RLock na mesma goroutine trava para sempre. A regra prática é começar com Mutex, medir com um benchmark paralelo (b.RunParallel e -cpu) e trocar só onde as leituras dominam e o profiler mostra disputa pelo lock.

Exercício 5

Um cache precisa de um método ObterOuCarregar(chave) que, se a chave não existir, consulta o banco e grava o resultado. A primeira versão segura o Lock durante a consulta, como no exemplo "errado" do artigo, e a segunda solta o lock antes. A segunda é mais rápida, mas em momentos de pico o banco recebe dezenas de consultas idênticas pela mesma chave. Explique o fenômeno e a solução.

Ver resposta

✓ Resposta: A segunda versão resolve o problema de desempenho, porque as outras goroutines não ficam paradas enquanto uma consulta o banco, mas cria outro: entre verificar que a chave não existe e gravar o resultado, o lock fica livre, e todas as goroutines que pedirem a mesma chave nesse intervalo também veem o cache vazio e também vão ao banco. Numa chave popular que acabou de expirar, isso vira dezenas de consultas idênticas ao mesmo tempo, o chamado cache stampede. A solução é garantir que só uma goroutine carregue cada chave, enquanto as outras esperam pelo resultado dela, sem segurar o lock do cache inteiro. O pacote golang.org/x/sync/singleflight faz exatamente isso: grupo.Do(chave, func() (any, error) { return consultarBanco(chave) }) executa a função uma vez por chave em andamento e entrega o mesmo resultado a todos que chamaram durante a execução. Combinado com o padrão correto do artigo, sem I/O dentro do lock, ele mantém o cache rápido e poupa o banco no pior momento possível.

Comentários

Mais em Golang

Criando um servidor HTTP com net/http
Criando um servidor HTTP com net/http

Servidor HTTP em Go com net/http: Handler e ServeMux, timeouts, o roteador do…

Middlewares: logging, autenticação e CORS
Middlewares: logging, autenticação e CORS

Middlewares em Go: responseWriter interceptado, logging com slog, JWT com…

Composição vs herança: o jeito Go de reutilizar código
Composição vs herança: o jeito Go de reutilizar código

Composição em vez de herança em Go: embedding de structs e de interfaces…