1. O papel dos mecanismos de sincronização
Na programação concorrente, quando múltiplas goroutines compartilham recursos, é necessário garantir que os recursos só possam ser acessados por uma goroutine de cada vez para evitar condições de corrida. Isso requer o uso de mecanismos de sincronização. Mecanismos de sincronização conseguem coordenar a ordem de acesso de diferentes goroutines aos recursos compartilhados, garantindo consistência de dados e sincronização de estados em um ambiente concorrente.
A linguagem Go fornece um amplo conjunto de mecanismos de sincronização, incluindo, mas não se limitando a:
- Mutexes (sync.Mutex) e mutexes de leitura-escrita (sync.RWMutex)
- Canais (Channels)
- WaitGroups
- Funções atômicas (pacote atomic)
- Variáveis condicionais (sync.Cond)
2. Primitivas de Sincronização
2.1 Mutex (sync.Mutex)
2.1.1 Conceito e papel do mutex
Mutex é um mecanismo de sincronização que garante a operação segura de recursos compartilhados, permitindo que apenas uma goroutine mantenha a trava para acessar o recurso compartilhado em um determinado momento. O mutex alcança a sincronização por meio dos métodos Lock
e Unlock
. Chamar o método Lock
irá bloquear até que a trava seja liberada e, nesse momento, outras goroutines que tentarem adquirir a trava aguardarão. Chamar Unlock
libera a trava, permitindo que outras goroutines em espera a adquiram.
var mu sync.Mutex
func secaoCritica() {
// Adquirir a trava para acessar exclusivamente o recurso
mu.Lock()
// Acessar o recurso compartilhado aqui
// ...
// Liberar a trava para permitir que outras goroutines a adquiram
mu.Unlock()
}
2.1.2 Uso prático do mutex
Suponha que precisamos manter um contador global, e múltiplas goroutines precisam incrementar seu valor. Usar um mutex pode garantir a precisão do contador.
var (
mu sync.Mutex
contador int
)
func incrementar() {
mu.Lock() // Travar antes de modificar o contador
contador++ // Incrementar o contador com segurança
mu.Unlock() // Destravar após a operação, permitindo que outras goroutines acessem o contador
}
func main() {
for i := 0; i < 10; i++ {
go incrementar() // Iniciar múltiplas goroutines para incrementar o valor do contador
}
// Aguardar por um tempo (na prática, você deve usar WaitGroup ou outros métodos para aguardar todas as goroutines concluírem)
time.Sleep(1 * time.Second)
fmt.Println(contador) // Exibir o valor do contador
}
2.2 Mutex de Leitura-Escrita (sync.RWMutex)
2.2.1 Conceito de mutex de leitura-escrita
RWMutex é um tipo especial de trava que permite que múltiplas goroutines leiam recursos compartilhados simultaneamente, enquanto as operações de escrita são exclusivas. Comparado aos mutexes, as trancas de leitura-escrita podem melhorar o desempenho em cenários de múltiplos leitores. Ele possui quatro métodos: RLock
, RUnlock
para travar e destravar operações de leitura, e Lock
, Unlock
para travar e destravar operações de escrita.
2.2.2 Casos de Uso Prático do Mutex de Leitura-Escrita
Em uma aplicação de banco de dados, as operações de leitura podem ser muito mais frequentes do que as operações de escrita. Usar uma trava de leitura-escrita pode melhorar o desempenho do sistema, pois permite que múltiplas goroutines leiam simultaneamente.
var (
rwMu sync.RWMutex
dados int
)
func lerDados() int {
rwMu.RLock() // Adquirir a trava de leitura, permitindo que outras operações de leitura prossigam simultaneamente
defer rwMu.RUnlock() // Garantir que a trava seja liberada usando defer
return dados // Ler dados com segurança
}
func escreverDados(novoValor int) {
rwMu.Lock() // Adquirir a trava de escrita, impedindo outras operações de leitura ou escrita neste momento
dados = novoValor // Escrever o novo valor com segurança
rwMu.Unlock() // Destravar após a escrita estar completa
}
func main() {
go escreverDados(42) // Iniciar uma goroutine para realizar uma operação de escrita
fmt.Println(lerDados()) // A goroutine principal realiza uma operação de leitura
// Use WaitGroup ou outros métodos de sincronização para garantir que todas as goroutines estão finalizadas
}
No exemplo acima, vários leitores podem executar a função lerDados
simultaneamente, mas um escritor executando escreverDados
irá bloquear novos leitores e outros escritores. Este mecanismo oferece vantagens de desempenho para cenários com mais leituras do que escritas.
2.3 Variáveis Condicionais (sync.Cond
)
2.3.1 Conceito de Variáveis Condicionais
No mecanismo de sincronização da linguagem Go, as variáveis condicionais são usadas para aguardar ou notificar mudanças de condição como uma primitiva de sincronização. As variáveis condicionais são sempre usadas juntamente com um mutex (sync.Mutex
), que é usado para proteger a consistência da própria condição.
O conceito de variáveis condicionais vem do domínio dos sistemas operacionais, permitindo que um grupo de goroutines espere que uma certa condição seja atendida. Mais especificamente, uma goroutine pode pausar a execução enquanto espera que uma condição seja atendida, e outra goroutine pode notificar as outras goroutines para retomar a execução após alterar a condição usando a variável condicional.
Na biblioteca padrão Go, as variáveis condicionais são fornecidas através do tipo sync.Cond
, e seus principais métodos incluem:
-
Wait
: Chamando este método, será liberado o bloqueio mantido e aguardará até que outra goroutine chameSignal
ouBroadcast
na mesma variável condicional para acordá-la, após o que tentará adquirir o bloqueio novamente. -
Signal
: Acorda uma goroutine que está esperando por esta variável condicional. Se nenhuma goroutine estiver aguardando, chamar este método não terá efeito. -
Broadcast
: Acorda todas as goroutines que estão esperando por esta variável condicional.
As variáveis condicionais não devem ser copiadas, então geralmente são usadas como um campo de ponteiro de uma determinada estrutura.
2.3.2 Casos Práticos de Variáveis Condicionais
Aqui está um exemplo que utiliza variáveis condicionais e demonstra um modelo simples produtor-consumidor:
package main
import (
"fmt"
"sync"
"time"
)
// SafeQueue é uma fila segura protegida por um mutex
type SafeQueue struct {
mu sync.Mutex
cond *sync.Cond
queue []interface{}
}
// Enqueue adiciona um elemento no final da fila e notifica as goroutines em espera
func (sq *SafeQueue) Enqueue(item interface{}) {
sq.mu.Lock()
defer sq.mu.Unlock()
sq.queue = append(sq.queue, item)
sq.cond.Signal() // Notifica as goroutines em espera de que a fila não está vazia
}
// Dequeue remove um elemento do início da fila, aguarda se a fila estiver vazia
func (sq *SafeQueue) Dequeue() interface{} {
sq.mu.Lock()
defer sq.mu.Unlock()
// Aguarda quando a fila está vazia
for len(sq.queue) == 0 {
sq.cond.Wait() // Aguarda por uma mudança na condição
}
item := sq.queue[0]
sq.queue = sq.queue[1:]
return item
}
func main() {
queue := make([]interface{}, 0)
sq := SafeQueue{
mu: sync.Mutex{},
cond: sync.NewCond(&sync.Mutex{}),
queue: queue,
}
// Goroutine Produtora
go func() {
for i := 0; i < 5; i++ {
time.Sleep(1 * time.Second) // Simula o tempo de produção
sq.Enqueue(fmt.Sprintf("item%d", i)) // Produz um elemento
fmt.Println("Produzido:", i)
}
}()
// Goroutine Consumidora
go func() {
for i := 0; i < 5; i++ {
item := sq.Dequeue() // Consome um elemento, aguarda se a fila estiver vazia
fmt.Printf("Consumido: %v\n", item)
}
}()
// Aguarda por um período suficiente de tempo para garantir que toda a produção e consumo sejam concluídos
time.Sleep(10 * time.Second)
}
Neste exemplo, foi definida uma estrutura SafeQueue
com uma fila interna e uma variável condicional. Quando o consumidor chama o método Dequeue
e a fila está vazia, ele aguarda usando o método Wait
. Quando o produtor chama o método Enqueue
para enfileirar um novo elemento, ele usa o método Signal
para acordar o consumidor em espera.
2.4 WaitGroup
2.4.1 Conceito e Uso do WaitGroup
sync.WaitGroup
é um mecanismo de sincronização usado para aguardar um grupo de goroutines completar. Ao iniciar uma goroutine, você pode aumentar o contador chamando o método Add
, e cada goroutine pode chamar o método Done
(que na verdade realiza Add(-1)
) quando terminar. A goroutine principal pode bloquear chamando o método Wait
até que o contador atinja 0, indicando que todas as goroutines concluíram suas tarefas.
Ao usar WaitGroup
, os seguintes pontos devem ser observados:
- Os métodos
Add
,Done
eWait
não são seguros para thread e não devem ser chamados concorrentemente em múltiplas goroutines. - O método
Add
deve ser chamado antes da goroutine recém-criada iniciar.
2.4.2 Casos de Uso Práticos do WaitGroup
Aqui está um exemplo de uso do WaitGroup
:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Notificar WaitGroup após a conclusão
fmt.Printf("Trabalhador %d começando\n", id)
time.Sleep(time.Second) // Simular uma operação demorada
fmt.Printf("Trabalhador %d concluído\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // Incrementa o contador antes de iniciar a gorrotina
go worker(i, &wg)
}
wg.Wait() // Aguardar todas as gorrotinas trabalhadoras terminarem
fmt.Println("Todos os trabalhadores concluídos")
}
Neste exemplo, a função worker
simula a execução de uma tarefa. Na função main, iniciamos cinco gorrotinas worker
. Antes de iniciar cada gorrotina, chamamos wg.Add(1)
para notificar o WaitGroup
que uma nova tarefa está sendo executada. Quando cada função trabalhadora completa, ela chama defer wg.Done()
para notificar o WaitGroup
que a tarefa está concluída. Após iniciar todas as gorrotinas, a função main bloqueia em wg.Wait()
até que todas as trabalhadoras relatem a conclusão.
2.5 Operações Atômicas (sync/atomic
)
2.5.1 Conceito de Operações Atômicas
Operações atômicas referem-se a operações em programação concorrente que são indivisíveis, ou seja, elas não são interrompidas por outras operações durante a execução. Para múltiplas gorrotinas, o uso de operações atômicas pode garantir consistência de dados e sincronização de estado sem a necessidade de bloqueio, pois as operações atômicas garantem a atomicidade da execução.
Na linguagem Go, o pacote sync/atomic
fornece operações de memória atômica de baixo nível. Para tipos de dados básicos como int32
, int64
, uint32
, uint64
, uintptr
e ponteiro
, métodos do pacote sync/atomic
podem ser utilizados para operações concorrentes seguras. A importância das operações atômicas está em ser a base para a construção de outras primitivas concorrentes (como bloqueios e variáveis de condição) e frequentemente ser mais eficiente do que mecanismos de bloqueio.
2.5.2 Casos de Uso Práticos de Operações Atômicas
Considere um cenário em que precisamos rastrear o número concorrente de visitantes a um site. Usando uma variável de contador simples de forma intuitiva, aumentaríamos o contador quando um visitante chega e diminuiríamos quando um visitante saísse. No entanto, em um ambiente concorrente, essa abordagem levaria a corridas de dados. Portanto, podemos usar o pacote sync/atomic
para manipular o contador com segurança.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var visitorCount int32
func incrementVisitorCount() {
atomic.AddInt32(&visitorCount, 1)
}
func decrementVisitorCount() {
atomic.AddInt32(&visitorCount, -1)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
incrementVisitorCount()
time.Sleep(time.Second) // Tempo da visita do visitante
decrementVisitorCount()
wg.Done()
}()
}
wg.Wait()
fmt.Printf("Contagem atual de visitantes: %d\n", visitorCount)
}
Neste exemplo, criamos 100 gorrotinas para simular a chegada e partida de visitantes. Usando a função atomic.AddInt32()
, garantimos que os incrementos e decrementos do contador sejam atômicos, mesmo em situações altamente concorrentes, garantindo assim a precisão de visitorCount
.
2.6 Mecanismo de Sincronização de Canais
2.6.1 Características de Sincronização de Canais
Canais são uma forma para gorrotinas se comunicarem na linguagem Go no nível da linguagem. Um canal fornece a capacidade de enviar e receber dados. Quando uma gorrotina tenta ler dados de um canal e o canal não possui dados, ela bloqueará até que haja dados disponíveis. Da mesma forma, se o canal estiver cheio (para um canal não bufferizado, isso significa que já possui dados), a gorrotina que tenta enviar dados também bloqueará até que haja espaço para escrever. Essa característica torna os canais muito úteis para sincronização entre gorrotinas.
2.6.2 Casos de Uso de Sincronização com Canais
Suponha que temos uma tarefa que precisa ser concluída por várias goroutines, cada uma lidando com uma sub tarefa, e então precisamos agregar os resultados de todas as sub tarefas. Podemos usar um canal para esperar que todas as goroutines terminem.
pacote principal
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
defer wg.Done()
// Realizar algumas operações...
fmt.Printf("Trabalhador %d começando\n", id)
// Suponha que o resultado da sub tarefa é o id do trabalhador
resultChan <- id
fmt.Printf("Trabalhador %d feito\n", id)
}
func principal() {
var wg sync.WaitGroup
numWorkers := 5
resultChan := make(chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, &wg, resultChan)
}
go func() {
wg.Wait()
close(resultChan)
}()
// Coletar todos os resultados
para resultado := intervalo resultChan {
fmt.Printf("Resultado recebido: %d\n", resultado)
}
}
Neste exemplo, iniciamos 5 goroutines para realizar tarefas e coletamos os resultados através do canal resultChan
. A goroutine principal espera que todo o trabalho seja concluído em uma goroutine separada e então fecha o canal de resultados. Depois, a goroutine principal percorre o canal resultChan
, coletando e imprimindo os resultados de todas as goroutines.
2.7 Execução Única (sync.Once
)
sync.Once
é um primitivo de sincronização que garante que uma operação seja executada apenas uma vez durante a execução do programa. Um uso típico de sync.Once
é na inicialização de um objeto singleton ou em cenários que requerem inicialização atrasada. Independentemente de quantas goroutines chamem essa operação, ela será executada apenas uma vez, daí o nome da função Do
.
sync.Once
equilibra perfeitamente problemas de concorrência e eficiência de execução, eliminando preocupações com problemas de desempenho causados por inicializações repetidas.
Como exemplo simples para demonstrar o uso de sync.Once
:
pacote principal
import (
"fmt"
"sync"
)
var umaVez sync.Once
var instância *Singleton
tipo Singleton struct{}
func Instância() *Singleton {
umaVez.Do(func() {
fmt.Println("Criando única instância agora.")
instância = &Singleton{}
})
return instância
}
func principal() {
para i := 0; i < 10; i++ {
go Instância()
}
fmt.Scanln() // Aguarde para ver a saída
}
Neste exemplo, mesmo que a função Instância
seja chamada concorrentemente várias vezes, a criação da instância Singleton
ocorrerá apenas uma vez. Chamadas subsequentes retornarão diretamente a instância singleton criada na primeira vez, garantindo a unicidade da instância.
2.8 ErrGroup
ErrGroup
é uma biblioteca na linguagem Go usada para sincronizar múltiplas goroutines e coletar seus erros. Faz parte do pacote "golang.org/x/sync/errgroup", fornecendo uma maneira concisa de lidar com cenários de erro em operações concorrentes.
2.8.1 Conceito do ErrGroup
A ideia central do ErrGroup
é unir um grupo de tarefas relacionadas (normalmente executadas em paralelo) e, se uma das tarefas falhar, o a execução de todo o grupo será cancelada. Ao mesmo tempo, se qualquer uma dessas operações concorrentes retornar um erro, o ErrGroup
irá capturar e retornar esse erro.
Para usar o ErrGroup
, primeiro importe o pacote:
import "golang.org/x/sync/errgroup"
Em seguida, crie uma instância do ErrGroup
:
var g errgroup.Group
Depois, você pode passar as tarefas para o ErrGroup
na forma de closures e iniciar uma nova goroutine chamando o método Go
:
g.Go(func() error {
// Realizar uma determinada tarefa
// Se tudo correr bem
return nil
// Se ocorrer um erro
// return fmt.Errorf("erro ocorreu")
})
Por fim, chame o método Wait
, que bloqueará e esperará que todas as tarefas sejam concluídas. Se alguma dessas tarefas retornar um erro, Wait
retornará esse erro:
se err := g.Wait(); err != nil {
// Lidar com o erro
log.Fatalf("Erro na execução da tarefa: %v", err)
}
2.8.2 Caso Prático do ErrGroup
Considere um cenário onde precisamos buscar dados de forma concorrente em três fontes de dados diferentes e, se alguma das fontes de dados falhar, queremos cancelar imediatamente as outras operações de busca de dados. Essa tarefa pode ser facilmente realizada usando o ErrGroup
:
package main
import (
"fmt"
"golang.org/x/sync/errgroup"
)
func buscarDadosFonte1() error {
// Simular a busca de dados na fonte 1
return nil // ou retornar um erro para simular uma falha
}
func buscarDadosFonte2() error {
// Simular a busca de dados na fonte 2
return nil // ou retornar um erro para simular uma falha
}
func buscarDadosFonte3() error {
// Simular a busca de dados na fonte 3
return nil // ou retornar um erro para simular uma falha
}
func main() {
var g errgroup.Group
g.Go(buscarDadosFonte1)
g.Go(buscarDadosFonte2)
g.Go(buscarDadosFonte3)
// Aguardar a conclusão de todas as goroutines e coletar seus erros
if err := g.Wait(); err != nil {
fmt.Printf("Ocorreu um erro ao buscar os dados: %v\n", err)
return
}
fmt.Println("Todos os dados foram buscados com sucesso!")
}
Neste exemplo, as funções buscarDadosFonte1
, buscarDadosFonte2
e buscarDadosFonte3
simulam a busca de dados em diferentes fontes de dados. Elas são passadas para o método g.Go
e executadas em goroutines separadas. Se alguma das funções retornar um erro, o g.Wait
irá imediatamente retornar esse erro, permitindo o tratamento apropriado do erro quando ele ocorrer. Se todas as funções forem executadas com sucesso, o g.Wait
irá retornar nil
, indicando que todas as tarefas foram concluídas com sucesso.
Outra característica importante do ErrGroup
é que se alguma das goroutines lançar um pânico, ele tentará recuperar esse pânico e retorná-lo como um erro. Isso ajuda a evitar que outras goroutines em execução falhem ao encerrar de forma tardia. Claro, se quiser que as tarefas respondam a sinais de cancelamento externos, pode-se combinar a função WithContext
do errgroup
com o pacote context para fornecer um contexto cancelável.
Dessa forma, o ErrGroup
se torna um mecanismo muito prático de sincronização e tratamento de erros na prática de programação concorrente em Go.