Uno sguardo a Rust Async

Uno sguardo generale alle feature che rendono la concorrenza in Rust “fearless”

Introduzione

Rust è un linguaggio moderno, e come ogni linguaggio moderno trae ispirazione da altri linguaggi per costruire le sue feature. In questo articolo cercherò di darvi una panoramica alla parte asincrona di Rust, con un focus sui concetti chiave e da dove derivano.

rust logo

Composizione

Il primo concetto di cui voglio parlarvi è la Composizione, ossia la possibilità di concatenare multiple funzioni asincrone ed eseguirle in maniera asincrona rispetto al thread principale. Per questo concetto Rust prende ispirazione da 2 linguaggi molto diversi tra loro, JavaScript e Scala.

async/await

Da JavaScript, Rust eredita le funzionalità async/await, un modo semplice ed intuitivo di scrivere codice asincrono come se fosse sincrono. In particolare async non è altro che zucchero sintattico che trasforma qualcosa di questo tipo:

fn funzione_asincrona() -> impl Future<Output = ()> {
   lazy(|_| println!("Sto facendo qualcosa"))
       .then(|_| Delay::new(one_second))
       .then(|_| lazy(|_| println!("Sto facendo qualcosa dopo 1 secondo")))
}

in qualcosa del genere:

async fn funzione_asincrona() {
   println!("Sto facendo qualcosa");
   Delay::new(one_second).await;
   println!("Sto facendo qualcosa dopo 1 secondo");
}

Avrete notato anche l’utilizzo di await (e di Future, su cui mi soffermerò più avanti), che non è altro che indicare a Rust di aspettare il completamento di una funzione asincrona, nel nostro caso la funzione Delay::new, che ovviamente completa la sua esecuzione dopo un delay.

join!()

Da Scala invece, viene presa la feature di composizione delle funzioni asincrone. Mettiamo il caso di voler scrivere una funzione che restituisce una tupla contenente i 2 risultati di 2 operazioni asincrone diverse:

async fn esegui_parallelamente() -> (Risultato1, Risultato2) {
    let res1 = get_res1().await;
    let res2 = get_res2().await;
    (res1, res2)
}

Questo codice funziona sulla carta, tuttavia risulta molto inefficiente, in quanto il secondo risultato deve necessariamente aspettare il primo per essere calcolato, nonostante siano indipendenti. Rust ci fornisce quindi la macro join! per ovviare a questo problema:

async fn esegui_parallelamente() -> (Risultato1, Risultato2) {
    let res1_futures = get_res1();
    let res2_futures = get_res2();
    join!(res1_futures, res2_futures)
}

In questo modo (notare come siano stati rimossi gli await), Rust esegue parallelamente il calcolo dei 2 risultati, dando un boost notevole alle performance. E se volessi interrompere l’esecuzione asincrona nel caso in cui una delle funzioni restituisca un errore? Usa try_join!:

async fn esegui_parallelamente_fallibile() -> Result<(Risultato1, Risultato2), Error>{
    let res1_futures = get_res1_fallible();
    let res2_futures = get_res2_fallible();
    try_join!(res1_futures, res2_futures)
}

Così Rust propagherà l’errore generato in una delle funzioni fallibili e terminerà l’esecuzione asincrona immediatamente.

Allocazione

Future

Per quanto riguarda il Future accennato prima, questo è il vero elemento chiave dell’esecuzione asincrona di Rust, e fa parte del concetto di Allocazione. Un Future non è altro che un trait che fa riferimento ad una computazione asincrona che può produrre un certo valore. Internamente, per semplificare è fatto così:

trait FutureSemplificato{
    type Output;
    fn poll(&mut self, wake: fn()) -> Poll<Self::Output>;
}

enum Poll<T> {
    Ready(T),
    Pending,
}

