web-dev-qa-db-fra.com

Quel est le but de l'async / wait dans Rust?

Dans un langage comme C #, donner ce code (je n'utilise pas le mot clé await exprès):

async Task Foo()
{
    var task = LongRunningOperationAsync();

    // Some other non-related operation
    AnotherOperation();

    result = task.Result;
}

Dans la première ligne, l'opération longue est exécutée dans un autre thread et un Task est retourné (c'est un futur). Vous pouvez ensuite effectuer une autre opération qui s'exécutera en parallèle de la première, et à la fin, vous pouvez attendre la fin de l'opération. Je pense que c'est aussi le comportement de async/await en Python, JavaScript, etc.

D'un autre côté, dans Rust, j'ai lu dans le RFC que:

Une différence fondamentale entre les futurs de Rust et ceux d'autres langues est que les futurs de Rust ne font rien à moins d'être interrogés. Tout le système est construit autour de cela: par exemple, l'annulation laisse tomber l'avenir précisément pour cette raison. En revanche, dans d'autres langues, appeler un fn asynchrone fait tourner un futur qui commence à s'exécuter immédiatement.

Dans cette situation, à quoi sert async/await dans Rust? En voyant d'autres langages, cette notation est un moyen pratique d'exécuter des opérations parallèles, mais je ne vois pas comment cela fonctionne dans Rust si l'appel d'une fonction async n'exécute rien.

12
French Boiethios

Vous confondez quelques concepts.

La simultanéité n'est pas du parallélisme , et async et await sont des outils pour simultanéité, ce qui peut parfois signifier qu'ils sont également des outils pour le parallélisme .

De plus, si un futur est immédiatement interrogé ou non est orthogonal à la syntaxe choisie.

async/await

Les mots clés async et await existent pour faciliter la lecture et l'interaction avec du code asynchrone et ressembler davantage à du code synchrone "normal". C'est vrai dans toutes les langues qui ont de tels mots-clés, autant que je sache.

Code plus simple

Il s'agit d'un code qui crée un avenir qui ajoute deux nombres lors d'un sondage

avant

fn long_running_operation(a: u8, b: u8) -> impl Future<Output = u8> {
    struct Value(u8, u8);

    impl Future for Value {
        type Output = u8;

        fn poll(self: Pin<&mut Self>, _lw: &LocalWaker) -> Poll<Self::Output> {
            Poll::Ready(self.0 + self.1)
        }
    }

    Value(a, b)
}

après

async fn long_running_operation(a: u8, b: u8) -> u8 {
    a + b
}

Notez que le code "avant" est essentiellement le implémentation de la fonction poll_fn D'aujourd'hui

Voir aussi réponse de Peter Hall sur la façon de rendre plus facile le suivi de nombreuses variables.

Les références

Une des choses potentiellement surprenantes à propos de async/await est qu'elle permet un modèle spécifique qui n'était pas possible auparavant: utiliser des références dans les futurs. Voici du code qui remplit un tampon avec une valeur de manière asynchrone:

avant

use std::io;

fn fill_up<'a>(buf: &'a mut [u8]) -> impl Future<Output = io::Result<usize>> + 'a {
    futures::future::lazy(move |_| {
        for b in buf.iter_mut() { *b = 42 }
        Ok(buf.len())
    })
}

fn foo() -> impl Future<Output = Vec<u8>> {
    let mut data = vec![0; 8];
    fill_up(&mut data).map(|_| data)
}

Cela ne parvient pas à compiler:

error[E0597]: `data` does not live long enough
  --> src/main.rs:33:17
   |
33 |     fill_up_old(&mut data).map(|_| data)
   |                 ^^^^^^^^^ borrowed value does not live long enough
34 | }
   | - `data` dropped here while still borrowed
   |
   = note: borrowed value must be valid for the static lifetime...

error[E0505]: cannot move out of `data` because it is borrowed
  --> src/main.rs:33:32
   |
33 |     fill_up_old(&mut data).map(|_| data)
   |                 ---------      ^^^ ---- move occurs due to use in closure
   |                 |              |
   |                 |              move out of `data` occurs here
   |                 borrow of `data` occurs here
   |
   = note: borrowed value must be valid for the static lifetime...

après

use std::io;

async fn fill_up(buf: &mut [u8]) -> io::Result<usize> {
    for b in buf.iter_mut() { *b = 42 }
    Ok(buf.len())
}

