Pular para o conteúdo

Estado compartilhado em Rust: RwLock, atômicos e scoped threads

Na página de Concorrência, você compartilhou um contador entre threads com Arc<Mutex<T>>: o Arc deixa várias threads serem donas do valor, e o Mutex deixa só uma delas mexer nele de cada vez. Na página de Canais (channels), você evitou compartilhar qualquer coisa mandando mensagens. As duas são ótimas ferramentas, e dá para construir quase tudo com elas.

Mas pense no Mutex como a única chave do banheiro do escritório. Funciona, só que é exagero se tudo o que você quer é ler o mural do corredor. A biblioteca padrão do Rust tem mais algumas ferramentas, cada uma mais adequada para um tipo de trabalho. Nesta página, você vai conhecer quatro delas: scoped threads (threads com escopo), RwLock, atômicos e OnceLock/LazyLock. Também vai aprender a evitar o único bug que o compilador não consegue pegar por você: o deadlock.

Com thread::spawn, normalmente você precisa de move e, muitas vezes, de Arc. Isso porque uma thread criada assim pode continuar rodando depois que o main já seguiu em frente, então o Rust não deixa ela pegar emprestadas as suas variáveis locais.

O std::thread::scope resolve isso com uma promessa: toda thread iniciada dentro do escopo termina antes de o escopo acabar. Como as threads não podem viver mais que os seus dados, elas podem simplesmente pegá-los emprestados. Sem Arc, sem move, sem clonar nada.

use std::thread;
fn main() {
let shop = "Mercadinho da Esquina";
let sales = vec![12, 7, 30, 5, 18, 22, 9, 14];
let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| {
let am = s.spawn(|| morning.iter().sum::<i32>());
let pm = s.spawn(|| afternoon.iter().sum::<i32>());
(am.join().unwrap(), pm.join().unwrap())
});
println!("{shop}: manhã {am}, tarde {pm}, dia inteiro {}", am + pm);
println!("ainda é meu: {:?}", sales);
}

Saída:

Mercadinho da Esquina: manhã 54, tarde 63, dia inteiro 117
ainda é meu: [12, 7, 30, 5, 18, 22, 9, 14]
▶ Experimente você mesmo

O que está acontecendo:

  • thread::scope(|s| { ... }) abre o escopo. Você inicia as threads com s.spawn(...) em vez de thread::spawn(...).
  • As duas closures pegam emprestados morning e afternoon, que são só slices de sales. Nada é copiado.
  • O que a closure do escopo retornar (aqui, uma tupla com as duas somas) vira o valor de thread::scope.
  • Depois do escopo, sales continua pertencendo ao main, então você pode continuar usando.

Mesmo que você esqueça de chamar join(), o escopo espera todas as suas threads no } de fechamento. É exatamente essa promessa que torna o empréstimo seguro.

Imagine o quadro de cardápio de uma lanchonete. Vários clientes podem ler ao mesmo tempo, e tudo bem. Mas quando o dono muda um preço, todo mundo precisa esperar um pouquinho até o quadro novo estar pronto.

Isso é um RwLock (uma “trava de leitura e escrita”, do inglês read-write lock). Ele tem duas entradas:

  • read() dá a você uma guarda de leitura compartilhada. Várias threads podem ter uma ao mesmo tempo.
  • write() dá a você uma guarda de escrita exclusiva. Enquanto um escritor a segura, ninguém mais pode ler nem escrever.

O Mutex não faz essa diferença: até dois leitores precisam esperar a vez. Aqui está um cache de configurações que vários tratadores de requisição leem, e que um admin atualiza uma vez:

