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

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 di conseguenza li supportano. Tuttavia, questi modelli non sono privi di compromessi. Su molti sistemi operativi, utilizzano una buona quantità di memoria per ogni thread. 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.

C’è un motivo per cui le API per generare thread e task 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.

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 (limitato dalla potenza di calcolo), 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 (limitato da I/O), come gestire messaggi provenienti da diverse fonti che possono arrivare a intervalli o velocità 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.

File: src/main.rs
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::block_on(async {
        while let Some(messaggio) = rx.recv().await {
            println!("{messaggio}");
        }
    });
}
Listato 17-25: Invio di messaggi con codice bloccante in un thread e attesa dei messaggi in un blocco async

Iniziamo creando un canale async, quindi avviamo un thread che prende possesso dell’estremità del mittente del canale usando la parola chiave move. 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::block_on, 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.

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 e future 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.