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.
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)); } }
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.
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(); }
JoinHandle<T>
da thread::spawn
per garantire che il thread venga eseguito fino al completamentoLa 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:
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.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Ecco un vettore: {v:?}");
});
handle.join().unwrap();
}
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.
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();
}
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.
use std::thread; fn main() { let v = vec![1, 2, 3]; let handle = thread::spawn(move || { println!("Ecco un vettore: {v:?}"); }); handle.join().unwrap(); }
move
per forzare una chiusura a prendere ownership dei valori che utilizzaPotremmo 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.