use std::sync::{Arc, RwLock};
use std::thread;
struct Settings {
theme: String,
max_upload_mb: u32,
}
fn main() {
let settings = Arc::new(RwLock::new(Settings {
theme: String::from("claro"),
max_upload_mb: 10,
}));
// Quatro tratadores de requisição leem as configurações ao mesmo tempo.
let mut handles = Vec::new();
for id in 1..=4 {
let settings = Arc::clone(&settings);
handles.push(thread::spawn(move || {
let s = settings.read().unwrap(); // compartilhada: outros também podem ler
format!("requisição {id}: tema={}, limite={}MB", s.theme, s.max_upload_mb)
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
// Um admin muda as configurações uma vez.
{
let mut s = settings.write().unwrap(); // exclusiva: todo o resto espera
s.theme = String::from("escuro");
s.max_upload_mb = 25;
} // a guarda de escrita é descartada aqui, e a trava fica livre de novo
let s = settings.read().unwrap();
println!("depois da atualização: tema={}, limite={}MB", s.theme, s.max_upload_mb);
}

Saída:

requisição 1: tema=claro, limite=10MB
requisição 2: tema=claro, limite=10MB
requisição 3: tema=claro, limite=10MB
requisição 4: tema=claro, limite=10MB
depois da atualização: tema=escuro, limite=25MB
▶ Experimente você mesmo

As threads podem terminar em qualquer ordem, mas a saída é sempre a mesma. Cada thread retorna a sua linha em vez de imprimi-la, e o main imprime as linhas na ordem em que faz o join.

Quando o RwLock ganha do Mutex? Quando as leituras são frequentes, as escritas são raras e cada leitura demora um pouco (por exemplo, procurar algo em um cache grande). Aí os leitores rodam lado a lado de verdade.

Quando não ganha? Se as leituras são rapidinhas, ou se as escritas acontecem o tempo todo, um RwLock é só um Mutex mais complicado. Ele faz um controle extra, e leitores e escritores acabam esperando uns pelos outros de qualquer jeito. Na dúvida, comece com um Mutex. É mais simples, e você pode trocar depois se uma medição mostrar que a trava virou um gargalo.

Para um único número ou uma única flag verdadeiro/falso, até uma trava é mais do que você precisa. Os tipos atômicos, como AtomicUsize, AtomicI64 e AtomicBool, podem ser alterados com segurança por várias threads ao mesmo tempo, sem trava nenhuma. A CPU faz cada operação acontecer em um único passo indivisível, então nenhuma atualização se perde.

Aqui, 8 threads registram 1000 visualizações de página cada uma. Elas também contam votos: um voto positivo, depois três negativos, de novo e de novo. O total de votos fica negativo, e é por isso que ele é um AtomicI64 e não um tipo sem sinal:

use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::thread;
fn main() {
let page_views = AtomicUsize::new(0);
let net_votes = AtomicI64::new(0);
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for n in 0..1000 {
page_views.fetch_add(1, Ordering::Relaxed);
if n % 4 == 0 {
net_votes.fetch_add(1, Ordering::Relaxed); // voto positivo
} else {
net_votes.fetch_sub(1, Ordering::Relaxed); // voto negativo
}
}
});
}
}); // as 8 threads já terminaram aqui
println!("visualizações: {}", page_views.load(Ordering::Relaxed));
println!("saldo de votos: {}", net_votes.load(Ordering::Relaxed));
}

Saída:

visualizações: 8000
saldo de votos: -4000
▶ Experimente você mesmo

Repare que page_views não é mut. Os atômicos mudam através de uma referência compartilhada, e é exatamente por isso que várias threads podem usá-los ao mesmo tempo. Os métodos que você mais vai usar:

  • fetch_add(n, ordering) / fetch_sub(n, ordering) somam ou subtraem, e devolvem o valor antigo.
  • load(ordering) lê o valor atual.
  • store(value, ordering) sobrescreve o valor.

Toda operação atômica pede um Ordering. Ele diz à CPU e ao compilador com que rigor essa operação precisa manter a ordem em relação às operações de memória ao redor. A história completa é profunda, mas, como iniciante, você só precisa de duas regras:

  • Na dúvida, use Ordering::SeqCst. É a opção mais rigorosa e mais segura.
  • Ordering::Relaxed serve bem para um contador simples que você só lê no final, como o de cima. O total continua exatamente certo.

Se um dia você quiser ir mais longe, o livro gratuito Rust Atomics and Locks (com link no fim da página) explica as orderings com clareza.

Um AtomicBool é um ótimo sinal de “por favor, pare”. Aqui, um worker em segundo plano continua salvando automaticamente até o main virar a flag:

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop = AtomicBool::new(false);
let report = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::SeqCst) {
// finja que o documento é salvo automaticamente aqui
thread::sleep(Duration::from_millis(5));
}
"worker de salvamento automático parou sem problemas"
});
thread::sleep(Duration::from_millis(30)); // o main faz outras coisas
stop.store(true, Ordering::SeqCst); // pede para o worker parar
worker.join().unwrap()
});
println!("{report}");
}

Saída:

worker de salvamento automático parou sem problemas
▶ Experimente você mesmo

O worker confere a flag a cada volta do loop. Ele termina o passo atual e depois sai sozinho, o que é muito mais gentil do que matar uma thread no meio do salvamento de um arquivo.

Às vezes você quer um valor global que toda thread possa ler: uma tabela de consulta, o nome do app, uma configuração carregada na inicialização. A parte difícil é construí-lo. E se duas threads tentarem construir ao mesmo tempo?

O std::sync::LazyLock e o std::sync::OnceLock resolvem isso. O valor é criado exatamente uma vez, na primeira vez que alguém precisa dele. Se outras threads pedirem enquanto ele está sendo construído, elas simplesmente esperam, e depois todo mundo compartilha o mesmo valor.

