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 chame Signal ou Broadcast 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 e Wait 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.