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

Usare i Thread Per Eseguire Codice Simultaneamente

Nella maggior parte dei sistemi operativi attuali, il codice di un programma viene eseguito in un processo e il sistema operativo gestisce più processi contemporaneamente. All’interno di un programma, è possibile avere anche parti indipendenti che vengono eseguite simultaneamente. Le funzionalità che eseguono queste parti indipendenti sono chiamate thread. Ad esempio, un server web potrebbe avere più thread in modo da poter rispondere a più richieste contemporaneamente.

Suddividere i calcoli del tuo programma in più thread per eseguire più attività contemporaneamente può migliorare le prestazioni, ma aggiunge anche complessità. Poiché i thread possono essere eseguiti simultaneamente, non c’è alcuna garanzia intrinseca sull’ordine di esecuzione dei thread del tuo codice. Questo può portare a problemi, come ad esempio:

  • Competizione (race condition), in cui i thread accedono ai dati o alle risorse in un ordine incoerente
  • Stallo (deadlock), in cui due thread sono in attesa l’uno dell’altro, impedendo a entrambi di continuare
  • Bug che si verificano solo in determinate situazioni e sono difficili da riprodurre e risolvere in modo affidabile

Rust cerca di mitigare gli effetti negativi dell’uso dei thread, ma programmare in un contesto multi-thread richiede comunque un’attenta riflessione e una struttura del codice diversa da quella dei programmi eseguiti in un singolo thread.

I linguaggi di programmazione implementano i thread in diversi modi e molti sistemi operativi forniscono un’API che il linguaggio di programmazione può richiamare per creare nuovi thread. La libreria standard di Rust utilizza un modello 1:1 di implementazione dei thread, in base al quale un programma utilizza un thread del sistema operativo per ogni thread del linguaggio. Esistono dei crate che implementano altri modelli di threading che fanno dei compromessi diversi rispetto al modello 1:1. (Anche il sistema async di Rust, che vedremo nel prossimo capitolo, fornisce un ulteriore approccio alla concorrenza.)

Creare un Nuovo Thread con spawn

Per creare un nuovo thread, chiamiamo la funzione thread::spawn e le passiamo una chiusura (abbiamo parlato delle chiusure nel Capitolo 13) contenente il codice che vogliamo eseguire nel nuovo thread. L’esempio nel Listato 16-1 stampa del testo da un thread principale e altro testo da un nuovo thread.

