Future, Task e Thread
Come abbiamo visto nel Capitolo 16, i thread offrono un approccio alla concorrenza. Abbiamo visto un altro approccio in questo capitolo: utilizzare async con future e stream. Se ti stai chiedendo quando scegliere un metodo rispetto all’altro, la risposta è: dipende! E in molti casi, la scelta non è tra thread o async, ma piuttosto tra thread e async.
Molti sistemi operativi hanno fornito modelli di concorrenza basati su thread per decenni, e molti linguaggi di programmazione li supportano di conseguenza. Tuttavia, questi modelli non sono privi di compromessi. Su molti sistemi operativi, utilizzano una buona quantità di memoria per ogni thread e comportano un certo overhead per l’avvio e lo spegnimento. I thread sono anche un’opzione solo quando il tuo sistema operativo e hardware li supportano. A differenza dei computer desktop e smartphone moderni, alcuni sistemi embedded non hanno affatto un OS, quindi non hanno nemmeno thread.
Il modello async fornisce un insieme di compromessi diverso, e alla fine
complementare. Nel modello async, le operazioni concorrenti non richiedono i
propri thread. Invece, possono essere eseguite su task, come quando abbiamo
utilizzato trpl::spawn_task
per avviare un lavoro da una funzione sincrona
nella sezione degli stream. Un task è simile a un thread, ma invece di
essere gestito dal sistema operativo, è gestito da codice a livello di libreria:
il runtime.
Nella sezione precedente, abbiamo visto che potevamo costruire uno stream
utilizzando un canale async e avviando un task asincrono che potevamo
chiamare da codice sincrono. Possiamo fare esattamente la stessa cosa con un
thread. Nel Listato 17-40, avevamo utilizzato trpl::spawn_task
e
trpl::sleep
. Nel Listato 17-41, li sostituiamo con le API thread::spawn
e
thread::sleep
della libreria standard nella funzione ricevi_intervalli
.
extern crate trpl; // necessario per test mdbook use std::{pin::pin, thread, time::Duration}; use trpl::{ReceiverStream, Stream, StreamExt}; fn main() { trpl::run(async { let messaggi = ricevi_messaggi().timeout(Duration::from_millis(200)); let intervalli = ricevi_intervalli() .map(|conteggio| format!("Intervallo: {conteggio}")) .throttle(Duration::from_millis(500)) .timeout(Duration::from_secs(10)); let uniti = messaggi.merge(intervalli).take(20); let mut stream = pin!(uniti); while let Some(risultato) = stream.next().await { match risultato { Ok(elemento) => println!("{elemento}"), Err(ragione) => eprintln!("Problema: {ragione:?}"), } } }); } fn ricevi_messaggi() -> impl Stream<Item = String> { let (tx, rx) = trpl::channel(); trpl::spawn_task(async move { let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; for (indice, messaggio) in messaggi.into_iter().enumerate() { let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 }; trpl::sleep(Duration::from_millis(tempo_dormita)).await; if let Err(errore_invio) = tx.send(format!("Messaggio: '{messaggio}'")) { eprintln!("Impossibile inviare messaggio '{messaggio}': {errore_invio}"); break; } } }); ReceiverStream::new(rx) } fn ricevi_intervalli() -> impl Stream<Item = u32> { let (tx, rx) = trpl::channel(); // Questo NON è `trpl::spawn` ma `std::thread::spawn`! thread::spawn(move || { let mut conteggio = 0; loop { // E questo NON è `trpl::sleep` ma `std::thread::sleep`! thread::sleep(Duration::from_millis(1)); conteggio += 1; if let Err(errore_invio) = tx.send(conteggio) { eprintln!("Impossibile inviare intervallo {conteggio}: {errore_invio}"); break; }; } }); ReceiverStream::new(rx) }
std::thread
invece delle API async trpl
per la funzione ricevi_intervalli
Se esegui questo codice, l’output è identico a quello del Listato 17-40. E nota quanto poco cambia qui dalla prospettiva del codice chiamante. Inoltre, anche se una delle nostre funzioni ha avviato un task async sul runtime e l’altra ha avviato un thread del sistema operativo, gli stream risultanti non sono stati influenzati dalle differenze.
Nonostante le loro somiglianze, questi due approcci si comportano in modo molto diverso, anche se potremmo avere difficoltà a misurarlo in questo esempio molto semplice. Potremmo avviare milioni di task async su qualsiasi computer moderno. Se provassimo a farlo con i thread, esauriremmo letteralmente la memoria!
Tuttavia, c’è un motivo per cui queste API sono così simili. I thread agiscono come un confine per insiemi di operazioni sincrone; la concorrenza è possibile tra i thread. I task agiscono come un confine per insiemi di operazioni asincrone; la concorrenza è possibile sia tra che all’interno dei task, perché un task può passare tra future nel suo corpo. Infine, le future sono l’unità di concorrenza più granulare di Rust, e ogni future può rappresentare un albero di altre future. Il runtime, e nello specifico il suo esecutore, gestisce i task, e i task gestiscono le future. In questo senso, i task sono simili a thread leggeri gestiti dal runtime con capacità aggiuntive che derivano dal fatto di essere gestiti da un runtime anziché dal sistema operativo.
Questo non significa che i task async siano sempre migliori dei thread (o
viceversa). La concorrenza con i thread è in alcuni modi un modello di
programmazione più semplice rispetto alla concorrenza con async
. Questo può
essere un punto di forza o una debolezza. I thread sono in un certo senso
“esegui e dimenticatene”; non hanno un equivalente nativo a una future, quindi
semplicemente eseguono fino al completamento senza essere interrotti, tranne che
dal sistema operativo stesso. Cioè, non hanno supporto integrato per la
concorrenza intra-task come fanno le future. I thread in Rust non hanno
nemmeno meccanismi per la cancellazione, un argomento che non abbiamo trattato
esplicitamente in questo capitolo, ma che dovrebbe esserti apparso implicito dal
fatto che ogni volta che abbiamo terminato una future, il suo stato è stato
ripulito correttamente.
Queste limitazioni rendono anche i thread più difficili da comporre rispetto
alle future. È molto più difficile, ad esempio, utilizzare i thread per
costruire funzionalità come i metodi timeout
e throttle
che abbiamo
costruito in precedenza in questo capitolo. Il fatto che le future siano
strutture dati più ricche significa che possono essere composte insieme in modo
più naturale, come abbiamo visto.
I task, quindi, ci danno un controllo aggiuntivo sulle future,
permettendoci di scegliere dove e come raggrupparle. E se non bastasse, i
thread e i task spesso funzionano molto bene insieme, perché i task
possono (almeno in alcuni runtime) essere spostati tra i thread. Infatti,
dietro le quinte, il runtime che abbiamo utilizzato, comprese le funzioni
spawn_blocking
e spawn_task
, è multi-thread per impostazione predefinita!
Molti runtime utilizzano un approccio chiamato work stealing per spostare in
modo trasparente i task tra i thread, in base a come i thread vengono
attualmente utilizzati, per migliorare le prestazioni complessive del sistema.
Questo approccio richiede effettivamente sia thread che task, e quindi
future.
Quando si pensa a quale metodo utilizzare, considera queste regole pratiche:
- Se il lavoro è molto parallelizzabile, come l’elaborazione di un insieme di dati in cui ogni parte può essere elaborata separatamente, i thread sono una scelta migliore.
- Se il lavoro è molto concorrente, come gestire messaggi provenienti da diverse fonti che possono arrivare a intervalli o tassi diversi, async è una scelta migliore.
E se hai bisogno sia di parallelismo che di concorrenza, non devi scegliere tra thread e async. Puoi usarli insieme liberamente, lasciando a ciascuno il compito che svolge meglio. Ad esempio, il Listato 17-42 mostra un esempio piuttosto comune di questo tipo di mix nel codice Rust reale.
extern crate trpl; // necessario per test mdbook use std::{thread, time::Duration}; fn main() { let (tx, mut rx) = trpl::channel(); thread::spawn(move || { for i in 1..11 { tx.send(i).unwrap(); thread::sleep(Duration::from_secs(1)); } }); trpl::run(async { while let Some(messaggio) = rx.recv().await { println!("{messaggio}"); } }); }
Iniziamo creando un canale async, quindi avviamo un thread che prende
possesso della estremità del mittente del canale. All’interno del thread,
inviamo i numeri da 1 a 10, dormendo per un secondo tra ciascuno. Infine,
eseguiamo una future creata con un blocco async passato a trpl::run
,
proprio come abbiamo fatto in tutto il capitolo. In quella future, attendiamo
quei messaggi, proprio come negli altri esempi di invio messaggi che abbiamo
visto.
Per tornare allo scenario con cui abbiamo aperto il capitolo, immagina di eseguire un insieme di task di codifica video utilizzando un thread dedicato (perché la codifica video è vincolata al calcolo) ma notificando l’interfaccia utente che quelle operazioni sono terminate con un canale async. Ci sono innumerevoli esempi di queste combinazioni in casi d’uso reali.
Riepilogo
Questa non è l’ultima volta che vedrai la concorrenza in questo libro. Il progetto nel Capitolo 21 applicherà questi concetti in una situazione più realistica rispetto agli esempi più semplici discussi qui e confronterà la risoluzione dei problemi con i thread rispetto ai task in modo più diretto.
Indipendentemente da quale di questi approcci scegli, Rust ti offre gli strumenti necessari per scrivere codice concorrente sicuro e veloce, sia per un server web ad alta capacità che per un sistema operativo embedded.
Nel prossimo capitolo, parleremo di modi idiomatici per modellare problemi e strutturare soluzioni man mano che i tuoi programmi Rust crescono. Inoltre, discuteremo di come gli idiomi di Rust si relazionano a quelli con cui potresti avere familiarità provenienti dalla programmazione orientata agli oggetti.