O pacote sync da biblioteca padrão de Go fornece primitivas básicas de sincronização para programação concorrente. Enquanto canais (channels) são a ferramenta preferida para comunicação entre goroutines, o pacote sync oferece mecanismos para coordenar acesso a memória compartilhada e sincronizar execução. Nesta aula, exploraremos as principais funcionalidades: WaitGroup, Mutex, RWMutex e Once, entendendo quando cada uma é apropriada e como usá-las corretamente.

Dominar essas ferramentas é essencial para escrever código concorrente eficiente e seguro em Go. Embora o lema do Go seja "não se comunique por compartilhamento de memória; em vez disso, compartilhe memória se comunicando", há situações onde o uso de locks é mais simples e direto. Saber escolher entre channels e primitivas de sincronização é uma habilidade importante.

WaitGroup

sync.WaitGroup é usado para esperar que um conjunto de goroutines termine sua execução. Ele funciona como um contador: cada goroutine incrementa o contador antes de iniciar e decrementa ao finalizar. A chamada Wait() bloqueia até que o contador chegue a zero.

É comum usar Add() para definir o número de goroutines a esperar, e cada goroutine chama Done() (que equivale a Add(-1)) ao finalizar. O padrão típico é:

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        // trabalho da goroutine
    }(i)
}
wg.Wait() // aguarda todas as goroutines

É importante chamar Add() antes de lançar a goroutine para evitar condições de corrida. O WaitGroup não deve ser copiado após o primeiro uso, pois seu estado interno deve ser compartilhado por referência.

Mutex e RWMutex

sync.Mutex é um lock exclusivo: apenas uma goroutine pode manter o lock por vez. É usado para proteger seções críticas de código que acessam dados compartilhados. O padrão é:

var mu sync.Mutex
var counter int

mu.Lock()
counter++
mu.Unlock()

Sempre use defer para garantir que o unlock seja chamado, mesmo em caso de pânico:

mu.Lock()
defer mu.Unlock()
// código crítico

sync.RWMutex é um lock de leitura/escrita que permite múltiplas leituras simultâneas, mas apenas uma escrita exclusiva. É útil quando a carga de trabalho tem muitas leituras e poucas escritas. Métodos:

  • RLock() / RUnlock() para leitores
  • Lock() / Unlock() para escritores
var rw sync.RWMutex
var data map[string]int

// Leitura
rw.RLock()
value := data["chave"]
rw.RUnlock()

// Escrita
rw.Lock()
data["chave"] = 42
rw.Unlock()

O RWMutex pode ser mais eficiente que um Mutex simples quando há muitas leituras, mas cuidado: escritores podem sofrer starvation se houver muitos leitores.

Once

sync.Once garante que uma função seja executada apenas uma vez, mesmo se chamada por múltiplas goroutines. É útil para inicialização singleton ou lazy initialization.

var once sync.Once
var config *Config

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig() // executado apenas uma vez
    })
    return config
}

A função passada para Do não deve depender de valores que possam mudar, e não deve causar deadlocks. Once conta o número de chamadas bem-sucedidas; se a função panic, a execução não é considerada completa, mas o Once ainda a marcará como executada? Na verdade, se a função panic, o Once considera que a execução não foi concluída e pode executar novamente em chamadas futuras? A documentação diz: "If the function panics, the Do method considers it to have returned; future calls of Do will call the function again." Portanto, é seguro usar mesmo com panics.

Quando usar em vez de channels

Go incentiva o uso de channels para comunicação entre goroutines, mas há situações onde as primitivas do pacote sync são mais apropriadas:

  • Proteção de estado compartilhado: Quando múltiplas goroutines acessam uma variável ou estrutura de dados compartilhada, um Mutex ou RWMutex é mais direto do que tentar serializar o acesso via channels.
  • Espera por conclusão: WaitGroup é a ferramenta ideal para esperar um conjunto fixo de goroutines, sem necessidade de canal de sinalização.
  • Inicialização única: Once simplifica padrões de lazy initialization que seriam mais complexos com channels.
  • Performance: Em cenários de alta contenção, locks podem ser mais rápidos que channels, embora channels sejam otimizados.

Regra prática: use channels quando houver comunicação de dados (produtor/consumidor); use primitivas de sincronização quando houver acesso concorrente a estado compartilhado. Muitas vezes, uma combinação de ambos é a melhor solução.

Boas práticas

  • Nunca copie um sync.Mutex, sync.RWMutex ou sync.WaitGroup após o primeiro uso; passe por ponteiro.
  • Use defer para liberar locks, garantindo liberação mesmo em caso de pânico.
  • Mantenha o escopo do lock o menor possível para evitar contenção.
  • Evite locks aninhados que possam causar deadlocks.
  • Considere usar go vet para detectar possíveis problemas com cópias de mutexes.

