Concorrenza a Stato Condiviso
Il passaggio di messaggi è un buon modo per gestire la concorrenza, ma non è l’unico. Un altro metodo potrebbe essere quello di far accedere più thread agli stessi dati condivisi. Considera ancora una volta questa parte dello slogan della documentazione del linguaggio Go: “Non comunicare condividendo la memoria”.
Come funzionerebbe comunicare condividendo la memoria? Inoltre, perché gli appassionati della tecnica del passaggio di messaggi raccomandano di non utilizzare la condivisione della memoria?
In un certo senso, i canali in qualsiasi linguaggio di programmazione sono simili alla proprietà singola, perché una volta che trasferisci un valore lungo un canale, non dovresti più utilizzarlo. La concorrenza della memoria condivisa è simile alla proprietà multipla: più thread possono accedere alla stessa posizione di memoria nello stesso momento. Come hai visto nel Capitolo 15, dove i puntatori intelligenti rendono possibile la ownership multipla, questo può aggiungere complessità perché questi diversi proprietari devono essere gestiti. Il sistema dei type e le regole di ownership di Rust aiutano molto a gestire correttamente questo aspetto. Per fare un esempio, vediamo i mutex, uno dei type primitivi di concorrenza più comuni per la memoria condivisa.
Controllare l’Accesso con i Mutex
Mutex è l’abbreviazione di mutual exclusion (mutua esclusione), ovvero un mutex permette a un solo thread di accedere ad alcuni dati in un determinato momento. Per accedere ai dati di un mutex, un thread deve prima segnalare che vuole accedervi chiedendo di acquisire il blocco del mutex. Il blocco (lock) è una struttura di dati che fa parte del mutex e che tiene traccia di chi attualmente ha accesso esclusivo ai dati. Per questo motivo, il mutex può esser visto come un custode di dati a cui garantisce accesso tramite il sistema di blocco.
I mutex hanno la reputazione di essere difficili da usare perché devi ricordare due regole:
- Devi cercare di acquisire il blocco prima di utilizzare i dati.
- Quando hai finito di utilizzare i dati che il mutex custodisce, devi sbloccare i dati in modo che altri thread possano acquisirne il blocco.
Per una metafora del mondo reale di un mutex, immagina una tavola rotonda a una conferenza con un solo microfono. Prima che un relatore possa parlare, deve chiedere o segnalare che vuole usare il microfono. Quando ottiene il microfono, può parlare per tutto il tempo che vuole e poi passare il microfono al relatore successivo che chiede di parlare. Se un relatore dimentica di passare il microfono quando ha finito, nessun altro potrà parlare. Se chi gestisce la condivisione del microfono condiviso non fa il suo lavoro correttamente, la tavola rotonda non funzionerà come previsto!
La gestione dei mutex può essere incredibilmente complicata, per questo molte persone sono entusiaste dei canali. Tuttavia, grazie al sistema dei type e alle regole di ownership di Rust, non è possibile sbagliare il blocco e lo sblocco.
L’API di Mutex<T>
Come esempio di utilizzo di un mutex, iniziamo con l’utilizzo di un mutex in un contesto a thread singolo, come mostrato nel Listato 16-12.
use std::sync::Mutex; fn main() { let m = Mutex::new(5); { let mut num = m.lock().unwrap(); *num = 6; } println!("m = {m:?}"); }
Mutex<T>
in un contesto a thread singolo per semplicitàCome per molti altri type, 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 svolgere alcuna attività finché non sarà il
nostro turno di avere il blocco.
La chiamata a lock
fallirebbe se un altro thread che detiene il lock andasse
in panic. In questo caso, nessuno sarebbe in grado di ottenere il lock, quindi
abbiamo scelto di usare unwrap
e di far andare in panic questo thread se
ci troviamo in quella situazione.
Dopo aver acquisito il blocco, possiamo trattare il valore di ritorno, chiamato
num
in questo caso, come un reference mutabile ai dati all’interno. Il
sistema dei type assicura che acquisiamo un blocco prima di utilizzare il
valore in m
. Il type di m
è Mutex<i32>
, non i32
, quindi dobbiamo
chiamare lock
per poter utilizzare il valore i32
. Non possiamo
dimenticarcene; altrimenti il sistema dei type non ci permetterà di accedere
al valore interno i32
.
La chiamata a lock
restituisce un type chiamato MutexGuard
, incapsulato in
un LockResult
che abbiamo gestito con la chiamata a unwrap
. Il type
MutexGuard
implementa Deref
per puntare ai nostri dati interni; il type ha
anche un’implementazione Drop
che rilascia automaticamente il blocco quando un
MutexGuard
esce dallo scope, cosa che accade alla fine dello scope
interno. Di conseguenza, non rischiamo di dimenticarci di rilasciare il blocco e
di bloccare l’utilizzo del mutex da parte di 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 cambiare l’interno i32
in 6
.
Condividere Accesso a Mutex<T>
Ora proviamo a condividere un valore tra più thread utilizzando Mutex<T>
.
Avvieremo 10 thread e faremo in modo che ognuno di essi incrementi il valore
di un contatore di 1, in modo che il contatore vada da 0 a 10. L’esempio nel
Listato 16-13 avrà un errore del compilatore, che useremo per imparare di più
sull’uso di Mutex<T>
e su come Rust ci aiuta a usarlo correttamente.
use std::sync::Mutex;
use std::thread;
fn main() {
let contatore = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = contatore.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Risultato: {}", *contatore.lock().unwrap());
}
Mutex<T>
Creiamo una variabile contatore
per contenere un i32
all’interno di un
Mutex<T>
, come abbiamo fatto nel Listato 16-12. Poi 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 poi aggiunge 1
al valore nel mutex. Quando un thread termina l’esecuzione della sua chiusura,
num
uscirà dallo scope e rilascerà il blocco in modo che un altro thread
possa acquisirlo.
Nel thread principale, sugli handle dei thread raccolti in un vettore,
come fatto nel Listato 16-2, chiamiamo join
su ognuno di essi per assicurarci
che tutti i thread finiscano. A quel punto, il thread principale acquisirà
il blocco e stamperà il risultato di questo programma.
Abbiamo accennato al fatto che questo esempio non sarebbe stato compilato. Ora scopriamo perché!
$ cargo run
Compiling stato-condiviso v0.1.0 (file:///progetti/stato-condiviso)
error[E0382]: borrow of moved value: `contatore`
--> src/main.rs:21:32
|
5 | let contatore = Mutex::new(0);
| --------- move occurs because `contatore` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let handle = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Risultato: {}", *contatore.lock().unwrap());
| ^^^^^^^^^ value borrowed here after move
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = contatore.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = value.unwrap();
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `stato-condiviso` (bin "stato-condiviso") due to 1 previous error
Il messaggio di errore indica che il valore contatore
è stato spostato
nell’iterazione precedente del ciclo. Rust ci sta dicendo che non possiamo
spostare la ownership del blocco contatore
in più thread. Risolviamo
l’errore del compilatore con il metodo della ownership multipla di cui abbiamo
parlato nel Capitolo 15.
Ownership Multipla con Thread Multipli
Nel Capitolo 15, abbiamo dato un valore a più proprietari utilizzando il
puntatore intelligente Rc<T>
per creare un conteggio di reference. Facciamo
lo stesso qui e vediamo cosa succede. Incapsuleremo il Mutex<T>
in Rc<T>
nel
Listato 16-14 e cloneremo Rc<T>
prima di spostare la ownership al thread.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let contatore = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let contatore = Rc::clone(&contatore);
let handle = thread::spawn(move || {
let mut num = contatore.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Risultato: {}", *contatore.lock().unwrap());
}
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.
$ cargo run
Compiling stato-condiviso v0.1.0 (file:///progetti/stato-condiviso)
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 = contatore.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/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1
For more information about this error, try `rustc --explain E0277`.
error: could not compile `stato-condiviso` (bin "stato-condiviso") due to 1 previous error
Wow, questo messaggio di errore è molto prolisso! Ecco la parte importante su
cui concentrarsi: `Rc<Mutex<i32>>` cannot be sent between threads safely
(Rc<Mutex<i32>>
non può essere inviato tra i thread in modo sicuro). Il
compilatore ci dice anche il motivo: the trait `Send` is not implemented for `Rc<Mutex<i32>>` (
(il trait Send
non è implementato per
Rc<Mutex<i32>>
). Parleremo di Send
nella prossima sezione: è uno dei trait
che garantisce che i type che utilizziamo con i thread siano pensati per
l’uso in situazioni concorrenti.
Sfortunatamente, Rc<T>
non è sicuro da condividere tra i thread. Quando
Rc<T>
gestisce il conteggio dei reference, aggiunge al conteggio per ogni
chiamata a clone
e sottrae dal conteggio quando ogni clone viene rilasciato.
Ma non utilizza alcun type primitivo di concorrenza per assicurarsi che le
modifiche al conteggio non possano essere interrotte da un altro thread.
Questo potrebbe portare a conteggi sbagliati; bug che potrebbero a loro volta
portare a perdite di memoria o alla de-allocazione di un valore prima che
abbiamo finito di usarlo. Ciò di cui abbiamo bisogno è un type che sia
esattamente come Rc<T>
, ma che apporti modifiche al conteggio dei reference
in modo sicuro quando usato con i thread.
Conteggio di Reference Atomico con Arc<T>
Fortunatamente, Arc<T>
è un type come Rc<T>
che è sicuro da usare in
situazioni di concorrenza. La A sta per atomico, cioè è un type contatore
di reference atomico. Gli atomici sono un ulteriore type di primitivo di
concorrenza che non tratteremo in dettaglio in questa sede: per maggiori
dettagli, consulta la documentazione della libreria standard per
std::sync::atomic
. A questo punto, ti basterà sapere
che gli atomici funzionano come i type primitivi ma sono sicuri da condividere
tra i thread.
Potresti chiederti perché tutti i type primitivi non sono atomici e perché i
type della libreria standard non sono implementati in modo da utilizzare
Arc<T>
come impostazione predefinita. Il motivo è che la sicurezza dei
thread comporta una penalizzazione delle prestazioni che vorrai scegliere di
usare solo quando ne hai veramente bisogno. Se stai eseguendo operazioni su
valori all’interno di un singolo thread, il tuo codice può funzionare più
velocemente se non deve applicare le garanzie che gli atomici forniscono.
Torniamo al nostro esempio: Arc<T>
e Rc<T>
hanno la stessa API, quindi
correggiamo il nostro programma cambiando la riga use
, la chiamata a new
e
la chiamata a clone
. Il codice nel Listato 16-15 verrà finalmente compilato ed
eseguito.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let contatore = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let contatore = Arc::clone(&contatore); let handle = thread::spawn(move || { let mut num = contatore.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Risultato: {}", *contatore.lock().unwrap()); }
Arc<T>
per incapsulare il Mutex<T>
per poter condividere la ownership tra più threadQuesto codice stamperà quanto segue:
Risultato: 10
Ce l’abbiamo fatta! Abbiamo contato da 0 a 10, il che può non sembrare molto
impressionante, ma ci ha insegnato molto su Mutex<T>
e sulla sicurezza dei
thread. Puoi anche utilizzare la struttura di questo programma per fare
operazioni più complicate del semplice incremento di un contatore. Utilizzando
questa strategia, puoi dividere un calcolo in parti indipendenti, suddividere
queste parti tra i vari thread e poi utilizzare un Mutex<T>
per far sì che
ogni thread aggiorni il risultato finale con la sua parte.
Nota che se stai eseguendo semplici operazioni numeriche, esistono type più
semplici di Mutex<T>
forniti dal modulo std::sync::atomic
della libreria
standard. Questi type forniscono un accesso sicuro,
concorrente e atomico ai type primitivi. Abbiamo scelto di utilizzare
Mutex<T>
con un type primitivo per questo esempio, in modo da poterci
concentrare sul funzionamento di Mutex<T>
.
Comparazione tra RefCell<T>
/Rc<T>
e Mutex<T>
/Arc<T>
Avrai notato che contatore
è immutabile ma possiamo ottenere un reference
mutabile al valore al suo interno; questo significa che Mutex<T>
fornisce la
mutabilità interna, come fa la famiglia Cell
. Nello stesso modo in cui abbiamo
usato RefCell<T>
nel Capitolo 15 per permetterci di mutare i contenuti
all’interno di un Rc<T>
, usiamo 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>
. Ricordiamo dal Capitolo 15 che l’uso di
Rc<T>
comporta il rischio di creare cicli di riferimento, in cui due valori
Rc<T>
fanno riferimento l’uno all’altro, causando perdite di memoria. Allo
stesso modo, Mutex<T>
comporta il rischio di creare dei deadlock (stallo).
Questi si verificano quando un’operazione deve bloccare due risorse e due
thread hanno acquisito ciascuno uno dei blocchi, facendoli attendere
all’infinito l’un l’altro. Se ti interessano i deadlock, prova a creare un
programma Rust che abbia un deadlock; quindi ricerca le strategie di
mitigazione degli stalli per i mutex in qualsiasi altro linguaggio e prova a
implementarle in Rust. La documentazione API della libreria standard per
Mutex<T>
e MutexGuard
offre informazioni utili.
Concluderemo questo capitolo parlando dei trait Send
e Sync
e di come
possiamo utilizzarli con i type personalizzati.