Golang

sync.WaitGroup e sync.Mutex: coordenando goroutines Já leu

11 min de leitura

sync.WaitGroup e sync.Mutex: coordenando goroutines
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 estrutu

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.


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.


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

Resumo do que foi coberto

Este artigo apresentou as primitivas do pacote sync em profundidade: WaitGroup para aguardar grupos de goroutines, Mutex para exclusão mútua de seções críticas, RWMutex para reads concorrentes com writes exclusivos, Once para inicialização única garantida, Map para maps thread-safe e Pool para reuso de objetos. As armadilhas mais comuns — cópia de mutex, deadlock reentrante e locks longos — foram identificadas com padrões de correção. O próximo artigo apresenta o pacote context em profundidade.


Referências e leituras complementares

  • Documentação do pacote sync — Referência completa de todas as primitivas. https://pkg.go.dev/sync

  • Go by Example: Mutexes — Exemplos práticos de Mutex e RWMutex. https://gobyexample.com/mutexes

  • Go by Example: WaitGroups — Exemplos práticos de WaitGroup. https://gobyexample.com/waitgroups

  • Go Blog: Share Memory by Communicating — Filosofia de concorrência do Go. https://go.dev/blog/codelab-share

  • Go Race Detector — Documentação do detector de race conditions. https://go.dev/doc/articles/race_detector

  • Uber Go Style Guide — Concurrency — Boas práticas de concorrência adotadas na Uber. https://github.com/uber-go/guide/blob/master/style.md#concurrency

Comentários

Mais em Golang

Artigo 29 — Roteamento avançado com Gorilla Mux ou Chi
Artigo 29 — Roteamento avançado com Gorilla Mux ou Chi

Artigo 29 — Roteamento avançado com Gorilla Mux ou Chi Quando a biblioteca pa...

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 42 — Documentação com godoc e boas práticas de comentário
Artigo 42 — Documentação com godoc e boas práticas de comentário

Artigo 42 — Documentação com godoc e boas práticas de comentário Documentação...