Go

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

11 min de leitura

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

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.

<h2>Entendendo Goroutines e a Necessidade de Sincronização</h2>

<p>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?</p>

<p>É aqui que entra o pacote <code>sync</code> da biblioteca padrão do Go. Dois primitivos se destacam por sua utilidade: <code>sync.WaitGroup</code> para coordenar múltiplas goroutines em paralelo e <code>sync.Once</code> para garantir que uma operação execute exatamente uma vez. Ambos são essenciais para escrever código concorrente robusto e previsível.</p>

<h2>sync.WaitGroup: Coordenação de Múltiplas Tarefas</h2>

<h3>Conceito Fundamental</h3>

<p><code>sync.WaitGroup</code> é 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 <code>Add()</code> incrementa, cada chamada a <code>Done()</code> decrementa, e <code>Wait()</code> bloqueia até que o contador chegue a zero.</p>

<p>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.</p>

<h3>Estrutura e Métodos</h3>

<p>O <code>sync.WaitGroup</code> possui três métodos principais:</p>

<ul>

<li><strong><code>Add(delta int)</code></strong>: Adiciona <code>delta</code> ao contador interno. Você chama isso antes de iniciar uma goroutine.</li>

<li><strong><code>Done()</code></strong>: Decrementa o contador em 1. Equivalente a <code>Add(-1)</code>. Você chama isso quando uma goroutine termina.</li>

<li><strong><code>Wait()</code></strong>: Bloqueia até que o contador chegue a zero. Isso é chamado no código principal para esperar todas as goroutines.</li>

</ul>

<h3>Exemplo Prático: Processamento Paralelo de Dados</h3>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;sync&quot;

&quot;time&quot;

)

func processItem(id int, wg *sync.WaitGroup) {

// Garante que Done() será chamado ao final

defer wg.Done()

// Simula algum processamento

fmt.Printf(&quot;Iniciando processamento do item %d\n&quot;, id)

time.Sleep(time.Duration(id) * time.Second)

fmt.Printf(&quot;Concluído o item %d\n&quot;, id)

}

func main() {

var wg sync.WaitGroup

// Processaremos 5 itens em paralelo

for i := 1; i &lt;= 5; i++ {

wg.Add(1) // Adiciona 1 ao contador

go processItem(i, &amp;wg)

}

// Bloqueia até que todas as goroutines terminem

wg.Wait()

fmt.Println(&quot;Todos os itens foram processados!&quot;)

}</code></pre>

<p>Quando você executa este código, verá que os itens são processados em paralelo. O <code>Wait()</code> no final garante que o programa não saia até que todas as goroutines terminem. Sem ele, o programa encerraria antes das goroutines completarem.</p>

<h3>Armadilhas Comuns</h3>

<p>A primeira armadilha é chamar <code>Add()</code> dentro de uma goroutine que já foi iniciada — isso cria uma condição de corrida onde <code>Wait()</code> pode retornar antes da adição ser registrada. Sempre chame <code>Add()</code> no código principal antes de iniciar a goroutine.</p>

<p>A segunda é esquecer de chamar <code>Done()</code>. Se você tiver 5 tarefas, chamar <code>Add(5)</code> mas apenas 4 <code>Done()</code>, o programa ficará pendurado indefinidamente esperando algo que nunca chegará. Use <code>defer wg.Done()</code> para garantir que seja chamado mesmo em caso de pânico.</p>

<h2>sync.Once: Executando Código Exatamente Uma Vez</h2>

<h3>Quando Você Precisa de Once</h3>

<p><code>sync.Once</code> 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.</p>

<p>O <code>sync.Once</code> usa um padrão interno de &quot;double-checked locking&quot; otimizado, garantindo que mesmo sob contenção alta, a função é executada apenas uma vez. Depois disso, qualquer chamada a <code>Do()</code> retorna imediatamente.</p>

<h3>Estrutura e Método</h3>

<p>O <code>sync.Once</code> possui um único método:</p>

<ul>

<li><strong><code>Do(f func())</code></strong>: Executa a função <code>f</code> exatamente uma vez. Se já foi executado, ignora a chamada e retorna.</li>

</ul>

<h3>Exemplo Prático: Inicialização de Recurso Compartilhado</h3>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;sync&quot;

)

type Database struct {

connection string

}

var (

dbInstance *Database

once sync.Once

)

func getDatabase() *Database {

once.Do(func() {

fmt.Println(&quot;Inicializando banco de dados...&quot;)

// Simula uma inicialização cara

dbInstance = &amp;Database{

connection: &quot;postgresql://localhost:5432/mydb&quot;,

}

})

return dbInstance

}

func main() {

var wg sync.WaitGroup

// Tenta inicializar o banco de dados de 10 goroutines diferentes

for i := 1; i &lt;= 10; i++ {

wg.Add(1)

go func(id int) {

defer wg.Done()

db := getDatabase()

fmt.Printf(&quot;Goroutine %d obteve conexão: %s\n&quot;, id, db.connection)

}(i)

}

wg.Wait()

}</code></pre>

<p>Se você executar este código, verá &quot;Inicializando banco de dados...&quot; aparecer apenas uma vez, mesmo com 10 goroutines tentando acessar simultaneamente. Cada uma recebe a mesma instância de banco de dados.</p>