use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::thread;
// LazyLock: a receita fica escrita aqui mesmo e roda no primeiro uso.
static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("montando a tabela de moedas...");
HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])
});
// OnceLock: começa vazio, e você decide como preenchê-lo no lugar onde usa.
static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str {
STORE_NAME.get_or_init(|| {
std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Loja Rusty"))
})
}
fn main() {
let mut handles = Vec::new();
for country in ["US", "JP", "DE", "BR"] {
handles.push(thread::spawn(move || {
format!("{} em {country}: preços em {}", store_name(), CURRENCIES[country])
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}

Saída:

montando a tabela de moedas...
Loja Rusty em US: preços em USD
Loja Rusty em JP: preços em JPY
Loja Rusty em DE: preços em EUR
Loja Rusty em BR: preços em BRL
▶ Experimente você mesmo

As quatro threads querem a tabela quase ao mesmo tempo, mas “montando a tabela de moedas…” aparece uma vez só. E como esses valores são static e vivem durante o programa inteiro, as threads não precisam de Arc.

Qual dos dois escolher?

  • LazyLock quando você sabe como construir o valor no lugar onde o declara. Ele parece uma variável global comum.
  • OnceLock quando o valor só é decidido depois, por exemplo a partir de uma flag de linha de comando. Você o preenche com get_or_init(...) ou set(...) (e o set falha educadamente se ele já estiver preenchido).

Os dois fazem parte da biblioteca padrão. O LazyLock chegou no Rust 1.80, então códigos mais antigos costumam usar os crates once_cell ou lazy_static para o mesmo trabalho.

O compilador impede data races (condições de corrida nos dados), mas existe um bug de concorrência que ele não consegue pegar: o deadlock (impasse). É quando threads ficam esperando umas pelas outras para sempre e o seu programa simplesmente congela. Sem erro, sem crash, nada.

A receita clássica são duas travas pegas em ordens diferentes. Imagine duas pessoas que precisam, cada uma, da caneta e do caderno. Uma pega a caneta, a outra pega o caderno, e cada uma espera educadamente a outra soltar. Para sempre.

// Thread A // Thread B
let from = alice.lock().unwrap(); let from = bob.lock().unwrap();
let to = bob.lock().unwrap(); let to = alice.lock().unwrap();
// A segura alice, espera bob. // B segura bob, espera alice. Travou!

Três hábitos mantêm você seguro:

  1. Pegue as travas sempre na mesma ordem. Se toda thread travar alice antes de bob (por exemplo, em ordem de ID da conta), o ciclo não acontece.
  2. Mantenha os escopos das travas curtos. Segure uma trava só pelo tempo de ler ou mudar os dados. Nunca segure durante um trabalho lento, como uma chamada de rede, a escrita de um arquivo ou um cálculo demorado. Todas as outras threads ficam travadas esperando esse tempo todo.
  3. Descarte as guardas cedo. Uma guarda destrava quando é descartada (dropped). Você pode encerrá-la com um bloco interno { ... } (como o escritor do RwLock lá em cima) ou chamar drop(guard) você mesmo.

Aqui está o hábito 3 na prática. Cada worker pega um trabalho de uma fila compartilhada, destrava a fila e só depois faz a parte lenta:

use std::sync::Mutex;
use std::thread;
fn main() {
let queue = Mutex::new(vec!["redimensionar foto", "enviar e-mail", "gerar relatório"]);
let finished = Mutex::new(Vec::new());
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let mut jobs = queue.lock().unwrap();
let job = jobs.pop();
drop(jobs); // destrava a fila ANTES da parte lenta
if let Some(job) = job {
let result = format!("feito: {job}"); // imagine que isto leva segundos
finished.lock().unwrap().push(result);
}
});
}
});
// O escopo acabou, então nenhuma thread usa mais `finished`.
let mut done = finished.into_inner().unwrap();
done.sort(); // as threads terminam em qualquer ordem
for line in done {
println!("{line}");
}
}

Saída:

feito: enviar e-mail
feito: gerar relatório
feito: redimensionar foto
▶ Experimente você mesmo

Sem o drop(jobs), a guarda viveria até o fim da closure. Aí o trabalho “lento” aconteceria com a fila ainda travada, e os três workers rodariam um depois do outro em vez de lado a lado.

Um caso traiçoeiro que vale conhecer: em while let Some(job) = queue.lock().unwrap().pop() { ... }, a guarda vive durante o corpo inteiro do loop. Ou seja, a fila fica travada enquanto você trabalha no job. Pegue o trabalho primeiro, em uma instrução let separada, como o exemplo faz.

Você quer… Use
Mandar trabalho ou resultados de uma thread para outra Um canal (mpsc)
Deixar várias threads lerem e mudarem o mesmo valor Arc<Mutex<T>>
Compartilhar dados muito lidos e raramente alterados Arc<RwLock<T>>
Contar coisas, ou compartilhar uma flag simples de liga/desliga Atômicos (AtomicUsize, AtomicBool, …)
Construir um valor global uma vez e depois só ler LazyLock ou OnceLock
Deixar threads pegarem dados locais emprestados, sem Arc thread::scope

