Concorrenza in Rust
La concorrenza, cioè eseguire più lavori nello stesso momento, è il punto in cui molti linguaggi diventano davvero pericolosi: due thread che toccano gli stessi dati contemporaneamente possono corromperli in modi difficilissimi da scovare. È proprio il tipo di bug che le regole del prestito (borrowing) sono nate per impedire, e questa protezione si estende direttamente ai thread.
Avviare un thread
Sezione intitolata “Avviare un thread”use std::thread;
fn main() { let handle = thread::spawn(|| { for i in 1..=3 { println!("thread: {i}"); } });
for i in 1..=3 { println!("main: {i}"); }
handle.join().unwrap(); // aspetta che il thread finisca}thread::spawn prende una closure e la esegue su un nuovo thread, in parallelo con tutto il resto. .join() si blocca finché quel thread non ha finito: senza, main potrebbe terminare prima ancora che il thread avviato riesca a partire.
Spostare i dati in un thread
Sezione intitolata “Spostare i dati in un thread”Una closure passata a thread::spawn deve possedere completamente i dati che usa, perché potrebbe essere eseguita quando lo scope originale è già finito. Aggiungi la parola chiave move per ottenerlo:
use std::thread;
fn main() { let message = String::from("ciao da un thread");
let handle = thread::spawn(move || { println!("{message}"); });
handle.join().unwrap();}Output:
ciao da un threadSenza move, Rust non compila questo codice: non può garantire che message sia ancora valido nel momento in cui il nuovo thread lo usa davvero. move trasferisce l’ownership di message dentro la closure. È lo stesso «spostamento» (move) che hai visto nella pagina sull’ownership, solo che questa volta lo consegni a un thread invece che a un’altra variabile.
Condividere dati tra thread in sicurezza
Sezione intitolata “Condividere dati tra thread in sicurezza”I normali riferimenti non funzionano tra thread (non possono vivere più a lungo dello scope che li ha creati). Per condividere dati tra thread si combinano quindi due cose che conosci già: Arc (dalla pagina sugli smart pointer) per l’ownership condivisa, e un Mutex per garantire che un solo thread alla volta tocchi i dati:
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![];
for _ in 0..5 { let counter = Arc::clone(&counter); let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); }
for handle in handles { handle.join().unwrap(); }
println!("Risultato: {}", *counter.lock().unwrap());}Output:
Risultato: 5counter.lock() aspetta finché non può ottenere in sicurezza l’accesso esclusivo al valore, fa l’aggiornamento e poi lo rilascia automaticamente. Cinque thread incrementano lo stesso contatore e, grazie al Mutex, non si pestano mai i piedi: ottieni sempre 5, non un numero più piccolo dovuto a un aggiornamento perso.
Bloccare i dati condivisi è un modo per far collaborare i thread. L’altro modo comune è non condividere nulla e far sì che i thread si mandino messaggi. Ne parla la pagina sui canali.
Perché Rust la chiama «concorrenza senza paura»
Sezione intitolata “Perché Rust la chiama «concorrenza senza paura»”Nella maggior parte dei linguaggi niente ti impedisce di dimenticare di bloccare un Mutex, o di condividere per sbaglio tra thread un riferimento che non andrebbe condiviso. Il compilatore di Rust invece lo impone: il codice che causerebbe una data race tra thread semplicemente non compila. È lo stesso sistema di ownership e prestito che hai imparato prima in questo corso, applicato ai thread invece che a un singolo scope.
Pratica
Sezione intitolata “Pratica”1. Due thread, una somma
Sezione intitolata “1. Due thread, una somma”Somma i numeri da 1 a 100 usando due thread: uno somma da 1 a 50, l’altro da 51 a 100. Poi main somma i due risultati. Suggerimento: la closure di un thread può restituire un valore, e handle.join().unwrap() te lo consegna.
Mostra la soluzione
use std::thread;
fn main() { let first = thread::spawn(|| { let total: u32 = (1..=50).sum(); total }); let second = thread::spawn(|| { let total: u32 = (51..=100).sum(); total });
let a = first.join().unwrap(); let b = second.join().unwrap(); println!("{a} + {b} = {}", a + b);}Output:
1275 + 3775 = 5050Queste closure non usano variabili esterne, quindi non hanno bisogno di move. I due thread girano nello stesso momento, e join() aspetta ciascuno e ti restituisce il suo risultato.
2. Tutti firmano il libro degli ospiti
Sezione intitolata “2. Tutti firmano il libro degli ospiti”Avvia tre thread. Ognuno aggiunge una riga come "il thread 2 è passato di qui" a un unico Vec<String> condiviso. Quando tutti i thread hanno finito, stampa le righe in ordine. Suggerimento: usa Arc<Mutex<Vec<String>>> come nell’esempio del contatore, e alla fine ordina una copia, perché i thread possono finire in qualsiasi ordine.
Mostra la soluzione
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let book = Arc::new(Mutex::new(Vec::new())); let mut handles = vec![];
for id in 1..=3 { let book = Arc::clone(&book); handles.push(thread::spawn(move || { book.lock().unwrap().push(format!("il thread {id} è passato di qui")); })); }
for handle in handles { handle.join().unwrap(); }
let mut lines = book.lock().unwrap().clone(); lines.sort(); for line in lines { println!("{line}"); }}Output:
il thread 1 è passato di quiil thread 2 è passato di quiil thread 3 è passato di quiTogli la riga lines.sort() ed esegui il programma qualche volta: l’ordine potrebbe cambiare. Ordinare è il modo per ottenere sempre lo stesso output.
Riepilogo
Sezione intitolata “Riepilogo”thread::spawn(closure)esegue una closure su un nuovo thread;.join()aspetta che finisca.- Una closure passata a un thread di solito ha bisogno di
moveper prendere l’ownership completa dei dati che usa. - Per condividere dati tra thread si usa
Arc(ownership condivisa) insieme aMutex(accesso esclusivo):counter.lock()dà un accesso sicuro al valore, un thread alla volta. - Il compilatore rifiuta il codice che causerebbe una data race già in fase di compilazione, non durante l’esecuzione.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Perché una closure passata a thread::spawn di solito ha bisogno della parola chiave move?
2. A che cosa serve Arc<Mutex<T>>?
3. Che cosa può andare storto se dimentichi di chiamare .join() sull'handle di un thread?
4. Perché l'ordine delle righe "main:" e "thread:" può cambiare tra un'esecuzione e l'altra dello stesso programma?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- Using Threads to Run Code Simultaneously — The Rust Book (in inglese):
spawn,joine le closuremove, con esempi di come l’output si mescola. - Shared-State Concurrency — The Rust Book (in inglese):
MutexeArcnel dettaglio, compreso il motivo per cuiRcnon si può mandare a un altro thread. std::thread— documentazione della libreria standard (in inglese): tutto sui thread, compreso come dare loro un nome e gli scoped thread.Mutex— documentazione della libreria standard (in inglese): come funziona il blocco e cosa significa «poisoning» quando un thread va in panic mentre tiene il lock.- Threads — Rust by Example (in inglese): un esempio breve ed eseguibile che avvia e attende diversi thread.