|
Un capitolo importantissimo in tutti i linguaggi moderni riguarda concorrenza e parallelismo. Le nuove configurazioni hardware a disposizione hanno esaltato questi paradigmi e ora sono parte integrante di qualsiasi linguaggio, anche quelli più datati in qualche modo si sono adattati. E' importante specificare che si tratta di concetti che possono cooperare ma che sono profondamente diversi mentre a volte si incontra un po' di confusione. Per chiarire, diamo un paio di definizioni, peraltro molto simili a quelle che troverete in giro per il Web o sui tanti testi che trattano questi argomenti:
Rust è all'avanguardia per la gestione di questi moderni aspetti della programmazione con particolare attenzione a quelle situazioni di data-race (ovvero in pratica la criticità di risorse condivise tra più thread) che costituiscono un grosso problema in tutti i linguaggi. Tuttavia il discorso non è semplice, anzi, personalmente ritengo sia tra gli aspetti più delicati e sfidanti, specialmente in progetti ampi da usare sul campo. Partiamo con un semplice esempio, che trovate praticamente identico sulla documentazione ufficiale e su altri testi, che ci illustra il concetto di concorrenza:
Vediamo cosa succede riga per riga: le righe 1 e 2 caricano le librerie necessarie per la gestione del programma, in particolare la riga 1 ci mette a disposizione la il modulo thread, necessario, come comprensibile, per la gestione dei thread mentre la riga 2 predispone il modulo time::Duration, utile per definire delle pause temporali. Modulo interessante che riesce a gestire pause fino a frazioni di secondo che arrivano ai nanosecondi. La riga 5 però è quella che ci interessa di più, insieme alla 13. In esse infatti viene creato un thread tramite la funzione spawn che genera un nuovo thread e restituisce un oggetto di tipo JoinHandle che rapresenta in sostanze il gestore del thread appena creato. Questo gestore permette al thread principale di interagire con il thread creato, principalmente per attendere che il thread termini la sua esecuzione. Attraverso di esso il thread chiamante, main nel nostro caso è in grado appunto di gestire la fine del thread ed eventuali errore che occorressero. il metodo spawn accetta una funzione anonima, una chiusura anzi che nel nostro caso di estrinseca tra le righe 6 e 8 per un thread e tra le righe 14 e 16 per il secondo. Il funzionamento dei cicli for è abbastanza semplice: stampano un contatore da 1 a 5 e ad ogni iterazione abbiano una sospensione rispettivamente di mezzo secondo e di 7 decimi. Le righe 21 e 22 ci presentano il metodo join che blocca il thread principale fino a che il thread associato non sia terminato, in questo caso sono due eventualmente presentando un messaggio di errore se avviene una qualche anomalia che impesca il regolare stop dei thread. Se eseguite più volte il programma vedrete che una volta parte prima il thread1 e un'altra il thread2. Questo dipende dal sistema operativo i due thread vanno in stato di "pronto" e lì non dipende da loro chi parte per primo. Se vogliamo forzare un ordine bisogna usare sistemi di sincronizzazione come mutex che vedremo più avanti e che sono un po' complessi. Nell'esempio 43.1 non vi sono risorse condivise, siamo in un normale ambito di concorrenza decisamente meno complicato rispetto a quello scenario. Vediamo ora un altro esempio:
Anche qui vediamo cosa succede. Alla riga 4 creiamo un normale vettore di stringhe. Alla riga 5 creiamo un vettore vuoto Dalla 6 alla 14 prende il via la creazione e gestione dei thread scorrendo l'array di stringhe. I thread vengono caricati in sequenza (A B C) ed in tale sequenza sarebbero eseguiti se non fosse per quel ritardo introdotto alla riga 10. Grazie a tale ritardo, il sistema operativo carica tutti i thread e li esegue in maniera randomica. In pratica, il sistema operativo decide quale thread riprendere in base alla sua strategia di scheduling, che può dipendere da molti fattori (priorità, numero di CPU, carico di lavoro). Alla riga 7 noterete senza'ltro la presenza di move, necessario per accedere alle variabili esterne, in questo caso il vec trids. La riga 13 è interessante in quanto tiene traccia dei thread creati e fa in modo che il main ne attenda la fine attravrso join. A proposito è bene parlare un po' di join. Quando definiamo thread::spawn vediamo, dalla definizione formale di questo che viene restituito un JoinHandle, ovvero: pub fn spawn JoinHandle permette di fare due cose principali:
pub fn join(self) -> Result In pratica, definito un thread join prende possesso del handle e restituisce un Result che può avere due valori
Una nozione che vale pena di conoscere è che in Rust non esiste un modo nativo per terminare forzatamente un thread, come ad esempio, è possibile fare in Java con l'istruzione Thread.stop() (se ricordo bene... non sono un esperto di Java) o usando Abort in C#. Questa è una scelta di design al fine evitare di lasciare il programma in uno stato incoerente con conseguente rischio di corruzione dei dati. Una funzione che può essere utile è quella che verifica se un thread è ancora in esecuzione. Abbiamo il metodo: pub fn is_finished(&self) -> bool che vediamo in azione nel seguente esempio:
In questo capitolo introduttivo abbiamo solo visto come creare dei thread e come Rust permetta di maneggiarli a livello embrionale. Dobbamo ancora vedere le cose importanti a partire dal data race. |