Dá para misturar à vontade. Os exemplos desta página já combinaram thread::scope com atômicos e com um Mutex. Escolha a ferramenta mais simples que resolve, e não se preocupe em escolher “errado”: trocar depois costuma ser uma mudança pequena.

Pegar uma variável local emprestada em thread::spawn. Isto parece inofensivo:

use std::thread;
fn main() {
let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| {
let total: i32 = sales.iter().sum();
total
});
println!("total: {}", handle.join().unwrap());
}
error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `sales`
7 | let total: i32 = sales.iter().sum();
| ----- `sales` is borrowed here
|
help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keyword

O compilador está dizendo que a closure pode viver mais que a função atual, mas pega emprestado sales, que pertence a essa função. Você sabe que chama join() logo em seguida, mas o thread::spawn não tem como saber disso. Para ele, a thread pode rodar para sempre. Há duas correções: adicionar move (aí o main não pode mais usar sales depois), ou usar thread::scope, que pode pegar emprestado:

let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());

Esquecer o Arc. Sem Arc, a closure move da primeira thread fica com o Mutex, e não sobra nada para a próxima:

use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..3 {
handles.push(thread::spawn(move || {
*counter.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..3 {
| ------------- inside of this loop
9 | handles.push(thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

A mensagem diz que counter já foi movido para dentro da closure em uma volta anterior do loop. Embrulhe o valor em Arc::new(...) e clone o Arc no começo do loop, como na página de Concorrência. Ou use thread::scope e tire o move. Se você tentar usar Rc em vez de Arc, vai receber error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely, ou seja, um Rc não pode ser enviado entre threads com segurança. O Rc é só para uma thread (veja Ponteiros inteligentes).

Crie um Vec com os números de 1 a 1000. Use thread::scope para dividi-lo em 4 pedaços de 250 e somar cada pedaço em sua própria thread. Imprima a soma de cada pedaço e o total geral. Dica: numbers.chunks(250) dá as slices para você. Junte os handles em um Vec e depois faça o join deles em ordem.

Ver solução
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in numbers.chunks(250) {
handles.push(s.spawn(move || chunk.iter().sum::<u64>()));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("somas dos pedaços: {:?}", sums);
println!("total: {}", sums.iter().sum::<u64>());
}

Saída:

somas dos pedaços: [31375, 93875, 156375, 218875]
total: 500500
▶ Experimente você mesmo

O move aqui só move chunk, que é uma pequena slice emprestada, para dentro de cada thread. O vetor em si continua emprestado, não movido, então você poderia usar numbers de novo depois do escopo.

Uma loja online dá um número para cada pedido, começando em 1000. Inicie 4 threads que pegam, cada uma, 3 números de pedido de um único AtomicU64 compartilhado. Junte os 12 números, ordene e imprima. Dica: fetch_add(1, Ordering::SeqCst) retorna o valor antes da soma, então ele entrega cada número exatamente uma vez.

Ver solução
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for _ in 0..4 {
handles.push(s.spawn(|| {
(0..3)
.map(|_| next_order.fetch_add(1, Ordering::SeqCst))
.collect::<Vec<u64>>()
}));
}
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
numbers.sort();
println!("{} pedidos: {:?}", numbers.len(), numbers);
}

Saída:

12 pedidos: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]
▶ Experimente você mesmo

Qual thread pega qual número muda de uma execução para outra, mas nenhum número é entregue duas vezes nem pulado. Ordenar faz a lista impressa ser a mesma toda vez.

  • O thread::scope garante que as suas threads terminam antes de ele acabar, então elas podem pegar dados locais emprestados sem Arc nem move.
  • O RwLock permite vários leitores ou um escritor. Ele ajuda com dados muito lidos; fora isso, um Mutex é mais simples.
  • Os atômicos (AtomicUsize, AtomicI64, AtomicBool) cuidam de contadores e flags sem trava. Use SeqCst na dúvida e Relaxed para contadores simples.
  • LazyLock e OnceLock criam um valor global exatamente uma vez, com segurança, mesmo quando várias threads pedem ao mesmo tempo.
  • Evite deadlocks: pegue as travas sempre na mesma ordem, mantenha os escopos das travas curtos e descarte as guardas cedo.

Teste o que aprendeu

1. Por que threads iniciadas com s.spawn dentro de thread::scope podem pegar variáveis locais emprestadas?

2. Qual afirmação sobre o RwLock é verdadeira?

3. Você precisa de um contador de visualizações compartilhado por várias threads. Qual é a boa escolha mais simples?

4. A thread A trava alice e depois bob. A thread B trava bob e depois alice. O que pode acontecer?

Pontuação: 0 / 4