<h3>Padrão Singleton Seguro</h3>

<p>Este é um padrão poderoso em Go. Diferente de linguagens como Java ou C#, Go não oferece um <code>singleton</code> nativo na linguagem. Com <code>sync.Once</code>, você implementa isso de forma segura e elegante:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;sync&quot;

)

type Logger struct {

name string

}

var (

loggerInstance *Logger

loggerOnce sync.Once

)

func GetLogger() *Logger {

loggerOnce.Do(func() {

fmt.Println(&quot;Logger criado&quot;)

loggerInstance = &amp;Logger{name: &quot;GlobalLogger&quot;}

})

return loggerInstance

}

func (l *Logger) Log(message string) {

fmt.Printf(&quot;[%s] %s\n&quot;, l.name, message)

}

func main() {

// Todas estas chamadas usam a mesma instância

logger1 := GetLogger()

logger2 := GetLogger()

logger1.Log(&quot;Primeira mensagem&quot;)

logger2.Log(&quot;Segunda mensagem&quot;)

fmt.Printf(&quot;São a mesma instância? %v\n&quot;, logger1 == logger2) // true

}</code></pre>

<h2>Combinando WaitGroup e Once: Um Caso de Uso Real</h2>

<h3>Cenário: Sistema de Cache com Inicialização Única</h3>

<p>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:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;sync&quot;

&quot;time&quot;

)

type Cache struct {

mu sync.RWMutex

data map[string]string

}

var (

cache *Cache

cacheOnce sync.Once

)

func getCache() *Cache {

cacheOnce.Do(func() {

fmt.Println(&quot;Inicializando cache...&quot;)

cache = &amp;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(id100) time.Millisecond)

cache.Set(key, value)

fmt.Printf(&quot;Worker %d adicionou %s=%s\n&quot;, id, key, value)

}

func main() {

var wg sync.WaitGroup

// 5 workers adicionam dados ao cache

for i := 1; i &lt;= 5; i++ {

wg.Add(1)

go populateCache(i, fmt.Sprintf(&quot;key%d&quot;, i), fmt.Sprintf(&quot;value%d&quot;, i), &amp;wg)

}

wg.Wait()

// Verifica o cache

c := getCache()

fmt.Println(&quot;\nConteúdo final do cache:&quot;)

for i := 1; i &lt;= 5; i++ {

key := fmt.Sprintf(&quot;key%d&quot;, i)

fmt.Printf(&quot;%s =&gt; %s\n&quot;, key, c.Get(key))

}

}</code></pre>

<p>Neste exemplo, o <code>sync.Once</code> garante que o cache seja criado apenas uma vez, mesmo que múltiplas goroutines tentem acessá-lo simultaneamente. O <code>sync.WaitGroup</code> coordena o término de todas as goroutines que preenchem o cache. Finalmente, o <code>sync.RWMutex</code> dentro do cache protege os dados contra condições de corrida durante leitura e escrita.</p>

<h2>Conclusão</h2>

<p>Você aprendeu que <strong><code>sync.WaitGroup</code> é essencial para coordenar múltiplas goroutines em paralelo</strong>, permitindo que o programa principal aguarde a conclusão de todas as tarefas sem desperdício de CPU ou deadlock. Use <code>Add()</code> antes de iniciar, <code>Done()</code> (via defer) quando terminar e <code>Wait()</code> para bloquear até o fim.</p>

<p>Compreendeu também que <strong><code>sync.Once</code> garante que uma operação execute exatamente uma vez</strong>, tornando-a ideal para inicialização singleton, configurações e recursos compartilhados caros. Chame <code>Do()</code> 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.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://pkg.go.dev/sync#WaitGroup" target="_blank" rel="noopener noreferrer">Documentação oficial de sync.WaitGroup</a></li>

<li><a href="https://pkg.go.dev/sync#Once" target="_blank" rel="noopener noreferrer">Documentação oficial de sync.Once</a></li>

<li><a href="https://golang.org/doc/effective_go#concurrency" target="_blank" rel="noopener noreferrer">Effective Go - Concurrency</a></li>

<li><a href="https://www.gopl.io/" target="_blank" rel="noopener noreferrer">The Go Programming Language - Goroutines and Channels</a> (Capítulo 8)</li>

<li><a href="https://go.dev/blog/pipelines" target="_blank" rel="noopener noreferrer">Go Concurrency Patterns</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Go

Pacote encoding/json em Go: Serialização, Tags e Casos Especiais: Do Básico ao Avançado
Pacote encoding/json em Go: Serialização, Tags e Casos Especiais: Do Básico ao Avançado

Introdução ao Pacote encoding/json em Go O pacote é uma das ferramentas mais...

Guia Completo de Context em Go: Cancelamento, Timeout e Propagação de Valores
Guia Completo de Context em Go: Cancelamento, Timeout e Propagação de Valores

Context em Go: Cancelamento, Timeout e Propagação de Valores O é um dos pacot...

O que Todo Dev Deve Saber sobre Padrões de Concorrência em Go: Pipeline, Fan-out, Fan-in e Worker Pool
O que Todo Dev Deve Saber sobre Padrões de Concorrência em Go: Pipeline, Fan-out, Fan-in e Worker Pool

Introdução aos Padrões de Concorrência em Go Go foi projetada desde o início...