Mutex e RwLock em contexto concorrente

1. Introdução à Sincronização de Dados Compartilhados

Em programação concorrente, dois problemas clássicos surgem: corrida de dados (data races) e inconsistência de estado. Quando múltiplas threads acessam e modificam o mesmo dado simultaneamente, o resultado pode ser imprevisível. Rust, com seu sistema de ownership, já previne corridas de dados em tempo de compilação, mas ainda precisamos de mecanismos para compartilhar dados mutáveis entre threads.

O tipo Arc<T> (Atomic Reference Counting) permite que múltiplas threads possuam referências compartilhadas a um mesmo valor, mas não oferece mutabilidade segura — Arc<T> só fornece acesso imutável. Para modificar dados compartilhados, precisamos de primitivas de sincronização como Mutex<T> e RwLock<T>, presentes em std::sync.

2. Mutex: Exclusão Mútua em Rust

Mutex (mutual exclusion) garante que apenas uma thread por vez acesse o dado protegido. Em Rust, seu uso é elegante: o bloqueio é adquirido via lock(), que retorna um MutexGuard<T>. Quando o guardião sai de escopo, o bloqueio é automaticamente liberado graças ao trait Drop.

use std::sync::Mutex;

let contador = Mutex::new(0);

{
    let mut guard = contador.lock().unwrap();
    *guard += 1;
} // lock liberado aqui

println!("Valor: {}", contador.lock().unwrap());

O método try_lock() tenta adquirir o lock sem bloquear, retornando Err se não for possível. Já o poisoning ocorre se uma thread entra em pânico enquanto segura o lock — o mutex é marcado como "envenenado" e chamadas futuras a lock() retornarão Err.

3. RwLock: Leitura e Escrita com Granularidade Fina

RwLock (Read-Write Lock) oferece uma política mais flexível: múltiplos leitores podem acessar o dado simultaneamente, mas apenas um escritor por vez. Isso é ideal para workloads onde leituras são muito mais frequentes que escritas.

use std::sync::RwLock;

let dados = RwLock::new(vec![1, 2, 3]);

// Múltiplos leitores podem coexistir
let leitor1 = dados.read().unwrap();
let leitor2 = dados.read().unwrap();
println!("Leitores: {:?} e {:?}", leitor1, leitor2);
drop(leitor1);
drop(leitor2);

// Escritor precisa de acesso exclusivo
let mut escritor = dados.write().unwrap();
escritor.push(4);

A API oferece read(), write(), try_read() e try_write(). A versão try_* não bloqueia, útil para evitar deadlocks em cenários complexos.

4. Compartilhamento entre Threads: Arc> e Arc>

Para compartilhar um Mutex ou RwLock entre threads, combinamos com Arc. Exemplo de contador compartilhado:

use std::sync::{Arc, Mutex};
use std::thread;

let contador = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let c = Arc::clone(&contador);
    handles.push(thread::spawn(move || {
        let mut num = c.lock().unwrap();
        *num += 1;
    }));
}

for h in handles {
    h.join().unwrap();
}

println!("Resultado: {}", *contador.lock().unwrap()); // 10

Agora, um cache compartilhado com RwLock:

use std::collections::HashMap;
use std::sync::{Arc, RwLock};

let cache: Arc<RwLock<HashMap<String, i32>>> = Arc::new(RwLock::new(HashMap::new()));
let mut handles = vec![];

// Thread escritora
let c = Arc::clone(&cache);
handles.push(thread::spawn(move || {
    let mut map = c.write().unwrap();
    map.insert("chave".to_string(), 42);
}));

// Thread leitora
let c = Arc::clone(&cache);
handles.push(thread::spawn(move || {
    let map = c.read().unwrap();
    if let Some(valor) = map.get("chave") {
        println!("Cache: {}", valor);
    }
}));

5. Armadilhas e Boas Práticas

Deadlocks ocorrem quando duas threads esperam uma pela outra liberar locks. A solução clássica é estabelecer uma ordem global de aquisição de locks.

// Propenso a deadlock
let a = Mutex::new(1);
let b = Mutex::new(2);

thread::spawn(move || {
    let _ga = a.lock().unwrap();
    thread::sleep(Duration::from_millis(50));
    let _gb = b.lock().unwrap(); // pode deadlock
});

Escopo mínimo do lock: mantenha o guardião vivo pelo menor tempo possível. Use blocos {} para limitar seu escopo.

let mut guard = mutex.lock().unwrap();
*guard += 1;
// Libere explicitamente drop(guard) ou feche o bloco

Poisoning: ao detectar um mutex envenenado, você pode ignorar o erro (com unwrap_or_else) ou tratar o pânico. Em aplicações críticas, considere reinicializar o estado.

6. Mutex vs. RwLock: Quando Usar Cada Um

Característica Mutex RwLock
Leitores simultâneos 1 Múltiplos
Escritores simultâneos 1 1
Overhead Menor Maior (controle de leitores)
Ideal para Escrita frequente, dados pequenos Leitura predominante, dados grandes

Use Mutex quando:
- A maioria das operações envolve escrita
- O dado protegido é pequeno (ex: um contador, uma flag)
- A contenção é baixa ou moderada

Use RwLock quando:
- Leituras são muito mais frequentes que escritas (ex: cache, tabelas de configuração)
- O dado protegido é grande e copiá-lo seria caro
- Você precisa de acesso concorrente de leitura sem bloquear

Trade-off: RwLock tem overhead maior que Mutex devido ao gerenciamento de múltiplos leitores. Em cenários com escrita frequente, Mutex pode ser mais rápido.

7. Alternativas e Considerações Finais

O ecossistema Rust oferece alternativas interessantes:

  • std::sync::LazyLock (estabilizado no Rust 1.80): para inicialização tardia segura em contexto concorrente.
  • parking_lot::Mutex e parking_lot::RwLock: bibliotecas externas com desempenho superior, sem poisoning e com API mais ergonômica.
  • tokio::sync::Mutex: para uso com async/await, evita bloquear o runtime. Use std::sync::Mutex apenas em threads blocking ou quando o lock é mantido por tempo muito curto.

Em ambientes assíncronos, std::sync::Mutex pode causar starvation se mantido durante um .await. Prefira tokio::sync::Mutex ou tokio::sync::RwLock para tarefas async.

A escolha entre Mutex e RwLock depende do padrão de acesso aos dados. Para a maioria dos casos, comece com Mutex — é mais simples e eficiente. Migre para RwLock apenas quando medições mostrarem que leituras concorrentes trariam ganhos significativos de desempenho.

Referências