File: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("ciao numero {i} dal thread generato!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("ciao numero {i} dal thread principale!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listato 16-1: Creazione di un nuovo thread per stampare una cosa mentre il thread principale stampa qualcos’altro

Come puoi notare dall’output quando il thread main del programma Rust finisce anche il thread generato viene interrotto, che abbia o meno finito di fare quello che doveva fare. Eccolo qui:

ciao numero 1 dal thread principale!
ciao numero 1 dal thread generato!
ciao numero 2 dal thread principale!
ciao numero 2 dal thread generato!
ciao numero 3 dal thread principale!
ciao numero 3 dal thread generato!
ciao numero 4 dal thread principale!
ciao numero 4 dal thread generato!
ciao numero 5 dal thread generato!

Le chiamate a thread::sleep costringono un thread a interrompere la sua esecuzione per un breve periodo, consentendo a un altro thread di funzionare. I thread probabilmente si alterneranno, ma questo non è garantito: dipende da come il sistema operativo pianifica i thread. In questa esecuzione, il thread principale ha stampato per primo, anche se l’istruzione di stampa del thread generato (spawned) appare per prima nel codice. E anche se abbiamo detto al thread generato di stampare finché i non è 9, è arrivato solo a 5 prima che il thread principale si concludesse.

Se esegui questo codice e vedi solo l’output del thread principale o non vedi alcuna sovrapposizione, prova ad aumentare i numeri negli intervalli per creare più opportunità per il sistema operativo di passare da un thread all’altro.

Attendere Che Tutti i Thread Finiscano

Il codice nel Listato 16-1 non solo arresta il thread generato prematuramente nella maggior parte dei casi a causa della fine del thread principale, ma poiché non c’è alcuna garanzia sull’ordine di esecuzione dei thread, non possiamo nemmeno garantire che il thread generato venga eseguito!

Possiamo risolvere il problema del thread generato che non viene eseguito o che termina prematuramente salvando il valore di ritorno di thread::spawn in una variabile. Il type restituito da thread::spawn è JoinHandle<T>. Un JoinHandle<T> è un valore posseduto che, quando chiamiamo il metodo join su di esso, aspetterà che il suo thread finisca. Il Listato 16-2 mostra come utilizzare il JoinHandle<T> del thread creato nel Listato 16-1 e come chiamare join per assicurarsi che il thread generato finisca prima che main si concluda.

File: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("ciao numero {i} dal thread generato!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("ciao numero {i} dal thread principale!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listato 16-2: Salvare un JoinHandle<T> da thread::spawn per garantire che il thread venga eseguito fino al completamento

La chiamata a join sull’handle blocca il thread attualmente in esecuzione fino a quando il thread rappresentato dall’handle non termina. Bloccare un thread significa che a quel thread viene impedito di eseguire lavori o di uscire. Poiché abbiamo inserito la chiamata a join dopo il ciclo for del thread principale, l’esecuzione del Listato 16-2 dovrebbe produrre un risultato simile a questo:

ciao numero 1 dal thread principale!
ciao numero 1 dal thread generato!
ciao numero 2 dal thread principale!
ciao numero 2 dal thread generato!
ciao numero 3 dal thread principale!
ciao numero 3 dal thread generato!
ciao numero 4 dal thread principale!
ciao numero 4 dal thread generato!
ciao numero 5 dal thread generato!
ciao numero 6 dal thread generato!
ciao numero 7 dal thread generato!
ciao numero 8 dal thread generato!
ciao numero 9 dal thread generato!

I due thread continuano ad alternarsi, ma il thread principale attende a causa della chiamata a handle.join() e non termina finché il thread generato non è terminato.

Ma vediamo cosa succede se spostiamo handle.join() prima del ciclo for di main, in questo modo:

File: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("ciao numero {i} dal thread generato!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("ciao numero {i} dal thread principale!");
        thread::sleep(Duration::from_millis(1));
    }
}

Il thread principale aspetterà che il thread generato finisca e poi eseguirà il suo ciclo for, quindi l’output non sarà più alternato, come mostrato qui:

ciao numero 1 dal thread generato!
ciao numero 2 dal thread generato!
ciao numero 3 dal thread generato!
ciao numero 4 dal thread generato!
ciao numero 5 dal thread generato!
ciao numero 6 dal thread generato!
ciao numero 7 dal thread generato!
ciao numero 8 dal thread generato!
ciao numero 9 dal thread generato!
ciao numero 1 dal thread principale!
ciao numero 2 dal thread principale!
ciao numero 3 dal thread principale!
ciao numero 4 dal thread principale!

Piccoli dettagli, come il punto in cui viene chiamato join, possono influenzare l’esecuzione simultanea o meno dei thread.

Usare le Chiusure move con i Thread

Spesso useremo la parola chiave move con le chiusure passate a thread::spawn perché la chiusura prenderà la ownership dei valori che utilizza dall’ambiente, trasferendo così la ownership di quei valori da un thread all’altro. In “Catturare i Reference o Trasferire la Ownership del Capitolo 13, abbiamo parlato di move nel contesto delle chiusure. Ora ci concentreremo maggiormente sull’interazione tra move e thread::spawn.

Nota nel Listato 16-1 che la chiusura che passiamo a thread::spawn non accetta argomenti: non stiamo utilizzando alcun dato del thread principale nel codice del thread generato. Per utilizzare i dati del thread principale nel thread generato, la chiusura del thread generato deve catturare i valori di cui ha bisogno. Il Listato 16-3 mostra un tentativo di creare un vettore nel thread principale e di utilizzarlo nel thread generato. Tuttavia, questo non funziona ancora, come vedrai tra poco.

File: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Ecco un vettore: {v:?}");
    });

    handle.join().unwrap();
}
Listato 16-3: Tentativo di utilizzare un vettore creato dal thread principale in un altro thread

