Go Admin

Guia Completo de sync.Mutex e sync.RWMutex em Go: Exclusão Mútua Explícita Já leu

Entendendo Concorrência e a Necessidade de Sincronização A programação concorrente em Go permite que múltiplas goroutines executem simultaneamente, compartilhando recursos como variáveis, estruturas de dados e conexões com banco de dados. Quando várias goroutines acessam o mesmo recurso ao mesmo tempo, surgem problemas graves: race conditions, corrupção de dados e comportamentos impredíveis. A exclusão mútua (mutex) é o mecanismo fundamental para garantir que apenas uma goroutine acesse um recurso crítico por vez, preservando a integridade dos dados. Considere um exemplo real: você tem um contador compartilhado que múltiplas goroutines incrementam simultaneamente. Sem sincronização, duas goroutines poderiam ler o mesmo valor, incrementar e escrever de volta, causando perda de incrementos. O Go fornece dois tipos de mutex na package : o (mutex exclusivo) e o (mutex leitor-escritor). Ambos resolvem o problema, mas de formas diferentes, com trade-offs distintos. Mutex: Exclusão Mútua Simples O é a forma mais direta de proteger um recurso crítico. Ele funciona como uma porta com uma chave:

Entendendo Concorrência e a Necessidade de Sincronização

A programação concorrente em Go permite que múltiplas goroutines executem simultaneamente, compartilhando recursos como variáveis, estruturas de dados e conexões com banco de dados. Quando várias goroutines acessam o mesmo recurso ao mesmo tempo, surgem problemas graves: race conditions, corrupção de dados e comportamentos impredíveis. A exclusão mútua (mutex) é o mecanismo fundamental para garantir que apenas uma goroutine acesse um recurso crítico por vez, preservando a integridade dos dados.

Considere um exemplo real: você tem um contador compartilhado que múltiplas goroutines incrementam simultaneamente. Sem sincronização, duas goroutines poderiam ler o mesmo valor, incrementar e escrever de volta, causando perda de incrementos. O Go fornece dois tipos de mutex na package sync: o Mutex (mutex exclusivo) e o RWMutex (mutex leitor-escritor). Ambos resolvem o problema, mas de formas diferentes, com trade-offs distintos.

Mutex: Exclusão Mútua Simples

O sync.Mutex é a forma mais direta de proteger um recurso crítico. Ele funciona como uma porta com uma chave: apenas a goroutine que possui a chave pode entrar na seção crítica. Quando você chama Lock(), a goroutine adquire o mutex; quando chama Unlock(), libera-o. Se outra goroutine tentar fazer Lock() enquanto o mutex está ocupado, ela bloqueia até que o mutex seja liberado.

Estrutura Básica e Funcionamento

A forma padrão de usar Mutex é envolver um recurso compartilhado com a proteção:

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    counter := &Counter{}
    var wg sync.WaitGroup

    // 100 goroutines incrementando o contador
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                counter.Increment()
            }
        }()
    }

    wg.Wait()
    fmt.Printf("Valor final: %d (esperado: 100000)\n", counter.Value())
}

Neste exemplo, o campo mu protege o campo value. O padrão Lock() seguido por defer Unlock() garante que o mutex seja sempre liberado, mesmo se um pânico ocorrer. Sem essa proteção, o resultado final seria impredívelmente menor que 100000 devido à race condition.

Deadlock e Boas Práticas

Um risco comum ao usar Mutex é o deadlock: quando uma goroutine tenta adquirir um mutex que ela mesma já possui (em Go, isso causará um pânico). Outro cenário é quando goroutines A e B esperam uma pela outra indefinidamente. A melhor prática é manter seções críticas pequenas e evitar chamar funções que também tentam adquirir o mesmo mutex.

package main

import (
    "sync"
)

type BankAccount struct {
    mu      sync.Mutex
    balance float64
}

func (ba *BankAccount) Transfer(other *BankAccount, amount float64) {
    // ⚠️ PERIGO: se outro Transfer chamar Transfer na ordem inversa, deadlock!
    ba.mu.Lock()
    defer ba.mu.Unlock()

    other.mu.Lock()
    defer other.mu.Unlock()

    ba.balance -= amount
    other.balance += amount
}

// ✓ Solução: usar ordem consistente
func (ba *BankAccount) TransferSafe(other *BankAccount, amount float64) {
    // Sempre fazer lock na conta com ID menor primeiro
    first, second := ba, other
    if uintptr(unsafe.Pointer(ba)) > uintptr(unsafe.Pointer(other)) {
        first, second = other, ba
    }

    first.mu.Lock()
    defer first.mu.Unlock()

    second.mu.Lock()
    defer second.mu.Unlock()

    ba.balance -= amount
    other.balance += amount
}

RWMutex: Otimizando Leituras Frequentes

O sync.RWMutex é um mutex leitor-escritor que permite múltiplos leitores simultâneos, mas apenas um escritor exclusivo. Isso é ideal quando seu padrão de acesso é muito mais leitura do que escrita. Diferentemente do Mutex, que serializa tudo, o RWMutex permite paralelismo quando não há modificações em progresso.

Quando Usar RWMutex

RWMutex oferece três operações: RLock() para leitura compartilhada, RUnlock() para liberar leitura, e Lock()/Unlock() para escrita exclusiva. A regra é simples: se você está apenas lendo, use RLock(); se está modificando, use Lock().

package main

import (
    "fmt"
    "sync"
    "time"
)

type Config struct {
    mu     sync.RWMutex
    values map[string]string
}

func (c *Config) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.values[key]
}

