SE VUOI PRENDERE LA CERTIFICAZIONE PER QUESTO CORSO CLICCA QUI
Concorrenza con Condivisione di Stato
La messaggistica è un buon modo per gestire la concorrenza, ma non è l’unico. Un altro metodo sarebbe far accedere a dati condivisi da più thread. Consideriamo questa parte dello slogan della documentazione del linguaggio Go: “non comunicare condividendo la memoria”.
Come sarebbe comunicare condividendo la memoria? Inoltre, perché gli appassionati della messaggistica caucionano contro l’uso della condivisione di memoria?
In un certo senso, i canali in qualsiasi linguaggio di programmazione sono simili alla proprietà singola, perché una volta che trasferisci un valore giù per un canale, non dovresti più usare quel valore. La concorrenza con memoria condivisa è come la proprietà multipla: più thread possono accedere alla stessa posizione di memoria contemporaneamente. Come hai visto nel Capitolo 15, dove i puntatori intelligenti hanno reso possibile la proprietà multipla, la proprietà multipla può aggiungere complessità perché questi proprietari diversi devono essere gestiti. Il sistema di tipi e le regole di proprietà di Rust aiutano molto a gestire questa gestione. Per un esempio, vediamo i mutex, una delle primitive di concorrenza più comuni per la memoria condivisa.
Utilizzo dei Mutex per Consentire l’Accesso ai Dati da Parte di un Thread alla Volta
Mutex è l’abbreviazione di mutua esclusione, ovvero un mutex consente a un solo thread di accedere ai dati in un determinato momento. Per accedere ai dati in un mutex, un thread deve prima segnalare che desidera l’accesso richiedendo di acquisire il blocco del mutex. Il blocco è una struttura dati che fa parte del mutex e tiene traccia di chi attualmente ha accesso esclusivo ai dati. Pertanto, il mutex viene descritto come che protegge i dati che detiene tramite il sistema di blocco.
I mutex hanno la reputazione di essere difficili da usare perché devi ricordare due regole:
css
Devi cercare di acquisire il blocco prima di utilizzare i dati.
Quando hai finito con i dati che il mutex protegge, devi sbloccare i dati in modo che altri thread possano acquisire il blocco.
Per una metafora del mondo reale per un mutex, immagina una tavola rotonda in una conferenza con un solo microfono. Prima che un partecipante alla tavola rotonda possa parlare, deve chiedere o segnalare che desidera usare il microfono. Quando ottiene il microfono, può parlare per quanto desidera e poi passare il microfono al prossimo partecipante alla tavola rotonda che richiede di parlare. Se un partecipante alla tavola rotonda dimentica di passare il microfono quando ha finito con esso, nessun altro può parlare. Se la gestione del microfono condiviso va male, la tavola rotonda non funzionerà come previsto!
La gestione dei mutex può essere estremamente difficile da fare correttamente, motivo per cui molte persone sono entusiaste dei canali. Tuttavia, grazie al sistema di tipi e alle regole di proprietà di Rust, non puoi sbagliare il blocco e lo sblocco. API di Mutex<T>
Come esempio di come utilizzare un mutex, iniziamo usando un mutex in un contesto single-threaded, come mostrato nel Listato 16-12:
rust
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5); {
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {:?}", m);
}
Listato 16-12: Esplorazione dell’API di Mutex<T> in un contesto single-threaded per semplicità
Come con molti tipi, creiamo un Mutex<T> utilizzando la funzione associata new. Per accedere ai dati all’interno del mutex, utilizziamo il metodo lock per acquisire il blocco. Questa chiamata bloccherà il thread corrente in modo che non possa fare alcun lavoro fino a quando non è il nostro turno di avere il blocco.
La chiamata a lock fallirebbe se un altro thread che tiene il blocco entrasse in panico. In tal caso, nessuno potrebbe mai ottenere il blocco, quindi abbiamo scelto di srotolare e far entrare in panico questo thread se ci troviamo in quella situazione.
Dopo aver acquisito il blocco, possiamo trattare il valore restituito, chiamato num in questo caso, come un riferimento mutabile ai dati interni. Il sistema di tipi garantisce che acquisiamo un blocco prima di utilizzare il valore in m. Il tipo di m è Mutex<i32>, non i32, quindi dobbiamo chiamare lock per poter utilizzare il valore i32. Non possiamo dimenticare; il sistema di tipi non ci permetterà di accedere all’i32 interno altrimenti.
Come potresti sospettare, Mutex<T> è un puntatore intelligente. Più precisamente, la chiamata a lock restituisce un puntatore intelligente chiamato MutexGuard, avvolto in un LockResult che abbiamo gestito con la chiamata a unwrap. Il puntatore intelligente MutexGuard implementa Deref per puntare ai nostri dati interni; il puntatore intelligente ha anche un’implementazione Drop che rilascia automaticamente il blocco quando un MutexGuard esce dallo scope, che avviene alla fine dello scope interno. Di conseguenza, non rischiamo di dimenticare di rilasciare il blocco e di bloccare il mutex dall’essere utilizzato da altri thread, perché il rilascio del blocco avviene automaticamente.
Dopo aver rilasciato il blocco, possiamo stampare il valore del mutex e vedere che siamo riusciti a modificare il i32 interno in 6. Condivisione di un Mutex<T> Tra Più Thread
Ora, proviamo a condividere un valore tra più thread utilizzando Mutex<T>. Creeremo 10 thread e ognuno incrementerà un valore del contatore di 1, quindi il contatore andrà da 0 a 10. Il prossimo esempio nel Listato 16-13 avrà un errore del compilatore e utilizzeremo quell’errore per apprendere di più sull’utilizzo di Mutex<T> e su come Rust ci aiuta a utilizzarlo correttamente.
rust
use std::sync::Mutex;
use std::thread; fn main() {
let counter = Mutex::new(0);
let mut handles = vec![]; for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); *num += 1;
});
handles.push(handle);
} for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Listato 16-13: Dieci thread ognuno incrementa un contatore protetto da un Mutex<T>
Creiamo una variabile counter per contenere un i32 all’interno di un Mutex<T>, come abbiamo fatto nel Listato 16-12. Successivamente, creiamo 10 thread iterando su un intervallo di numeri. Usiamo thread::spawn e diamo a tutti i thread la stessa chiusura: una che sposta il contatore nel thread, acquisisce un blocco sul Mutex<T> chiamando il metodo lock e quindi aggiunge 1 al valore nel mutex. Quando un thread finisce di eseguire la sua chiusura, num uscirà dallo scope e rilascerà il blocco in modo che un altro thread possa acquisirlo.
Nel thread principale, raccogliamo tutti i gestori join. Quindi, come abbiamo fatto nel Listato 16-2, chiamiamo join su ogni gestore per assicurarci che tutti i thread finiscano. A quel punto, il thread principale acquisirà il blocco e stampa il risultato di questo programma.
Abbiamo accennato che questo esempio non si compilerebbe. Ora scopriamo perché!
less
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9 | let handle = thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop
10 | let mut num = counter.lock().unwrap();
| ------- use occurs due to use in closure
Per maggiori informazioni su questo errore, prova `rustc --explain E0382`.
error: could not compile `shared-state` due to previous error
Il messaggio di errore indica che il valore counter è stato spostato nella precedente iterazione del loop. Rust ci sta dicendo che non possiamo spostare la proprietà del contatore nel blocco in più thread. Correggiamo l’errore del compilatore con un metodo di proprietà multipla che abbiamo discusso nel Capitolo 15. Proprietà Multipla con Più Thread
Nel Capitolo 15, abbiamo dato un valore a più proprietari utilizzando il puntatore intelligente Rc<T> per creare un valore conteggiato dai riferimenti. Facciamo lo stesso qui e vediamo cosa succede. Avvolgeremo il Mutex<T> in Rc<T> nel Listato 16-14 e cloneremo l’Rc<T> prima di spostare la proprietà nel thread.
rust
use std::rc::Rc;
use std::sync::Mutex;
use std::thread; fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![]; for _ in 0..10 {
let counter = Rc::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!("Result: {}", *counter.lock().unwrap());
}
Listato 16-14: Tentativo di utilizzare Rc<T> per consentire a più thread di possedere il Mutex<T>
Ancora una volta, compiliamo e otteniamo… errori diversi! Il compilatore ci sta insegnando molto.
less
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `[closure@src/main.rs:11:36: 11:43]`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
|
= help: within `[closure@src/main.rs:11:36: 11:43]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/std/src/thread/mod.rs:704:8
|
= note: required by this bound in `spawn`
Per maggiori informazioni su questo errore, prova `rustc --explain E0277`.
error: could not compile `shared-state` due to previous error
Wow, il messaggio di errore è molto verboso! Ecco la parte importante su cui concentrarsi: Rc<Mutex<i32>>
non può essere inviato tra i thread in modo sicuro. Il compilatore ci sta anche dicendo il motivo: il tratto Send
non è implementato per Rc<Mutex<i32>>
. Parleremo del tratto Send nella sezione successiva: è uno dei tratti che garantisce che i tipi che utilizziamo con i thread siano destinati all’uso in situazioni concorrenti.
Sfortunatamente, Rc<T> non è sicuro da condividere tra thread. Quando Rc<T> gestisce il conteggio dei riferimenti, aggiunge al conteggio per ogni chiamata a clone e sottrae dal conteggio quando ogni clone viene rilasciato. Ma non utilizza nessuna primitiva di concorrenza per garantire che le modifiche al conteggio non possano essere interrotte da un altro thread. Questo potrebbe portare a conteggi errati – bug sottili che potrebbero a loro volta causare memory leak o il rilascio di un valore prima che ne abbiamo finito con esso. Quello di cui abbiamo bisogno è un tipo esattamente come Rc<T> ma che apporti modifiche al conteggio dei riferimenti in modo sicuro tra thread. Conteggio dei Riferimenti Atomico con Arc<T>
Fortunatamente, Arc<T> è un tipo come Rc<T> che è sicuro da utilizzare in situazioni concorrenti. La a sta per atomico, il che significa che è un tipo conteggiato atomicamente. Gli atomi sono un tipo aggiuntivo di primitiva di concorrenza che non copriremo in dettaglio qui: vedere la documentazione della libreria standard per std::sync::atomic per ulteriori dettagli. A questo punto, devi solo sapere che gli atomi funzionano come tipi primitivi ma sono sicuri da condividere tra i thread.
Potresti chiederti perché tutti i tipi primitivi non sono atomici e perché i tipi della libreria standard non sono implementati per utilizzare Arc<T> per impostazione predefinita. Il motivo è che la sicurezza dei thread comporta un costo prestazionale che vuoi pagare solo quando ne hai davvero bisogno. Se stai eseguendo solo operazioni su valori all’interno di un singolo thread, il tuo codice può essere eseguito più velocemente se non deve far rispettare le garanzie fornite dagli atomi.
Torniamo al nostro esempio: Arc<T> e Rc<T> hanno la stessa API, quindi correggiamo il nostro programma cambiando la riga di utilizzo, la chiamata a new e la chiamata a clone. Il codice nel Listato 16-15 finalmente si compilerà e si eseguirà:
rust
use std::sync::{Arc, Mutex};
use std::thread; fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![]; for _ in 0..10 {
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!("Result: {}", *counter.lock().unwrap());
}
Listato 16-15: Utilizzo di un Arc<T> per avvolgere il Mutex<T> per poter condividere la proprietà tra più thread
Questo codice stamperà quanto segue:
makefile
Result: 10
Ce l’abbiamo fatta! Abbiamo contato da 0 a 10, il che potrebbe non sembrare molto impressionante, ma ci ha insegnato molto su Mutex<T> e sulla sicurezza dei thread. Potresti anche utilizzare la struttura di questo programma per eseguire operazioni più complicate rispetto a semplici incrementi di un contatore. Utilizzando questa strategia, puoi dividere un calcolo in parti indipendenti, dividere quelle parti tra i thread e quindi utilizzare un Mutex<T> per far sì che ogni thread aggiorni il risultato finale con la propria parte.
Nota che se stai eseguendo operazioni numeriche semplici, ci sono tipi più semplici dei tipi Mutex<T> forniti dal modulo std::sync::atomic della libreria standard. Questi tipi forniscono accesso atomico sicuro e concorrente ai tipi primitivi. Abbiamo scelto di utilizzare Mutex<T> con un tipo primitivo per questo esempio in modo da poterci concentrare su come funziona Mutex<T>. Similitudini Tra RefCell<T>/Rc<T> e Mutex<T>/Arc<T>
Potresti aver notato che counter è immutabile ma possiamo ottenere un riferimento mutabile al valore al suo interno; questo significa che Mutex<T> fornisce mutabilità interna, come fa la famiglia Cell. Allo stesso modo in cui abbiamo usato RefCell<T> nel Capitolo 15 per consentirci di mutare i contenuti all’interno di un Rc<T>, utilizziamo Mutex<T> per mutare i contenuti all’interno di un Arc<T>.
Un altro dettaglio da notare è che Rust non può proteggerti da tutti i tipi di errori logici quando usi Mutex<T>. Ricordi nel Capitolo 15 che l’utilizzo di Rc<T> comportava il rischio di creare cicli di riferimento, dove due valori Rc<T> si riferivano reciprocamente, causando memory leak. Allo stesso modo, Mutex<T> comporta il rischio di creare impasse. Queste si verificano quando un’operazione deve bloccare due risorse e due thread hanno acquisito ciascuno un blocco, costringendoli ad aspettarsi l’un l’altro per sempre. Se sei interessato alle impasse, prova a creare un programma Rust che abbia un’impasse; quindi ricerca le strategie di mitigazione delle impasse per i mutex in qualsiasi linguaggio e prova a implementarle in Rust. La documentazione dell’API della libreria standard per Mutex<T> e MutexGuard offre informazioni utili.
Concludiamo questo capitolo parlando dei tratti Send e Sync e su come possiamo utilizzarli con tipi personalizzati.