O pacote sync
Esta aula explora o pacote sync da biblioteca padrão de Go, abordando WaitGroup, Mutex, RWMutex e Once. Aprenda a sincronizar goroutines e gerenciar concorrência de forma eficiente, entendendo quando usar essas primitivas em vez de channels.
O pacote sync da biblioteca padrão de Go fornece primitivas básicas de sincronização, como mutexes e grupos de espera, essenciais para coordenar o acesso concorrente a recursos compartilhados. Embora Go incentive o uso de canais para comunicação entre goroutines, existem situações em que as primitivas de baixo nível do sync são mais adequadas, especialmente para proteger dados compartilhados ou sincronizar tarefas que não envolvem troca de mensagens.
Nesta aula, vamos explorar os principais tipos do pacote: WaitGroup, Mutex, RWMutex e Once. Cada um resolve um problema específico de concorrência. Ao final, discutiremos quando é preferível usar essas ferramentas em vez de canais.
WaitGroup
Um WaitGroup é usado para esperar que um conjunto de goroutines termine sua execução. Ele funciona como um contador: você incrementa o contador para cada goroutine que inicia e decrementa quando ela termina. A goroutine principal chama Wait() para bloquear até que o contador chegue a zero.
Exemplo típico: lançar várias goroutines que realizam trabalho paralelo e precisamos aguardar todas finalizarem antes de prosseguir. É importante que o número de chamadas a Done() corresponda exatamente ao número de chamadas a Add(), sob pena de deadlock ou pânico.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // decrementa o contador ao sair
fmt.Printf("Worker %d iniciando\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d finalizado\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // incrementa para cada goroutine
go worker(i, &wg)
}
wg.Wait() // aguarda todas as goroutines
fmt.Println("Todos os workers finalizaram")
}Mutex e RWMutex
Um Mutex (mutual exclusion) é usado para proteger seções críticas, garantindo que apenas uma goroutine por vez acesse um recurso compartilhado. Ele oferece os métodos Lock() e Unlock(). É fundamental sempre chamar Unlock() mesmo em caso de erro, geralmente com defer.
Já o RWMutex é uma versão mais eficiente quando há muitas leituras e poucas escritas. Ele permite que múltiplas goroutines adquiram um bloqueio de leitura (RLock()) simultaneamente, mas bloqueia todas quando uma escrita (Lock()) está ativa. Isso melhora a performance em cenários de leitura pesada.
package main
import (
"fmt"
"sync"
"time"
)
var (
counter int
mu sync.RWMutex
)
func read(id int, wg *sync.WaitGroup) {
defer wg.Done()
mu.RLock() // bloqueio de leitura
fmt.Printf("Leitor %d: counter = %d\n", id, counter)
time.Sleep(100 * time.Millisecond)
mu.RUnlock()
}
func write(id int, wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock() // bloqueio de escrita
counter++
fmt.Printf("Escritor %d incrementou para %d\n", id, counter)
time.Sleep(200 * time.Millisecond)
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go read(i, &wg)
}
for i := 1; i <= 2; i++ {
wg.Add(1)
go write(i, &wg)
}
wg.Wait()
fmt.Println("Finalizado")
}Once
O tipo sync.Once garante que uma função seja executada apenas uma vez, mesmo se chamada por múltiplas goroutines simultaneamente. É útil para inicialização única de recursos compartilhados, como configurações ou conexões com banco de dados.
Seu uso é simples: crie uma variável sync.Once e chame Do(func()). A função passada será executada exatamente uma vez, independentemente de quantas goroutines chamem Do. Isso evita condições de corrida na inicialização.
package main
import (
"fmt"
"sync"
)
var once sync.Once
var config string
func loadConfig() {
fmt.Println("Carregando configuração...")
config = "configuração carregada"
}
func getConfig() string {
once.Do(loadConfig)
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(getConfig())
}()
}
wg.Wait()
}Quando usar em vez de channels
Em Go, canais são a forma idiomática de comunicação entre goroutines. No entanto, existem situações em que as primitivas do pacote sync são mais apropriadas:
- Proteção de estado compartilhado: Quando múltiplas goroutines acessam e modificam uma variável ou estrutura de dados, um
MutexouRWMutexé mais direto e eficiente do que usar canais para serializar o acesso. - Sincronização simples: Para esperar a conclusão de um grupo de goroutines,
WaitGroupé mais leve e claro do que criar um canal de sinalização. - Inicialização única:
sync.Onceresolve elegantemente o problema de inicialização lazy sem necessidade de canais ou variáveis de condição. - Alta contenção de leitura:
RWMutexpermite leituras concorrentes, enquanto canais forçariam uma serialização desnecessária.
Como regra geral, use canais para comunicação e fluxo de dados; use mutexes para proteger estado. Canais tornam o código mais expressivo quando há troca de mensagens, mas mutexes são mais simples para exclusão mútua.
Boas práticas
- Sempre use
deferpara liberar bloqueios (Unlock,RUnlock,Done). - Evite copiar valores de tipos
sync; passe ponteiros. - Não misture mutexes e canais desnecessariamente; escolha a ferramenta certa.
- Mantenha as seções críticas o menores possíveis para reduzir contenção.
- Use
go vetpara detectar erros comuns de sincronização.
Exercícios
Crie um programa que lance 10 goroutines, cada uma incrementando um contador compartilhado 1000 vezes. Use um WaitGroup para esperar a conclusão e um Mutex para proteger o contador. Imprima o valor final.
✓ Resposta:package main import ( "fmt" "sync" ) func main() { var counter int var mu sync.Mutex var wg sync.WaitGroup 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) // Deve imprimir 10000 }Modifique o programa anterior para usar RWMutex, permitindo leituras concorrentes. Adicione algumas goroutines que leem o contador enquanto as escritas ocorrem.
✓ Resposta:package main import ( "fmt" "sync" ) func main() { var counter int var rw sync.RWMutex var wg sync.WaitGroup // Escritores for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 100; j++ { rw.Lock() counter++ rw.Unlock() } }() } // Leitores for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() for j := 0; j < 100; j++ { rw.RLock() _ = counter rw.RUnlock() } }() } wg.Wait() fmt.Println("Contador final:", counter) }Use sync.Once para garantir que uma função de inicialização (que imprime "Inicializado") seja chamada apenas uma vez, mesmo que 5 goroutines tentem executá-la.
✓ Resposta:package main import ( "fmt" "sync" ) var once sync.Once func initialize() { fmt.Println("Inicializado") } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() once.Do(initialize) }() } wg.Wait() // A saída será apenas uma linha "Inicializado" }Escreva um programa que simule um pool de workers: 3 workers processam tarefas de um canal de strings. Use WaitGroup para aguardar todos os workers terminarem e um Mutex para proteger um contador de tarefas processadas.
✓ Resposta:package main import ( "fmt" "sync" ) func worker(id int, tasks <-chan string, wg *sync.WaitGroup, counter *int, mu *sync.Mutex) { defer wg.Done() for task := range tasks { fmt.Printf("Worker %d processando: %s\n", id, task) mu.Lock() *counter++ mu.Unlock() } } func main() { tasks := make(chan string, 10) var wg sync.WaitGroup var counter int var mu sync.Mutex // Inicia 3 workers for i := 1; i <= 3; i++ { wg.Add(1) go worker(i, tasks, &wg, &counter, &mu) } // Envia tarefas for i := 1; i <= 5; i++ { tasks <- fmt.Sprintf("Tarefa %d", i) } close(tasks) wg.Wait() fmt.Printf("Total de tarefas processadas: %d\n", counter) }Explique em um parágrafo a diferença entre Mutex e RWMutex e dê um exemplo de cenário onde RWMutex é mais eficiente.
✓ Resposta:Mutex permite que apenas uma goroutine por vez acesse a seção crítica, seja para leitura ou escrita. RWMutex permite múltiplas leituras simultâneas, mas exclusão mútua para escritas. Um cenário onde RWMutex é mais eficiente é um cache de configurações que é lido frequentemente por várias goroutines e raramente atualizado. Com RWMutex, as leituras não bloqueiam umas às outras, melhorando a concorrência.