Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 45
Concorrenza e parallelismo - 3

Il passo successivo nell'ambito della concorrenza vede la gestione di accessi multipli, ovvero da parte di più threads, di una risorsa condivisa. Evidentemente si tratta di un processo delicato in quanto è necessario
prevedere un meccanismo di sincronizzazione per evitare il data race (o competizione sui dati, se volete una dizione italiana) con conseguenti comportamenti anomali o corruzione dei dati, che potrebbero essere all'origine di bug piuttosto difficili da individuare. A questo punto, intuirete subito l'importanza di una struct di nome Mutex. Essa è definita in std::sync ed il suo nome sta per Mutual Exclusion. Il suo funzionamento si può descrivere in 3 punti:

  • un thread acquisce il Mutex
  • una volta acquisito accede in modalità esclusiva alla risorsa desiderata
  • terminate le sue elaborazioni rilascia la risorsa

Mutex si avvolge intorno ai dati che intende proteggere e in questo modo garantisce la loro protezione.
Vediamo un po' di codice, anche questo tratto dalla documentazione ufficiale e che riprendo in quanto molto semplice e chiaro:

  Esempio 45.1
1
2
3
4
5
6
7
8
9
use std::sync::Mutex;
fn main() {
  let data = Mutex::new(5);
  {
    let mut locked_data = data.lock().unwrap();
    *locked_data += 1;
  } // Il lock viene automaticamente rilasciato alla fine dello scope.
  println!("Valore aggiornato: {:?}", data.lock().unwrap());
}

quindi, alla riga 1 importiamo il crate necessario e alla 3 avvogliamo il Mutex (che è in realtà uno smart pointer) intorno alla variabile di nome "data".
La riga 5 è importante in quanto attraverso di essa e in particolare tramite lock() si ottiene il blocco della risorsa per un suo utilizzo esclusivo. Tramite unwrap() estraiamo invece un MutexGuard (oppure ne riceviamo un panic se ci sono problemi). Ma che cos'è un MutexGuard?  Si tratta di una cosidetta guardia RAII (Resource Acquisition Is Initialization) che mantiene il controllo della risorsa finchè necessario e la rilascia quando esce dallo scope. Si tratta di realtà di una struct definita internamente come segue:

pub struct MutexGuard<'a, T: ?Sized + 'a> { /* private fields */ }

ovvero:

'a (Lifetime parameter): Assicura che l'istanza di MutexGuard non possa vivere più a lungo del mutex da cui è stato creato. Questo impedisce l'uso di un riferimento al contenuto del mutex dopo che il lock è stato rilasciato.
T: ?Sized + 'a: Indica che T è un tipo generico che può essere di dimensioni dinamiche (?Sized permette di gestire i tipi che non hanno una dimensione nota a compile-time), e che deve vivere almeno quanto 'a.

MutexGuard implementa Deref, DerefMut e Drop che permette il rilascio della risorsa e anche Send e Sync (se T lo è)

e proprio grazie a DerefMut possiamo, alla riga 6, modificare il valore di locked_data. 

Bene, ora che ne abbiamo spoegato il funzionamento di Mutex, almeno per sommi capi, è il momento di addentrarci un po' di più nell'argomento. Il passo successivo è la condivisione di una risorsa tra più thread.
Il seguente programma condivide una stringa attraverso 3 thread i quali andranno ad aggiungere, a tale stringa, una lettera ciascuno. Il codice risolve il seguente problema: prende una variabile di tipo string e ogni thread, che supponiamo siano 3, deve per 5 volte aggiungere rispettivamente;
thread1 la lettera 'a',
thread2 la lettera 'b',
thread3 la lettera 'c'.

  Esempio 45.2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
//creiamo una variabile condivisa protetta da un Mutex e avvolta in un Arc per la
//condivisione sicura tra thread
    let shared_string = Arc::new(Mutex::new(String::new()));
    // Creiamo i thread
    let mut handles = vec![];
    for &ch in &['a', 'b', 'c'] {
        let shared_string = Arc::clone(&shared_string);
        let handle = thread::spawn(move || {
            for _ in 0..5 {
                let mut string = shared_string.lock().unwrap(); // Acquisiamo il lock
                string.push(ch); // Modifichiamo la stringa condivisa
            }
        });
        handles.push(handle);
    }
    // Aspettiamo che tutti i thread terminino
    for handle in handles {
        handle.join().unwrap();
    }
    // Stampiamo la stringa finale
    println!("Stringa finale: {}", shared_string.lock().unwrap());
}

In questo programma usiamo il nostro Mutex per l'accesso sicuro e in suo aiuto anche Arc che, come visto, permette la condivisione sicura. Ovviamente, alla riga 2 ci serve thread per la creazione dei thread.
La riga 6 è un po' il cuore di tutto. Creiamo una stringa vuota, avvolta da
Mutex per l'accesso concorrente a sua volta avvolto da Arc per la condivisione ovvero la creazione di più copie sicure. La riga 8 prepara il solito vettore dei thread per attenderne la fine gestita alle righe 20 e 21. Nulla di nuovo direi. Alla riga 9 iteriamo su un vettore di caratteri via referenza. Interessante è la riga 10 nella quale effettuiamo la clonazione (necessaria: Mutex non supporta Copy) di Arc aumentando il numero di riferimenti. In questo modo tutti i thread puntano al Mutex in maniera indipendente. Alla 11 troviamo move necessario per portare il thread dentro al loop, come abbiamo già visto. La riga 13 è invece il motore del programma, lock cerca di prendere possesso dell'elemento, unwrap() che forza l'esito del lock(se la risorsa è già occupata il programma crasha). Alla 14 aggiungiamo il carattere al vec. Il resto è deja vu.
Il seguente schema mostra il funzionamento grafico del programma.




Una avvertenza che è posta in chiaro sia sulla documentazione ufficiale che su vari testi è che Mutex non garantisce una totale protezione rispetto ai deadlock ovvero quelle situazioni di stallo che si verificano quando per esempio due thread attendono l'uno la fine dell'altro. Vedremo più avanti un esempio, tenete presente che nessun linguaggio può, per sua natura, evitare del tutto questo problema.