async fn foo() -> Vec<u8> {
    let mut data = vec![0; 8];
    fill_up(&mut data).await.expect("IO failed");
    data
}

Cela marche!

L'appel d'une fonction async ne lance rien

En revanche, l'implémentation et la conception d'un Future et de l'ensemble du système autour des futures ne sont pas liées aux mots clés async et await. En effet, Rust possède un écosystème asynchrone prospère (comme avec Tokio) avant que les mots clés async/await n'existent. Il en va de même pour JavaScript.

Pourquoi les Futures ne sont-ils pas interrogés immédiatement lors de la création?

Pour la réponse la plus fiable, consultez ce commentaire de sans bateaux sur la demande de tirage RFC:

Une différence fondamentale entre les futurs de Rust et ceux d'autres langues est que les futurs de Rust ne font rien à moins d'être interrogés. Tout le système est construit autour de cela: par exemple, l'annulation laisse tomber l'avenir précisément pour cette raison. En revanche, dans d'autres langues, appeler un fn asynchrone fait tourner un futur qui commence à s'exécuter immédiatement.

Un point à ce sujet est que asynchroniser et attendre dans Rust ne sont pas des constructions intrinsèquement concurrentes. Si vous avez un programme qui utilise uniquement async et attend et aucune primitive de concurrence, le code de votre programme s'exécutera dans un ordre linéaire défini et connu statiquement. De toute évidence, la plupart des programmes utiliseront une sorte de simultanéité pour planifier plusieurs tâches simultanées sur la boucle d'événements, mais ils n'y sont pas obligés. Cela signifie que vous pouvez - trivialement - localement garantir l'ordre de certains événements, même s'il y a non blocage IO effectué entre eux que vous voulez être asynchrone avec un ensemble plus large d'événements non locaux (par exemple, vous pouvez contrôler strictement l'ordre des événements à l'intérieur d'un gestionnaire de requêtes, tout en étant simultané avec de nombreux autres gestionnaires de requêtes, même sur les deux côtés d'un point d'attente).

Cette propriété donne à la syntaxe asynchrone/attente de Rust le type de raisonnement local et de contrôle de bas niveau qui fait de Rust ce qu'il est. L'exécution jusqu'au premier point d'attente ne violerait pas intrinsèquement cela - vous feriez Je sais toujours quand le code est exécuté, il s'exécutera simplement à deux endroits différents selon qu'il survient avant ou après une attente. Cependant, je pense que la décision prise par d'autres langages de commencer à s'exécuter immédiatement découle en grande partie de leurs systèmes qui planifient immédiatement une tâche simultanément lorsque vous appelez un fn asynchrone (par exemple, c'est l'impression du problème sous-jacent que j'ai obtenu du document Dart 2.0).

Une partie de l'arrière-plan de Dart 2.0 est couverte par cette discussion de munificent :

Salut, je fais partie de l'équipe Dart. L'async/wait de Dart a été conçu principalement par Erik Meijer, qui a également travaillé sur async/wait pour C #. En C #, async/expect est synchrone à la première attente. Pour Dart, Erik et d'autres ont estimé que le modèle de C # était trop déroutant et ont plutôt spécifié qu'une fonction asynchrone donne toujours une fois avant d'exécuter n'importe quel code.

À l'époque, moi et un autre membre de mon équipe étions chargés d'être les cobayes pour essayer la nouvelle syntaxe et la sémantique en cours dans notre gestionnaire de paquets. Sur la base de cette expérience, nous avons estimé que les fonctions asynchrones devraient s'exécuter de manière synchrone au premier attente. Nos arguments étaient principalement:

  1. Toujours céder une fois encourt une pénalité de performance sans raison valable. Dans la plupart des cas, cela n'a pas d'importance, mais dans certains cas, c'est vraiment le cas. Même dans les cas où vous pouvez vivre avec, c'est un frein à saigner un peu de perf partout.

  2. Toujours céder signifie que certains modèles ne peuvent pas être implémentés en utilisant async/wait. En particulier, il est vraiment courant d'avoir du code comme (pseudo-code ici):

    getThingFromNetwork():
      if (downloadAlreadyInProgress):
        return cachedFuture
    
      cachedFuture = startDownload()
      return cachedFuture
    

    En d'autres termes, vous disposez d'une opération asynchrone que vous pouvez appeler plusieurs fois avant qu'elle ne se termine. Les appels ultérieurs utilisent le même avenir en attente précédemment créé. Vous voulez vous assurer de ne pas démarrer l'opération plusieurs fois. Cela signifie que vous devez vérifier le cache de manière synchrone avant de commencer l'opération.

    Si les fonctions asynchrones sont asynchrones depuis le début, la fonction ci-dessus ne peut pas utiliser async/wait.

Nous avons plaidé notre cause, mais en fin de compte, les concepteurs de langage sont restés avec async-from-the-top. C'était il ya plusieurs années.

Cela s'est avéré être le mauvais appel. Le coût de la performance est suffisamment réel pour que de nombreux utilisateurs aient développé un état d'esprit selon lequel "les fonctions asynchrones sont lentes" et ont commencé à éviter de l'utiliser même dans les cas où la perf perf était abordable. Pire encore, nous voyons des bogues de concurrence désagréables où les gens pensent qu'ils peuvent effectuer un travail synchrone au sommet d'une fonction et sont consternés de découvrir qu'ils ont créé des conditions de concurrence. Dans l'ensemble, il semble que les utilisateurs n'assument pas naturellement qu'une fonction asynchrone donne avant d'exécuter un code.

Donc, pour Dart 2, nous prenons maintenant le changement de rupture très douloureux pour changer les fonctions asynchrones pour être synchrone à la première attente et migrer tout notre code existant à travers cette transition. Je suis heureux que nous apportions le changement, mais j'aimerais vraiment que nous ayons fait la bonne chose le premier jour.

Je ne sais pas si la propriété et le modèle de performance de Rust vous imposent des contraintes différentes, où être asynchrone du haut est vraiment mieux, mais d'après notre expérience, la synchronisation avec le premier attente est clairement le meilleur compromis pour Dart.

réponses cramert (notez qu'une partie de cette syntaxe est désormais obsolète):

Si vous avez besoin de code pour s'exécuter immédiatement lorsqu'une fonction est appelée plutôt que plus tard lorsque le futur est interrogé, vous pouvez écrire votre fonction comme ceci:

fn foo() -> impl Future<Item=Thing> {
    println!("prints immediately");
    async_block! {
        println!("prints when the future is first polled");
        await!(bar());
        await!(baz())
    }
}

Exemples de code

Ces exemples utilisent la prise en charge asynchrone dans 1.37.0-nightly (2019-06-05) et la caisse d'aperçu à terme (0.3.0-alpha.16).

Transcription littérale du code C #

#![feature(async_await)]

async fn long_running_operation(a: u8, b: u8) -> u8 {
    println!("long_running_operation");

    a + b
}

fn another_operation(c: u8, d: u8) -> u8 {
    println!("another_operation");

    c * d
}

async fn foo() -> u8 {
    println!("foo");

    let sum = long_running_operation(1, 2);

    another_operation(3, 4);

    sum.await
}

fn main() {
    let task = foo();

    futures::executor::block_on(async {
        let v = task.await;
        println!("Result: {}", v);
    });
}

Si vous avez appelé foo, la séquence d'événements dans Rust serait:

  1. Quelque chose implémentant Future<Output = u8> Est retourné.

C'est ça. Aucun travail "réel" n'est encore effectué. Si vous prenez le résultat de foo et le conduisez vers la fin (en l'interrogeant, dans ce cas via futures::executor::block_on), Les étapes suivantes sont les suivantes:

  1. Quelque chose implémentant Future<Output = u8> Est retourné en appelant long_running_operation (Il ne démarre pas encore).

  2. another_operation Fonctionne car il est synchrone.

  3. la syntaxe .await fait démarrer le code dans long_running_operation. Le futur foo continuera de renvoyer "non prêt" jusqu'à ce que le calcul soit terminé.

La sortie serait:

foo
another_operation
long_running_operation
Result: 3

Notez qu'il n'y a pas de pools de threads ici: tout cela se fait sur un seul thread.

async blocs

Vous pouvez également utiliser des blocs async:

use futures::{future, FutureExt};

fn long_running_operation(a: u8, b: u8) -> u8 {
    println!("long_running_operation");

    a + b
}

fn another_operation(c: u8, d: u8) -> u8 {
    println!("another_operation");

    c * d
}

async fn foo() -> u8 {
    println!("foo");

    let sum = async { long_running_operation(1, 2) };
    let oth = async { another_operation(3, 4) };

    let both = future::join(sum, oth).map(|(sum, _)| sum);

    both.await
}

Ici, nous enveloppons le code synchrone dans un bloc async puis attendons que les deux actions se terminent avant que cette fonction soit terminée.

Notez que l'encapsulation de code synchrone comme celui-ci est pas une bonne idée pour tout ce qui prendra réellement beaucoup de temps; voir Quelle est la meilleure approche pour encapsuler les E/S bloquantes dans future-rs? pour plus d'informations.

Avec un threadpool

use futures::{executor::ThreadPool, future, task::SpawnExt, FutureExt};

async fn foo(pool: &mut ThreadPool) -> u8 {
    println!("foo");

    let sum = pool
        .spawn_with_handle(async { long_running_operation(1, 2) })
        .unwrap();
    let oth = pool
        .spawn_with_handle(async { another_operation(3, 4) })
        .unwrap();

    let both = future::join(sum, oth).map(|(sum, _)| sum);

    both.await
}
23
Shepmaster

Considérez ce code pseudo-JavaScript simple qui récupère certaines données, les traite, récupère d'autres données en fonction de l'étape précédente, les résume, puis imprime un résultat:

getData(url)
   .then(response -> parseObjects(response.data))
   .then(data -> findAll(data, 'foo'))
   .then(foos -> getWikipediaPagesFor(foos))
   .then(sumPages)
   .then(sum -> console.log("sum is: ", sum));

Dans async/await forme, c'est:

async {
    let response = await getData(url);
    let objects = parseObjects(response.data);
    let foos = findAll(objects, 'foo');
    let pages = await getWikipediaPagesFor(foos);
    let sum = sumPages(pages);
    console.log("sum is: ", sum);
}

Il introduit de nombreuses variables à usage unique et est sans doute pire que la version originale avec des promesses. Alors pourquoi s'embêter?

Considérez ce changement, où les variables response et objects sont nécessaires plus tard dans le calcul:

async {
    let response = await getData(url);
    let objects = parseObjects(response.data);
    let foos = findAll(objects, 'foo');
    let pages = await getWikipediaPagesFor(foos);
    let sum = sumPages(pages, objects.length);
    console.log("sum is: ", sum, " and status was: ", response.status);
}

Et essayez de le réécrire dans la forme originale avec des promesses:

getData(url)
   .then(response -> Promise.resolve(parseObjects(response.data))
       .then(objects -> Promise.resolve(findAll(objects, 'foo'))
           .then(foos -> getWikipediaPagesFor(foos))
           .then(pages -> sumPages(pages, objects.length)))
       .then(sum -> console.log("sum is: ", sum, " and status was: ", response.status)));

Chaque fois que vous devez vous référer à un résultat précédent, vous devez imbriquer la structure entière d'un niveau plus en profondeur. Cela peut rapidement devenir très difficile à lire et à gérer, mais la version async/await ne souffre pas de ce problème.

6
Peter Hall

Le but de async/await dans Rust est de fournir une boîte à outils pour la simultanéité, comme en C # et dans d'autres langages.

En C # et JavaScript, les méthodes async commencent à s'exécuter immédiatement et elles sont planifiées, que vous await le résultat ou non. Dans Python et Rust, lorsque vous appelez une méthode async, rien ne se passe (ce n'est même pas planifié) tant que vous await ne l'avez pas. Mais c'est en grande partie le même style de programmation de toute façon.

Je pense que vous avez raison de dire que la possibilité de générer une autre tâche (qui s'exécute simultanément et indépendamment de la tâche actuelle) est une pièce manquante. Peut-être que ce sera ajouté. (N'oubliez pas que async de Rust n'est pas encore terminé - le design évolue toujours.)


Quant à pourquoi Rust async n'est pas exactement comme C #, eh bien, considérez les différences entre les deux langues:

  • Rust décourage l'état mutable global. En C # et JS, chaque appel de méthode async est implicitement ajouté à une file d'attente mutable globale. C'est un effet secondaire dans un contexte implicite. Pour le meilleur ou pour le pire, ce n'est pas le style de Rust.

  • Rust n'est pas un framework. Il est logique que C # fournisse une boucle d'événement par défaut. Il fournit également un grand collecteur d'ordures! Beaucoup de choses qui viennent en standard dans d'autres langues sont des bibliothèques optionnelles dans Rust.

3
Jason Orendorff