Exercícios

  1. Escreva um programa que lance 10 goroutines, cada uma incrementando um contador compartilhado 1000 vezes. Use sync.WaitGroup para esperar todas terminarem e sync.Mutex para proteger o contador. Imprima o valor final.

    ✓ Resposta:
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func main() {
        var mu sync.Mutex
        var wg sync.WaitGroup
        counter := 0
    
        for i := 0; i < 10; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                for j := 0; j < 1000; j++ {
                    mu.Lock()
                    counter++
                    mu.Unlock()
                }
            }()
        }
        wg.Wait()
        fmt.Println("Contador final:", counter)
    }
    
  2. Implemente um cache seguro para concorrência usando sync.RWMutex. O cache deve suportar operações de leitura (Get) e escrita (Set). Demonstre com múltiplas goroutines.

    ✓ Resposta:
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    type Cache struct {
        mu   sync.RWMutex
        data map[string]string
    }
    
    func NewCache() *Cache {
        return &Cache{data: make(map[string]string)}
    }
    
    func (c *Cache) Get(key string) (string, bool) {
        c.mu.RLock()
        defer c.mu.RUnlock()
        val, ok := c.data[key]
        return val, ok
    }
    
    func (c *Cache) Set(key, value string) {
        c.mu.Lock()
        defer c.mu.Unlock()
        c.data[key] = value
    }
    
    func main() {
        cache := NewCache()
        var wg sync.WaitGroup
    
        // Escritas
        for i := 0; i < 5; i++ {
            wg.Add(1)
            go func(i int) {
                defer wg.Done()
                cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
            }(i)
        }
    
        // Leituras
        for i := 0; i < 5; i++ {
            wg.Add(1)
            go func(i int) {
                defer wg.Done()
                if val, ok := cache.Get(fmt.Sprintf("key%d", i)); ok {
                    fmt.Printf("key%d: %s\n", i, val)
                }
            }(i)
        }
        wg.Wait()
    }
    
  3. Use sync.Once para implementar uma função que retorna uma configuração carregada apenas uma vez. Simule um carregamento demorado com time.Sleep.

    ✓ Resposta:
    package main
    
    import (
        "fmt"
        "sync"
        "time"
    )
    
    type Config struct {
        Name string
    }
    
    var (
        once   sync.Once
        config *Config
    )
    
    func loadConfig() *Config {
        fmt.Println("Carregando configuração...")
        time.Sleep(2 * time.Second)
        return &Config{Name: "MinhaConfig"}
    }
    
    func GetConfig() *Config {
        once.Do(func() {
            config = loadConfig()
        })
        return config
    }
    
    func main() {
        var wg sync.WaitGroup
        for i := 0; i < 5; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                cfg := GetConfig()
                fmt.Printf("Config: %s\n", cfg.Name)
            }()
        }
        wg.Wait()
    }
    
  4. Explique por que o código abaixo pode causar um deadlock e corrija-o usando sync.Mutex e canais adequadamente.
    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int)
        go func() {
            ch <- 42
        }()
        fmt.Println(<-ch)
    }
    

    ✓ Resposta: O código original não tem deadlock, pois o canal não é bufferizado e a goroutine envia antes da recepção? Na verdade, o envio e recepção são sincronizados, então funciona. Um deadlock ocorreria se a goroutine principal tentasse receber sem uma goroutine enviando. Para exemplificar, considere:
    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int)
        ch <- 42 // deadlock: nenhuma goroutine recebe
        fmt.Println(<-ch)
    }
    
    Correção: usar uma goroutine para enviar ou usar canal bufferizado.
  5. Crie um programa que simule um pool de workers onde cada worker processa um job e reporta o resultado. Use sync.WaitGroup para esperar todos os workers terminarem e um sync.Mutex para coletar resultados em um slice compartilhado.

    ✓ Resposta:
    package main
    
    import (
        "fmt"
        "sync"
    )
    
    func worker(id int, jobs <-chan int, results *[]int, mu *sync.Mutex, wg *sync.WaitGroup) {
        defer wg.Done()
        for job := range jobs {
            result := job * 2 // processa
            mu.Lock()
            *results = append(*results, result)
            mu.Unlock()
            fmt.Printf("Worker %d processou job %d\n", id, job)
        }
    }
    
    func main() {
        const numJobs = 10
        const numWorkers = 3
        jobs := make(chan int, numJobs)
        var results []int
        var mu sync.Mutex
        var wg sync.WaitGroup
    
        for w := 1; w <= numWorkers; w++ {
            wg.Add(1)
            go worker(w, jobs, &results, &mu, &wg)
        }
    
        for j := 1; j <= numJobs; j++ {
            jobs <- j
        }
        close(jobs)
    
        wg.Wait()
        fmt.Println("Resultados:", results)
    }
    

Referências