func (c *Config) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.values[key] = value
}

func main() {
    config := &Config{values: make(map[string]string)}
    config.Set("database", "localhost:5432")

    var wg sync.WaitGroup

    // 50 goroutines lendo
    for i := 0; i < 50; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < 100; j++ {
                _ = config.Get("database")
                time.Sleep(time.Microsecond)
            }
        }(i)
    }

    // 2 goroutines escrevendo ocasionalmente
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := 0; j < 10; j++ {
                time.Sleep(10 * time.Millisecond)
                config.Set("database", fmt.Sprintf("server%d", id))
            }
        }(i)
    }

    wg.Wait()
    fmt.Println("Teste concluído com sucesso")
}

Neste cenário, 50 leitores podem acessar Get() simultaneamente enquanto nenhum escritor está ativo. Quando um escritor chama Set(), todos os novos RLock() esperam e os leitores existentes terminam antes do escritor prosseguir. Isso oferece muito mais throughput que um Mutex simples para este padrão de acesso.

Armadilhas do RWMutex

O RWMutex tem overhead maior que o Mutex simples. Se sua workload é principalmente escrita ou leitura/escrita equilibrada, um Mutex simples será mais rápido. Além disso, o RWMutex pode provocar "starvation" de escritores se houver um fluxo constante de leitores.

package main

import (
    "sync"
    "testing"
)

// Demonstra quando Mutex é melhor que RWMutex
func BenchmarkMutex(b *testing.B) {
    var mu sync.Mutex
    var value int

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            value++
            mu.Unlock()
        }
    })
}

func BenchmarkRWMutex(b *testing.B) {
    var mu sync.RWMutex
    var value int

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            value++
            mu.Unlock()
        }
    })
}

// Para aplicações com padrão read-heavy, RWMutex vence
func BenchmarkMutexReadHeavy(b *testing.B) {
    var mu sync.Mutex
    var value int

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            _ = value
            mu.Unlock()
        }
    })
}

func BenchmarkRWMutexReadHeavy(b *testing.B) {
    var mu sync.RWMutex
    var value int

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.RLock()
            _ = value
            mu.RUnlock()
        }
    })
}

Padrões Avançados e Erros Comuns

Além do uso básico, existem padrões que aumentam a robustez do código concorrente. Um padrão fundamental é separar a estrutura de dados da lógica que a protege, garantindo que o mutex seja sempre usado quando necessário.

Padrão de Encapsulamento

A melhor prática é tornar o recurso privado (com letra minúscula) e expor apenas métodos sincronizados:

package main

import (
    "sync"
)

type SafeMap struct {
    mu    sync.RWMutex
    items map[string]interface{}
}

func NewSafeMap() *SafeMap {
    return &SafeMap{items: make(map[string]interface{})}
}

func (sm *SafeMap) Set(key string, value interface{}) {
    sm.mu.Lock()
    defer sm.mu.Unlock()
    sm.items[key] = value
}

func (sm *SafeMap) Get(key string) (interface{}, bool) {
    sm.mu.RLock()
    defer sm.mu.RUnlock()
    val, ok := sm.items[key]
    return val, ok
}

func (sm *SafeMap) Delete(key string) {
    sm.mu.Lock()
    defer sm.mu.Unlock()
    delete(sm.items, key)
}

func (sm *SafeMap) Len() int {
    sm.mu.RLock()
    defer sm.mu.RUnlock()
    return len(sm.items)
}

func main() {
    sm := NewSafeMap()

    sm.Set("user:1", "Alice")
    sm.Set("user:2", "Bob")

    if val, ok := sm.Get("user:1"); ok {
        println(val.(string)) // Alice
    }

    println(sm.Len()) // 2
}

Evitar Erros Comuns

O erro mais frequente é esquecer de chamar Unlock() ou esquecer o Lock() em uma função que deveria ser sincronizada. Use defer sempre que possível. Outro erro é tentar serializar o próprio mutex ou passar goroutines com comportamento impredível.

package main

import (
    "sync"
)

// ❌ ERRADO: deadlock potencial
func wrongApproach() {
    var mu sync.Mutex

    go func() {
        mu.Lock()
        println("Goroutine 1")
        // ... se tentar chamar outra função que faz Lock(), deadlock
    }()

    go func() {
        mu.Lock()
        println("Goroutine 2")
        mu.Unlock()
    }()
}

// ✓ CORRETO: usar canais ou passar o mutex para funções que precisam
func correctApproach() {
    var mu sync.Mutex
    done := make(chan bool, 2)

    go func() {
        mu.Lock()
        defer mu.Unlock()
        println("Goroutine 1")
        done <- true
    }()

    go func() {
        mu.Lock()
        defer mu.Unlock()
        println("Goroutine 2")
        done <- true
    }()

    <-done
    <-done
}

Conclusão

Aprendemos que exclusão mútua explícita via sync.Mutex e sync.RWMutex é essencial para programação concorrente segura em Go. O Mutex fornece proteção simples e adequada para a maioria dos casos, enquanto o RWMutex otimiza cenários read-heavy, permitindo leitores simultâneos. O segundo ponto crítico é que deadlocks e race conditions são evitáveis através de disciplina: use defer Unlock(), mantenha seções críticas pequenas, estabeleça uma ordem consistente para múltiplos locks, e encapsule recursos compartilhados em tipos com métodos sincronizados. Por fim, sempre meça e perfil seu código antes de escolher entre Mutex e RWMutex, pois o overhead do RWMutex só compensa em padrões verdadeiramente read-heavy.

Referências


Artigos relacionados