Con questa lezione è giunto il momento di gettare le basi, teoriche e pratiche, di un altro argomento molto importante in un linguaggio come Rust che può essere impiegato in ogni
genere di attività. Imparando le prime operazioni di programmazione siamo generalmente portati a svolgere funzioni che hanno un risultato immediato: elaborazioni di stringhe, calcoli, etc. Ciò ci porta, al fine di conservare il risultato ottenuto, ad utilizzare un operatore di assegnazione (l'uguale) per inserire quanto ottenuto in una variabile destinazione.
Con il procedere degli studi, al momento di iniziare a proiettare le nostre conoscenze su Rust verso panorami professionali, nasce il desiderio/bisogno di interagire con importanti fonti di dati quali database, file su disco, risorse accessibili via Internet. In tali casi, la latenza (il tempo che aspettiamo per ottenere un risultato) aumenta e soprattutto, fattore ancora peggiore, diventa
variabile a seconda della congestione a tempo di esecuzione dei canali di comunicazione e il carico di lavoro a cui le sorgenti/destinazione sono in quel momento sottoposte.
Se, in tali casi, attendessimo il risultato dietro un operatore di assegnazione per inserirlo in una variabile rischieremmo il blocco o almeno il rallentamento dell'esecuzione del software. Non solo, ci troveremmo in situazioni in cui ci accorgeremmo che eseguire le operazioni "una alla volta" potrebbe non essere più sostenibile.
Ciò fa nascere la necessità di iniziare ad avviare attività che potrebbero essere caratterizzate da tempi di latenza maggiore in modalità asincrona ovvero operando su thread paralleli.
Lavorare con i thread in Rust
Senza affondare troppo nella teoria informatica pensiamo ad un programma come una grande autostrada fatta di tante corsie. Su ognuna di esse scorre del traffico parallelamente a quello delle altre corsie e così succede in un programma.
Se in un software abbiamo una componente che scarica dati e li inserisce in una struttura dati ed un'altra che da questa li raccoglie e li utilizza (in Informatica ci si riferisce a questo modello come produttore/consumatore) ognuna di esse dovrebbe lavorare indipendentemente su una propria corsia, parlando tecnicamente un thread isolato.
Come spesso capita con questo linguaggio ed in tutti i linguaggi moderni, esistono molte alternative, alcune parte della libreria standard, altre offerte da librerie di terze parti. In questa lezione, vediamo i thread al lavoro per assicurarci di aver ben compreso gli aspetti teorici ma nelle prossime lezioni avremo modo di vedere efficienti ed efficacissimi pattern proposti da librerie e framework.
Un esempio pratico in Rust
Vediamo subito un esempio:
use std::{thread, time};
// numero di thread che desideriamo attivare
const NUM_OF_THREADS: u32 = 10;
fn main() {
// vettore che archivia i thread generati per eseguire il join
let mut thread_in_esecuzione = vec![];
// tempo di latenza simulato per far durare il thread
let dieci_secondi = time::Duration::from_millis(10000);
// ciclo di creazione di ogni thread
for i in 0..NUM_OF_THREADS {
thread_in_esecuzione.push(thread::spawn(move || {
println!("Inizia operazione lunga in thread {}...", i);
thread::sleep(dieci_secondi);
println!("...finisce operazione lunga in thread {}", i);
}));
}
// ciclo per eseguire il join su ogni thread
for singolo_thread in thread_in_esecuzione {
let _ = singolo_thread.join();
}
println!("FINE PROGRAMMA!");
}
Avvio dei thread
In questo esempio, avviamo 10 thread (quantitativo modulabile agendo sul valore di NUM_OF_THREADS
ognuno dei quali durerà circa 10 secondi come da timer impostato mediante thread::sleep(dieci_secondi)
. I thread si avvieranno in parallelo infatti l'output si presenterà così (nell'esecuzione l'ordine può variare):
Inizia operazione lunga in thread 1...
Inizia operazione lunga in thread 0...
Inizia operazione lunga in thread 9...
Inizia operazione lunga in thread 8...
Inizia operazione lunga in thread 7...
Inizia operazione lunga in thread 6...
Inizia operazione lunga in thread 5...
Inizia operazione lunga in thread 4...
Inizia operazione lunga in thread 3...
Inizia operazione lunga in thread 2...
...finisce operazione lunga in thread 1
...finisce operazione lunga in thread 0
...finisce operazione lunga in thread 9
...finisce operazione lunga in thread 8
...finisce operazione lunga in thread 6
...finisce operazione lunga in thread 7
...finisce operazione lunga in thread 4
...finisce operazione lunga in thread 5
...finisce operazione lunga in thread 3
...finisce operazione lunga in thread 2
FINE PROGRAMMA!
Analisi dell'esecuzione
Se lo si esegue si vedrà prima apparire tutte le righe tipo Inizia operazione lunga in thread... e poi, dopo un lasso di tempo circa pari a 10 secondi (il timer impostato), vedremo apparire quelle con il messaggio di chiusura. Il fatto che i thread inizino e finiscano tutti insieme è la dimostrazione che stanno lavorando in parallelo: in questo caso non stanno realizzando niente ma nella realtà verrebbero impiegati con operazioni ognuna caratterizzata da proprie latenze.
Si noti che i thread sono stati avviati con la funzione spawn
che ha ricevuto come argomento un blocco di codice da eseguire su un nuovo thread. Alla fine del codice, altro aspetto da
notare, con un nuovo ciclo è stato invocato join
su ogni thread e questo è il motivo per cui ne abbiamo conservato un riferimento nel vettore.
In Rust il join
ha lo scopo di "legare" il ciclo di vita del thread appena nato a quello del thread principale per fare in modo che quest'ultimo non si possa chiudere senza aspettare che i thread si siano conclusi.
Conclusioni
Con questo esempio si può iniziare a sperimentare i thread in Rust modificando il codice lanciato da spawn
ma si ricordi che questa è una forma di attività in background che potremmo definire "primitiva". Nelle prossime lezioni avremo modo di scoprire ulteriori alternative molto diffuse.