Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Devi cercare di acquisire il blocco prima di utilizzare i dati.
  2. 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.

File: src/main.rs
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: Uso dell’API di 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.

File: src/main.rs
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());
}
Listato 16-13: Dieci thread, ognuno dei quali incrementa un contatore custodito da un 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.

File: src/main.rs
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());
}
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.

$ 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.

File: src/main.rs
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());
}
Listato 16-15: Utilizzo di un Arc<T> per incapsulare il Mutex<T> per poter condividere la ownership tra più thread

Questo 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.