Chiamando la funzione pool portiamo avanti l’esecuzione asincrona fin dove possibile e in caso di stop viene restituita la variante Pending dell’enumeratore Pool. Inoltre la funzione pool gestisce il “risveglio” del Future chiamando la funzione wake, che avvertirà l’esecutore di eventuali progressi, fin quando l’esecuzione non termina e ci viene restituito un valore contenuto all’interno della variante Ready. Questo modello rende possibile l’esecuzione simultanea di multipli futures senza la necessità di dover allocare ulteriore memoria.

Pin/Unpin

Tuttavia, bisogna aggiungere un altro tassello molto importante, il Pinning. Rust per garantire il corretto funzionamento dei Futures implementa i trait Pin e Unpin che, molto semplicemente rendono o meno possibile lo spostamento in memoria di un Future, in modo tale che l’esecutore possa accedervi senza problemi fino alla fine dell’esecuzione (quando viene unpinnato).

Stato Condiviso

Nella programmazione asincrona prima o poi ci si ritroverà a dover condividere delle risorse tra varie esecuzioni, è quindi arrivato il momento di parlare del concetto di Stato Condiviso.

Mutex

Rust mette a disposizione un strumento molto potente per garantire thread safety, il Mutex. Mutex sta per mutual exclusion e fornisce un blocco che permette l’accesso al dato contenuto un solo thread alla volta, prevenendo quindi data races. L’utilizzo basilare del Mutex è questo:

fn main() {
    let my_mutex = Mutex::new(5);

    *my_mutex.lock().unwrap() = 6;

    println!("{:?}", my_mutex);

    my_mutex.unlock();
}

In questo modo, per poter modificare il valore contenuto in my_mutex bisogna necessariamente chiamare la funzione lock, che non potrà essere chiamata se il Mutex è già stato bloccato, dovendo quindi aspettare che venga chiamato unlock.

Arc

Per via del design di Rust, Mutex viene spesso usato in combinazione con Arc. Quest’ultimo sta per Atomic Reference Counter ed il suo scopo è abbastanza semplice. Dando per scontato che sappiate come funziona il borrow checker di Rust, Rc (senza la A) consente al dato contenuto al suo interno di avere molteplici owner, aggiornando un counter interno ogni volta che viene “droppato” o clonato un valore, garantendo il drop effettivo del dato solo una volta che il counter tocca lo 0. Arc non è altro che la versione “Atomica” di questo conteggio, in quanto il dato deve essere posseduto da thread diversi.

Channels

E se volessi gestire lo stato in un unico punto “orchestratore” e farlo accedere da più thread? Nessun problema, Rust ci fornisce i Channels (presi direttamente da Go). Ecco un banale, quanto pratico, esempio di utilizzo dei channels:

fn main() {
    let (sender, receiver) = channel();

    sender.send(5).unwrap();
    println!("{}", receiver.recv().unwrap());
}

Con il corretto utilizzo di questa feature è possibile scrivere un orchestratore di stato in maniera semplice ed efficiente.

Conclusioni

Ci sarebbero ancora diversi elementi da approfondire per capire pienamente la concorrenza in Rust, ma questo andrebbe fuori dall’overview che ho scelto di scrivere. In conclusione, sì, mi sento di confermare che l’approccio “fearless concurrency” di Rust sia più che valido e che questo dovrebbe spronare molti sviluppatori a valutare Rust come tecnologia di punta nelle loro applicazioni multithreading.

in rust we trust

References


Pubblicato il
Be Smart Be Open

Business Software Solutions

Ultimi articoli

Rilasciata in produzione applicazione Wasteful basata su Flutter

11 Marzo 2022

I nostri contatti

Email: besmart@besmartbeopen.it
Pec: besmartbeopen@pec.it
P.IVA e C.F.: 02137570509
Top
Noi ed alcuni partner utilizziamo cookie o tecnologie simili come specificato nella cookie policy Per maggiori informazioni consulta la nostra Cookie Policy Cookie Policy