Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 43
Concorrenza e parallelismo

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:

  • Parallelismo: È l'esecuzione simultanea di più task (processi o thread) su diversi core o processori. Nel parallelismo, le attività vengono suddivise in unità più piccole che vengono eseguite contemporaneamente per accelerare il completamento.
  • Concorrenza. Si riferisce alla gestione di più task che avanzano indipendentemente l'uno dall'altro, anche se non necessariamente nello stesso istante. In pratica, i task possono essere alternati su uno stesso core. In questo caso non è necessario avere più core.

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:

  Esempio 43.1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::thread;
use std::time::Duration;
fn main() {
    // Creazione del primo thread
    let handle1 = thread::spawn(|| {
        for i in 1..=5 {
            println!("Thread 1: Contatore {}", i);
            thread::sleep(Duration::from_millis(500)); // Pausa di mezzo secondo
        }
    });

    // Creazione del secondo thread
    let handle2 = thread::spawn(|| {
        for i in 1..=5 {
            println!("Thread 2: Contatore {}", i);
            thread::sleep(Duration::from_millis(700)); // Pausa di 700 millisecondi
        }
    });

    // Attende che entrambi i thread terminino
    handle1.join().expect("Thread 1 fallito.");
    handle2.join().expect("Thread 2 fallito.");
    println!("Esecuzione terminata.");
}

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:

  Esempio 43.2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::thread;
use std::time::Duration;
fn main() {
    let trids = vec!["Thread A", "Thread B", "Thread C"];
    let mut handles = vec![];
    for trid in trids {
        let handle = thread::spawn(move || {
            for i in 1..=3 {
                println!("{} - Iterazione {}", trid, i);
                thread::sleep(Duration::from_millis(200));
            }
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().expect("Errore nel thread");
    }
    println!("Esecuzione terminata!");
}

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(f: F) -> JoinHandle

JoinHandle permette di fare due cose principali:

  1. Attendere il termine del thread: Puoi usare il metodo join su un JoinHandle per aspettare che il thread termini la sua esecuzione. Il metodo restituisce un Result che contiene il valore di ritorno del thread se tutto è andato a buon fine, oppure un Err se il thread ha panico.

  2. Gestire la proprietà: Il JoinHandle ti dà la proprietà del thread che è stato creato. Quando il JoinHandle viene eliminato, il thread continuerà a eseguire fino a quando non termina naturalmente.

Quindi per ogni thread creato abbiamo un JoinHandle. Questo viene preso in carico tramite Join dal thread chiamante che si blocca fino a quando  i thread secondari non sono giunti al termine. Join è definita come segue:

pub fn join(self) -> Result

In pratica, definito un thread join prende possesso del handle e restituisce un Result che può avere due valori
  • Ok(T): Il valore di ritorno del thread, se è terminato correttamente.
  • Err(Box<dyn Any + Send + 'static>): Se il thread è andato in panic.

Questo Result deve essere gestito via codice eventualmente anche in forma esplicita. Di seguito un semplice esempio che prende appunto in forma esplicita la gestione del Result espresso da join, mentre nell'esempio 43.2 abbiamo considerato l'intervento solo in occasione di un eventuale comunicazione di fallimento:

  Esempio 43.3
1
2
3
4
5
6
7
8
9
10
11
use std::thread;
fn main() {
    let handle = thread::spawn(|| {
        7 // Il thread restituisce un valore
    });
    let result = handle.join(); // `Result<i32>` viene restituito
    match result {
        Ok(value) => println!("Il thread ha restituito: {}", value), //  Successo
        Err(_) => println!("Errore: il thread è andato in panic"),   //  Errore
    }
}
 
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:

  Esempio 43.4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::thread;
use std::time::Duration;

fn main() {
let handle = thread::spawn(|| {
thread::sleep(Duration::from_secs(3)); // Simula un lavoro lungo
println!("Thread completato!");
});
// Controlliamo periodicamente se il thread è finito
while !handle.is_finished() {
println!("Thread ancora in esecuzione...");
thread::sleep(Duration::from_secs(1));
}
// Ora possiamo unirlo in sicurezza
handle.join().unwrap();
println!("Esecuzione terminata.");
}

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.