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.
Scoped threads: emprestar em vez de mover
Seção intitulada “Scoped threads: emprestar em vez de mover”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 117ainda é meu: [12, 7, 30, 5, 18, 22, 9, 14]O que está acontecendo:
thread::scope(|s| { ... })abre o escopo. Você inicia as threads coms.spawn(...)em vez dethread::spawn(...).- As duas closures pegam emprestados
morningeafternoon, que são só slices desales. 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,
salescontinua pertencendo aomain, 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.
RwLock: vários leitores ou um escritor
Seção intitulada “RwLock: vários leitores ou um escritor”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=10MBrequisição 2: tema=claro, limite=10MBrequisição 3: tema=claro, limite=10MBrequisição 4: tema=claro, limite=10MBdepois da atualização: tema=escuro, limite=25MBAs 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.
Atômicos: contadores sem trava
Seção intitulada “Atômicos: contadores sem trava”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: 8000saldo de votos: -4000Repare 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.
O que é Ordering?
Seção intitulada “O que é Ordering?”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::Relaxedserve 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.
Uma flag de parada
Seção intitulada “Uma flag de parada”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 problemasO 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.
OnceLock e LazyLock: configure uma vez só
Seção intitulada “OnceLock e LazyLock: configure uma vez só”À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 USDLoja Rusty em JP: preços em JPYLoja Rusty em DE: preços em EURLoja Rusty em BR: preços em BRLAs 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?
LazyLockquando você sabe como construir o valor no lugar onde o declara. Ele parece uma variável global comum.OnceLockquando o valor só é decidido depois, por exemplo a partir de uma flag de linha de comando. Você o preenche comget_or_init(...)ouset(...)(e osetfalha 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.
Evitando deadlocks
Seção intitulada “Evitando deadlocks”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 Blet 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:
- Pegue as travas sempre na mesma ordem. Se toda thread travar
aliceantes debob(por exemplo, em ordem de ID da conta), o ciclo não acontece. - 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.
- Descarte as guardas cedo. Uma guarda destrava quando é descartada (dropped). Você pode encerrá-la com um bloco interno
{ ... }(como o escritor doRwLocklá em cima) ou chamardrop(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-mailfeito: gerar relatóriofeito: redimensionar fotoSem 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.
Qual ferramenta eu uso?
Seção intitulada “Qual ferramenta eu uso?”| 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.
Erros comuns
Seção intitulada “Erros comuns”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` keywordO 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 loopA 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).
Prática
Seção intitulada “Prática”1. Soma em quatro pedaços
Seção intitulada “1. Soma em quatro pedaços”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: 500500O 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.
2. Números de pedido únicos
Seção intitulada “2. Números de pedido únicos”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]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::scopegarante que as suas threads terminam antes de ele acabar, então elas podem pegar dados locais emprestados semArcnemmove. - O
RwLockpermite vários leitores ou um escritor. Ele ajuda com dados muito lidos; fora isso, umMutexé mais simples. - Os atômicos (
AtomicUsize,AtomicI64,AtomicBool) cuidam de contadores e flags sem trava. UseSeqCstna dúvida eRelaxedpara contadores simples. LazyLockeOnceLockcriam 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
Seção intitulada “Teste o que aprendeu”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
Para saber mais
Seção intitulada “Para saber mais”- Shared-State Concurrency — The Rust Book (em inglês): o capítulo oficial sobre
MutexeArc, a base sobre a qual esta página foi construída. std::thread::scope— documentação da biblioteca padrão (em inglês): como as scoped threads funcionam, com mais exemplos.std::sync::RwLock— documentação da biblioteca padrão (em inglês): guardas de leitura e escrita, poisoning (envenenamento) e uma nota sobre como a justiça entre threads depende do sistema operacional.std::sync::atomic— documentação da biblioteca padrão (em inglês): todos os tipos atômicos e uma visão geral rápida das orderings de memória.- Rust Atomics and Locks, de Mara Bos (em inglês): um livro online gratuito que explica atômicos, ordering de memória e como as travas são construídas, passo a passo.