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 Mutex ou RWMutex é 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.Once resolve elegantemente o problema de inicialização lazy sem necessidade de canais ou variáveis de condição.
  • Alta contenção de leitura: RWMutex permite 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 defer para 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 vet para detectar erros comuns de sincronização.

Exercícios

  1. 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
    }
  2. 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)
    }
  3. 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"
    }
  4. 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)
    }
  5. 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.

Referências