Stato condiviso in Rust: RwLock, atomici e scoped thread
Nella pagina sulla concorrenza hai condiviso un contatore tra thread con Arc<Mutex<T>>: Arc permette a tanti thread di possedere il valore, e Mutex fa sì che lo tocchi uno solo alla volta. Nella pagina sui canali hai evitato del tutto la condivisione mandando messaggi. Sono due ottimi strumenti, e con loro puoi costruire quasi tutto.
Però pensa a un Mutex come all’unica chiave del bagno di un ufficio. Funziona, ma è eccessivo se vuoi solo leggere la bacheca in corridoio. La libreria standard di Rust ha qualche strumento in più, ognuno più adatto a un certo lavoro. In questa pagina ne conoscerai quattro: gli scoped thread, RwLock, gli atomici e OnceLock/LazyLock. Imparerai anche a evitare l’unico bug che il compilatore non può scovare al posto tuo: il deadlock.
Scoped thread: prendere in prestito invece di spostare
Sezione intitolata “Scoped thread: prendere in prestito invece di spostare”Con thread::spawn di solito ti serve move, e spesso anche Arc. Questo perché un thread avviato potrebbe continuare a girare quando main è già andato avanti, quindi Rust non gli permette di prendere in prestito le tue variabili locali.
std::thread::scope risolve il problema con una promessa: ogni thread avviato dentro lo scope finisce prima che lo scope termini. Visto che i thread non possono sopravvivere ai tuoi dati, possono semplicemente prenderli in prestito. Niente Arc, niente move, niente clonazioni.
use std::thread;
fn main() { let shop = "Bottega sotto casa"; 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}: mattina {am}, pomeriggio {pm}, giornata intera {}", am + pm); println!("ancora mio: {:?}", sales);}Output:
Bottega sotto casa: mattina 54, pomeriggio 63, giornata intera 117ancora mio: [12, 7, 30, 5, 18, 22, 9, 14]Ecco cosa succede:
thread::scope(|s| { ... })apre lo scope. Avvii i thread cons.spawn(...)invece dithread::spawn(...).- Le due closure prendono in prestito
morningeafternoon, che sono solo slice disales. Non si copia niente. - Qualunque cosa restituisca la closure dello scope (qui una tupla con due somme) diventa il valore di
thread::scope. - Dopo lo scope,
salesappartiene ancora amain, quindi puoi continuare a usarlo.
Anche se ti dimentichi di chiamare join(), lo scope aspetta tutti i suoi thread alla } di chiusura. È proprio questa promessa a rendere sicuro il prestito.
RwLock: tanti lettori o un solo scrittore
Sezione intitolata “RwLock: tanti lettori o un solo scrittore”Immagina la lavagna del menu in un bar. Tanti clienti possono leggerla nello stesso momento, e va benissimo. Ma quando il titolare cambia un prezzo, tutti devono aspettare un attimo finché la nuova lavagna non è pronta.
Ecco cos’è un RwLock («read-write lock», lock di lettura e scrittura). Ha due ingressi:
read()ti dà una guardia di lettura condivisa. Tanti thread possono tenerne una nello stesso momento.write()ti dà una guardia di scrittura esclusiva. Mentre uno scrittore la tiene, nessun altro può leggere o scrivere.
Un Mutex non fa questa distinzione: anche due lettori devono fare a turno. Ecco una cache di impostazioni che diversi gestori di richieste leggono, e che un amministratore aggiorna una volta:
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("chiaro"), max_upload_mb: 10, }));
// Quattro gestori di richieste leggono le impostazioni nello stesso momento. 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(); // condiviso: anche gli altri possono leggere format!("richiesta {id}: tema={}, limite={}MB", s.theme, s.max_upload_mb) })); } for handle in handles { println!("{}", handle.join().unwrap()); }
// Un amministratore cambia le impostazioni una volta. { let mut s = settings.write().unwrap(); // esclusivo: tutti gli altri aspettano s.theme = String::from("scuro"); s.max_upload_mb = 25; } // qui la guardia di scrittura viene eliminata, quindi il lock è di nuovo libero
let s = settings.read().unwrap(); println!("dopo l'aggiornamento: tema={}, limite={}MB", s.theme, s.max_upload_mb);}Output:
richiesta 1: tema=chiaro, limite=10MBrichiesta 2: tema=chiaro, limite=10MBrichiesta 3: tema=chiaro, limite=10MBrichiesta 4: tema=chiaro, limite=10MBdopo l'aggiornamento: tema=scuro, limite=25MBI thread possono finire in qualsiasi ordine, ma l’output è sempre lo stesso. Ogni thread restituisce la sua riga invece di stamparla, e main le stampa nell’ordine in cui fa join.
Quando RwLock batte Mutex? Quando le letture sono frequenti, le scritture rare, e ogni lettura richiede un po’ di tempo (per esempio cercare in una cache grande). Allora i lettori girano davvero fianco a fianco.
Quando no? Se le letture sono brevissime, o le scritture avvengono di continuo, un RwLock è solo un Mutex più complicato. Fa un lavoro di contabilità in più, e lettori e scrittori finiscono comunque quasi sempre per aspettarsi a vicenda. Se non sei sicuro, parti da un Mutex. È più semplice, e puoi cambiare in seguito se le misurazioni mostrano che il lock è un collo di bottiglia.
Atomici: contatori senza lock
Sezione intitolata “Atomici: contatori senza lock”Per un singolo numero o un singolo flag vero/falso, anche un lock è più di quanto serva. I tipi atomici come AtomicUsize, AtomicI64 e AtomicBool possono essere modificati in sicurezza da tanti thread contemporaneamente, senza nessun lock. La CPU esegue ogni operazione in un unico passo indivisibile, quindi nessun aggiornamento va perso.
Qui 8 thread registrano ciascuno 1000 visualizzazioni di pagina. Contano anche i voti: un voto positivo, poi tre negativi, e così via. Il totale dei voti diventa negativo, ed è per questo che è un AtomicI64 e non un tipo senza segno:
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 } } }); } }); // qui tutti gli 8 thread hanno finito
println!("visualizzazioni: {}", page_views.load(Ordering::Relaxed)); println!("voti netti: {}", net_votes.load(Ordering::Relaxed));}Output:
visualizzazioni: 8000voti netti: -4000Nota che page_views non è mut. Gli atomici si modificano attraverso un riferimento condiviso, ed è proprio per questo che tanti thread possono usarli insieme. I metodi che userai di più:
fetch_add(n, ordering)/fetch_sub(n, ordering)sommano o sottraggono, e restituiscono il valore vecchio.load(ordering)legge il valore attuale.store(valore, ordering)lo sovrascrive.
Che cos’è Ordering?
Sezione intitolata “Che cos’è Ordering?”Ogni operazione atomica chiede un Ordering. Dice alla CPU e al compilatore quanto rigidamente questa operazione deve restare in ordine rispetto alle operazioni di memoria intorno. La storia completa è profonda, ma da principiante ti bastano due regole:
- Se non sei sicuro, usa
Ordering::SeqCst. È la scelta più rigida e più sicura. Ordering::Relaxedva bene per un semplice contatore che leggi solo alla fine, come quello qui sopra. Il totale resta esattamente giusto.
Se un giorno vorrai approfondire, il libro gratuito Rust Atomics and Locks (link in fondo) spiega gli ordering in modo chiaro.
Un flag di stop
Sezione intitolata “Un flag di stop”Un AtomicBool è un ottimo segnale di «per favore, fermati». Qui un worker in background continua a fare salvataggi automatici finché main non cambia il 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) { // qui fingiamo di salvare automaticamente il documento thread::sleep(Duration::from_millis(5)); } "worker di salvataggio automatico fermato in modo pulito" });
thread::sleep(Duration::from_millis(30)); // main fa altro lavoro stop.store(true, Ordering::SeqCst); // chiede al worker di fermarsi worker.join().unwrap() });
println!("{report}");}Output:
worker di salvataggio automatico fermato in modo pulitoIl worker controlla il flag a ogni giro del suo ciclo. Finisce il passo in corso e poi esce da solo, il che è molto più gentile che uccidere un thread a metà del salvataggio di un file.
OnceLock e LazyLock: prepararlo una volta sola
Sezione intitolata “OnceLock e LazyLock: prepararlo una volta sola”A volte vuoi un unico valore globale che ogni thread possa leggere: una tabella di consultazione, il nome dell’app, una configurazione caricata all’avvio. La parte difficile è costruirlo. E se due thread provassero a costruirlo nello stesso momento?
std::sync::LazyLock e std::sync::OnceLock risolvono il problema. Il valore viene creato esattamente una volta, la prima volta che qualcuno ne ha bisogno. Se altri thread lo chiedono mentre è in costruzione, aspettano e basta, e poi tutti condividono lo stesso valore.
use std::collections::HashMap;use std::sync::{LazyLock, OnceLock};use std::thread;
// LazyLock: la ricetta è scritta proprio qui, e parte al primo utilizzo.static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| { println!("costruisco la tabella delle valute..."); HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])});
// OnceLock: parte vuoto, e decidi tu come riempirlo nel punto in cui lo usi.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("Rusty Shop")) })}
fn main() { let mut handles = Vec::new(); for country in ["US", "JP", "DE", "BR"] { handles.push(thread::spawn(move || { format!("{} in {country} fa pagare in {}", store_name(), CURRENCIES[country]) })); } for handle in handles { println!("{}", handle.join().unwrap()); }}Output:
costruisco la tabella delle valute...Rusty Shop in US fa pagare in USDRusty Shop in JP fa pagare in JPYRusty Shop in DE fa pagare in EURRusty Shop in BR fa pagare in BRLQuattro thread vogliono la tabella più o meno nello stesso momento, eppure «costruisco la tabella delle valute…» viene stampato una volta sola. E visto che sono valori static che vivono per tutto il programma, i thread non hanno bisogno di Arc.
Quale scegliere?
LazyLockquando sai come costruire il valore nel punto in cui lo dichiari. Sembra una normale variabile globale.OnceLockquando il valore si decide più tardi, per esempio da un’opzione della riga di comando. Lo riempi conget_or_init(...)oset(...)(esetfallisce educatamente se è già pieno).
Fanno parte entrambi della libreria standard. LazyLock è arrivato con Rust 1.80, quindi il codice più vecchio usa spesso i crate once_cell o lazy_static per lo stesso scopo.
Evitare i deadlock
Sezione intitolata “Evitare i deadlock”Il compilatore blocca le data race, ma c’è un bug di concorrenza che non può scovare: il deadlock (stallo). Succede quando i thread si aspettano a vicenda per sempre e il programma si congela. Nessun errore, nessun crash, niente.
La ricetta classica sono due lock presi in ordini diversi. Immagina due persone che hanno bisogno entrambe della penna e del quaderno. Una prende la penna, l’altra il quaderno, e ognuna aspetta educatamente che l’altra molli la presa. Per 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 tiene alice, aspetta bob. // B tiene bob, aspetta alice. Bloccati!Tre abitudini ti tengono al sicuro:
- Prendi sempre i lock nello stesso ordine. Se ogni thread blocca
aliceprima dibob(per esempio in ordine di ID del conto), il ciclo non può verificarsi. - Tieni brevi gli scope dei lock. Tieni un lock solo il tempo necessario per leggere o modificare i dati. Non tenerlo mai durante un lavoro lento come una chiamata di rete, la scrittura di un file o un calcolo lungo. Tutti gli altri thread restano bloccati ad aspettare per tutto quel tempo.
- Elimina presto le guardie. Una guardia sblocca il lock quando viene eliminata. Puoi farla finire con un blocco interno
{ ... }(come lo scrittore delRwLockqui sopra) oppure chiamare tudrop(guard).
Ecco l’abitudine 3 in azione. Ogni worker prende un lavoro da una coda condivisa, sblocca la coda e solo dopo fa la parte lenta:
use std::sync::Mutex;use std::thread;
fn main() { let queue = Mutex::new(vec!["ridimensiona foto", "invia email", "crea report"]); 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); // sblocca la coda PRIMA della parte lenta
if let Some(job) = job { let result = format!("fatto: {job}"); // immagina che ci voglia qualche secondo finished.lock().unwrap().push(result); } }); } });
// Lo scope è finito, quindi nessun thread usa più `finished`. let mut done = finished.into_inner().unwrap(); done.sort(); // i thread finiscono in qualsiasi ordine for line in done { println!("{line}"); }}Output:
fatto: crea reportfatto: invia emailfatto: ridimensiona fotoSenza drop(jobs), la guardia vivrebbe fino alla fine della closure. A quel punto il lavoro «lento» avverrebbe mentre la coda è ancora bloccata, e i tre worker girerebbero uno dopo l’altro invece che fianco a fianco.
Un caso subdolo da conoscere: in while let Some(job) = queue.lock().unwrap().pop() { ... } la guardia vive per tutto il corpo del ciclo. Quindi la coda resta bloccata mentre lavori sul job. Prendi prima il lavoro con un’istruzione let separata, come fa l’esempio.
Quale strumento uso?
Sezione intitolata “Quale strumento uso?”| Vuoi… | Usa |
|---|---|
| Mandare lavori o risultati da un thread a un altro | Un canale (mpsc) |
| Far leggere e modificare lo stesso valore a tanti thread | Arc<Mutex<T>> |
| Condividere dati letti spesso e modificati di rado | Arc<RwLock<T>> |
| Contare cose, o condividere un semplice flag acceso/spento | Atomici (AtomicUsize, AtomicBool, …) |
| Costruire un valore globale una volta, e poi solo leggerlo | LazyLock o OnceLock |
Far prendere in prestito dati locali ai thread, senza Arc |
thread::scope |
Si possono combinare liberamente. Gli esempi di questa pagina hanno già unito thread::scope con gli atomici e con un Mutex. Scegli lo strumento più semplice che va bene, e non preoccuparti di sbagliare: cambiare in seguito di solito è una modifica piccola.
Errori comuni
Sezione intitolata “Errori comuni”Prendere in prestito una variabile locale in thread::spawn. Sembra innocuo:
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` keywordIl compilatore dice che la closure potrebbe sopravvivere alla funzione, mentre prende in prestito sales. Tu sai che chiami join() subito, ma thread::spawn non può saperlo: per quanto lo riguarda, il thread potrebbe girare per sempre. Hai due soluzioni: aggiungere move (ma poi main non può più usare sales), oppure usare thread::scope, che può prendere in prestito:
let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());Dimenticare Arc. Senza Arc, la closure move del primo thread si prende il Mutex, e per il successivo non resta niente:
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 loopQui rustc spiega che counter è già stato spostato nella closure durante un giro precedente del ciclo. Avvolgilo in Arc::new(...) e clona l’Arc all’inizio del ciclo, come nella pagina sulla concorrenza. Oppure usa thread::scope e togli il move. Se usi Rc invece di Arc, ottieni error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely: Rc è solo per un thread (vedi Smart pointer).
Pratica
Sezione intitolata “Pratica”1. Somma in quattro blocchi
Sezione intitolata “1. Somma in quattro blocchi”Crea un Vec con i numeri da 1 a 1000. Usa thread::scope per dividerlo in 4 blocchi da 250 e sommare ogni blocco nel suo thread. Stampa la somma di ogni blocco e il totale complessivo. Suggerimento: numbers.chunks(250) ti dà le slice. Raccogli gli handle in un Vec, poi fai join in ordine.
Mostra la soluzione
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!("somme dei blocchi: {:?}", sums); println!("totale: {}", sums.iter().sum::<u64>());}Output:
somme dei blocchi: [31375, 93875, 156375, 218875]totale: 500500Qui move sposta in ogni thread solo chunk, che è una piccola slice presa in prestito. Il vettore resta preso in prestito, non spostato, quindi potresti usare di nuovo numbers dopo lo scope.
2. Numeri d’ordine unici
Sezione intitolata “2. Numeri d’ordine unici”Un negozio online dà a ogni ordine un numero, a partire da 1000. Avvia 4 thread che prendono ciascuno 3 numeri d’ordine da un unico AtomicU64 condiviso. Raccogli tutti i 12 numeri, ordinali e stampali. Suggerimento: fetch_add(1, Ordering::SeqCst) restituisce il valore prima della somma, quindi consegna ogni numero esattamente una volta.
Mostra la soluzione
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!("{} ordini: {:?}", numbers.len(), numbers);}Output:
12 ordini: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]Quale thread riceve quale numero cambia da un’esecuzione all’altra, ma nessun numero viene mai consegnato due volte o saltato. Ordinare rende la lista stampata sempre uguale.
Riepilogo
Sezione intitolata “Riepilogo”thread::scopegarantisce che i suoi thread finiscano prima che lui termini, quindi possono prendere in prestito dati locali senzaArcnémove.RwLockpermette tanti lettori oppure un solo scrittore. Aiuta con i dati letti spesso; negli altri casi unMutexè più semplice.- Gli atomici (
AtomicUsize,AtomicI64,AtomicBool) gestiscono contatori e flag senza lock. UsaSeqCstquando non sei sicuro,Relaxedper i contatori semplici. LazyLockeOnceLockcreano un valore globale esattamente una volta, in sicurezza, anche quando tanti thread lo chiedono nello stesso momento.- Evita i deadlock: prendi i lock sempre nello stesso ordine, tieni brevi gli scope dei lock ed elimina presto le guardie.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Perché i thread avviati con s.spawn dentro thread::scope possono prendere in prestito variabili locali?
2. Quale affermazione su RwLock è vera?
3. Ti serve un contatore di visualizzazioni condiviso da tanti thread. Qual è la scelta buona più semplice?
4. Il thread A blocca alice e poi bob. Il thread B blocca bob e poi alice. Che cosa può succedere?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- Shared-State Concurrency — The Rust Book (in inglese): il capitolo ufficiale su
MutexeArc, la base su cui si costruisce questa pagina. std::thread::scope— documentazione della libreria standard (in inglese): come funzionano gli scoped thread, con altri esempi.std::sync::RwLock— documentazione della libreria standard (in inglese): guardie di lettura e scrittura, poisoning e una nota su come l’equità dipende dal sistema operativo.std::sync::atomic— documentazione della libreria standard (in inglese): tutti i tipi atomici e una breve panoramica sugli ordering della memoria.- Rust Atomics and Locks di Mara Bos (in inglese): un libro online gratuito che spiega passo dopo passo gli atomici, l’ordering della memoria e come sono costruiti i lock.