La chiusura utilizza v, quindi catturerà v e lo renderà parte dell’ambiente della chiusura. Poiché thread::spawn esegue questa chiusura in un nuovo thread, dovremmo essere in grado di accedere a v all’interno di questo nuovo thread. Ma quando compiliamo questo esempio, otteniamo il seguente errore:

$ cargo run
   Compiling threads v0.1.0 (file:///progetti/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Ecco un vettore: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Ecco un vettore: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust inferisce come catturare v e, poiché println! ha bisogno solo di un reference a v, la chiusura cerca di prendere in prestito v. Tuttavia, c’è un problema: Rust non può sapere per quanto tempo verrà eseguito il thread generato, quindi non sa se il reference a v sarà sempre valido.

Il Listato 16-4 mostra uno scenario in cui è più probabile che un reference a v non sia valido.

File: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Ecco un vettore: {v:?}");
    });

    drop(v); // oh, no!

    handle.join().unwrap();
}
Listato 16-4: Un thread con una chiusura che tenta di catturare un reference a v da un thread principale che libera v

Se Rust ci permettesse di eseguire questo codice, è possibile che il thread generato venga immediatamente messo in background senza essere eseguito affatto. Il thread generato ha un reference a v al suo interno, ma il thread principale libera immediatamente v, utilizzando la funzione drop di cui abbiamo parlato nel Capitolo 15. Poi, quando il thread generato viene eseguito, v non è più valido, quindi anche il reference ad esso non è valido. Oh, no!

Per risolvere l’errore del compilatore nel Listato 16-3, possiamo utilizzare i consigli del messaggio di errore:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Aggiungendo la parola chiave move prima della chiusura, obblighiamo la chiusura a prendere ownership dei valori che sta utilizzando, invece di permettere a Rust di dedurre che deve prendere in prestito i valori. La modifica al Listato 16-3 mostrata nel Listato 16-5 verrà compilata ed eseguita come previsto.

File: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Ecco un vettore: {v:?}");
    });

    handle.join().unwrap();
}
Listato 16-5: Usare la parola chiave move per forzare una chiusura a prendere ownership dei valori che utilizza

Potremmo essere tentati di fare la stessa cosa per correggere il codice del Listato 16-4 in cui il thread principale chiamava drop utilizzando una chiusura move. Tuttavia, questa correzione non funzionerà perché ciò che il Listato 16-4 sta cercando di fare è vietato per un motivo diverso. Se aggiungessimo move alla chiusura, sposteremmo v nell’ambiente della chiusura e non potremmo più chiamare drop su di essa nel thread principale. Otterremmo invece questo errore del compilatore:

$ cargo run
   Compiling threads v0.1.0 (file:///progetti/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Ecco un vettore: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Le regole di ownership di Rust ci hanno salvato ancora una volta! Abbiamo ricevuto un errore dal codice del Listato 16-3 perché Rust era conservativo e prendeva solo in prestito v per il thread, il che significava che il thread principale poteva teoricamente invalidare il reference del thread generato. Dicendo a Rust di spostare la ownership di v al thread generato, garantiamo a Rust che il thread principale non userà più v. Se modifichiamo il Listato 16-4 nello stesso modo, violeremo le regole di ownership quando cercheremo di usare v nel thread principale. La parola chiave move sovrascrive il comportamento conservativo di Rust di prendere in prestito; non ci permette di violare le regole di ownership.

Ora che abbiamo analizzato cosa sono i thread e i metodi forniti dall’API dei thread, vediamo alcune situazioni in cui possiamo utilizzarli.