Entendendo Goroutines e a Necessidade de Sincronização
Go foi projetado com concorrência em mente. As goroutines são funções executadas de forma leve e independente, permitindo que você crie centenas de milhares delas sem sobrecarregar o sistema. Contudo, quando múltiplas goroutines precisam trabalhar juntas ou acessar recursos compartilhados, surge um problema fundamental: como garantir que todas terminem suas tarefas antes de continuar? Como evitar condições de corrida e estados inconsistentes?
É aqui que entra o pacote sync da biblioteca padrão do Go. Dois primitivos se destacam por sua utilidade: sync.WaitGroup para coordenar múltiplas goroutines em paralelo e sync.Once para garantir que uma operação execute exatamente uma vez. Ambos são essenciais para escrever código concorrente robusto e previsível.
sync.WaitGroup: Coordenação de Múltiplas Tarefas
Conceito Fundamental
sync.WaitGroup é um contador que permite que você aguarde a conclusão de um conjunto de goroutines. Funciona como um semáforo simples: você adiciona tarefas ao grupo, marca cada uma como concluída quando termina e bloqueia a execução até que todas terminem. Internamente, mantém um contador: cada chamada a Add() incrementa, cada chamada a Done() decrementa, e Wait() bloqueia até que o contador chegue a zero.
A beleza dessa abordagem é que você não precisa saber antecipadamente quantas goroutines terá — pode adicionar dinamicamente. Além disso, é segura para concorrência: o WaitGroup usa um mutex interno para garantir que o contador seja modificado de forma atômica.
Estrutura e Métodos
O sync.WaitGroup possui três métodos principais:
Add(delta int): Adicionadeltaao contador interno. Você chama isso antes de iniciar uma goroutine.Done(): Decrementa o contador em 1. Equivalente aAdd(-1). Você chama isso quando uma goroutine termina.Wait(): Bloqueia até que o contador chegue a zero. Isso é chamado no código principal para esperar todas as goroutines.
Exemplo Prático: Processamento Paralelo de Dados
package main
import (
"fmt"
"sync"
"time"
)
func processItem(id int, wg *sync.WaitGroup) {
// Garante que Done() será chamado ao final
defer wg.Done()
// Simula algum processamento
fmt.Printf("Iniciando processamento do item %d\n", id)
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Concluído o item %d\n", id)
}
func main() {
var wg sync.WaitGroup
// Processaremos 5 itens em paralelo
for i := 1; i <= 5; i++ {
wg.Add(1) // Adiciona 1 ao contador
go processItem(i, &wg)
}
// Bloqueia até que todas as goroutines terminem
wg.Wait()
fmt.Println("Todos os itens foram processados!")
}
Quando você executa este código, verá que os itens são processados em paralelo. O Wait() no final garante que o programa não saia até que todas as goroutines terminem. Sem ele, o programa encerraria antes das goroutines completarem.
Armadilhas Comuns
A primeira armadilha é chamar Add() dentro de uma goroutine que já foi iniciada — isso cria uma condição de corrida onde Wait() pode retornar antes da adição ser registrada. Sempre chame Add() no código principal antes de iniciar a goroutine.
A segunda é esquecer de chamar Done(). Se você tiver 5 tarefas, chamar Add(5) mas apenas 4 Done(), o programa ficará pendurado indefinidamente esperando algo que nunca chegará. Use defer wg.Done() para garantir que seja chamado mesmo em caso de pânico.
sync.Once: Executando Código Exatamente Uma Vez
Quando Você Precisa de Once
sync.Once resolve um problema diferente: você tem uma operação que deve executar exatamente uma vez, mesmo que múltiplas goroutines tentem iniciá-la simultaneamente. Exemplos clássicos incluem inicializar uma conexão com banco de dados, carregar configurações, criar um logger singleton ou validar licenças. Na maioria desses casos, fazer a operação múltiplas vezes desperdiça recursos ou causa comportamentos inesperados.
O sync.Once usa um padrão interno de "double-checked locking" otimizado, garantindo que mesmo sob contenção alta, a função é executada apenas uma vez. Depois disso, qualquer chamada a Do() retorna imediatamente.
Estrutura e Método
O sync.Once possui um único método:
Do(f func()): Executa a funçãofexatamente uma vez. Se já foi executado, ignora a chamada e retorna.
Exemplo Prático: Inicialização de Recurso Compartilhado
package main
import (
"fmt"
"sync"
)
type Database struct {
connection string
}
var (
dbInstance *Database
once sync.Once
)
func getDatabase() *Database {
once.Do(func() {
fmt.Println("Inicializando banco de dados...")
// Simula uma inicialização cara
dbInstance = &Database{
connection: "postgresql://localhost:5432/mydb",
}
})
return dbInstance
}
func main() {
var wg sync.WaitGroup
// Tenta inicializar o banco de dados de 10 goroutines diferentes
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
db := getDatabase()
fmt.Printf("Goroutine %d obteve conexão: %s\n", id, db.connection)
}(i)
}
wg.Wait()
}
Se você executar este código, verá "Inicializando banco de dados..." aparecer apenas uma vez, mesmo com 10 goroutines tentando acessar simultaneamente. Cada uma recebe a mesma instância de banco de dados.
Padrão Singleton Seguro
Este é um padrão poderoso em Go. Diferente de linguagens como Java ou C#, Go não oferece um singleton nativo na linguagem. Com sync.Once, você implementa isso de forma segura e elegante:
package main
import (
"fmt"
"sync"
)
type Logger struct {
name string
}
var (
loggerInstance *Logger
loggerOnce sync.Once
)
func GetLogger() *Logger {
loggerOnce.Do(func() {
fmt.Println("Logger criado")
loggerInstance = &Logger{name: "GlobalLogger"}
})
return loggerInstance
}
func (l *Logger) Log(message string) {
fmt.Printf("[%s] %s\n", l.name, message)
}
func main() {
// Todas estas chamadas usam a mesma instância
logger1 := GetLogger()
logger2 := GetLogger()
logger1.Log("Primeira mensagem")
logger2.Log("Segunda mensagem")
fmt.Printf("São a mesma instância? %v\n", logger1 == logger2) // true
}
Combinando WaitGroup e Once: Um Caso de Uso Real
Cenário: Sistema de Cache com Inicialização Única
Frequentemente, você precisa combinar ambos. Considere um cenário onde múltiplas goroutines devem enriquecer dados em um cache compartilhado, mas o cache deve ser inicializado apenas uma vez. Aqui está como estruturar isso:
package main
import (
"fmt"
"sync"
"time"
)
type Cache struct {
mu sync.RWMutex
data map[string]string
}
var (
cache *Cache
cacheOnce sync.Once
)
func getCache() *Cache {
cacheOnce.Do(func() {
fmt.Println("Inicializando cache...")
cache = &Cache{
data: make(map[string]string),
}
})
return cache
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *Cache) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
func populateCache(id int, key string, value string, wg *sync.WaitGroup) {
defer wg.Done()
cache := getCache()
time.Sleep(time.Duration(id*100) * time.Millisecond)
cache.Set(key, value)
fmt.Printf("Worker %d adicionou %s=%s\n", id, key, value)
}
func main() {
var wg sync.WaitGroup
// 5 workers adicionam dados ao cache
for i := 1; i <= 5; i++ {
wg.Add(1)
go populateCache(i, fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), &wg)
}
wg.Wait()
// Verifica o cache
c := getCache()
fmt.Println("\nConteúdo final do cache:")
for i := 1; i <= 5; i++ {
key := fmt.Sprintf("key%d", i)
fmt.Printf("%s => %s\n", key, c.Get(key))
}
}
Neste exemplo, o sync.Once garante que o cache seja criado apenas uma vez, mesmo que múltiplas goroutines tentem acessá-lo simultaneamente. O sync.WaitGroup coordena o término de todas as goroutines que preenchem o cache. Finalmente, o sync.RWMutex dentro do cache protege os dados contra condições de corrida durante leitura e escrita.
Conclusão
Você aprendeu que sync.WaitGroup é essencial para coordenar múltiplas goroutines em paralelo, permitindo que o programa principal aguarde a conclusão de todas as tarefas sem desperdício de CPU ou deadlock. Use Add() antes de iniciar, Done() (via defer) quando terminar e Wait() para bloquear até o fim.
Compreendeu também que sync.Once garante que uma operação execute exatamente uma vez, tornando-a ideal para inicialização singleton, configurações e recursos compartilhados caros. Chame Do() quantas vezes quiser — apenas a primeira execução ocorre. Juntas, essas duas primitivas resolvem a maioria dos problemas de sincronização em Go de forma elegante e eficiente.