Go Admin

Boas Práticas de sync.WaitGroup e sync.Once em Go: Coordenação de Goroutines para Times Ágeis Já leu

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 da biblioteca padrão do Go. Dois primitivos se destacam por sua utilidade: para coordenar múltiplas goroutines em paralelo e 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 é 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.

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): Adiciona delta ao contador interno. Você chama isso antes de iniciar uma goroutine.
  • Done(): Decrementa o contador em 1. Equivalente a Add(-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ção f exatamente 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.

Referências


Artigos relacionados