🚧 Attention, peinture fraîche !

Cette page a été traduite par une seule personne et n'a pas été relue et vérifiée par quelqu'un d'autre ! Les informations peuvent par exemple être erronées, être formulées maladroitement, ou contenir d'autres types de fautes.

Vous pouvez contribuer à l'amélioration de cette page sur sa Pull Request.

Pourquoi l'asynchrone ?

Nous apprécions tous la façon dont Rust nous permet d'écrire rapidement des programmes sûrs. Mais comment la programmation asynchrone s'inscrit-elle dans cette démarche ?

La programmation asynchrone, abrégé async, est un modèle de programmation concurrent pris en charge par un nombre croissant de langages de programmation. Il vous permet d'exécuter un grand nombre de tâches concurrentes sur un petit nombre de processus du Système d'Exploitation, tout en conservant l'apparence et la convivialité de la programmation synchrone habituelle, grâce à la syntaxe async/await.

L'asynchrone et les autres modèles de concurrence

La programmation concurrente est moins mûre et moins "formalisée" que la programmation séquentielle classique. Par conséquent, nous formulons la concurrence différemment selon le modèle de programmation pris en charge par le langage. Un bref panorama des modèles de concurrence les plus populaires peut vous aider à comprendre où se situe la programmation asynchrone dans le domaine plus large de la programmation asynchrone :

  • Les processus du système d'exploitation ne nécessitent aucun changement dans le modèle de programmation, ce qui facilite l'expression de la concurrence. Cependant, la synchronisation entre les processus peut être difficile, et la conséquence sur les performances est importante. Les groupes de processus peuvent réduire certains coûts, mais pas suffisamment pour faire face à la charge de travail d'une grosse masse d'entrées/sorties.
  • La programmation orientée évènements, conjuguée avec les fonctions de rappel, peut s'avérer très performante, mais a tendance à produire un contrôle de flux "non-linéaire" et verbeux. Les flux de données et les propagations d'erreurs sont souvent difficiles à suivre.
  • Les coroutines, comme les processus, ne nécessitent pas de changements sur le modèle de programmation, ce qui facilite leur utilisation. Comme l'asynchrone, elles peuvent supporter de nombreuses tâches. Cependant, elles font abstraction des détails de bas niveau, qui sont importants pour la programmation système et les implémentations personnalisées d'environnements d'exécution.
  • Le modèle acteur divise tous les calculs concurrents en différentes parties que l'on appelle acteurs, qui communiquent par le biais de passage de messages faillibles, comme dans les systèmes distribués. Le modèle d'acteur peut être implémenté efficacement, mais il ne répondra pas à tous les problèmes, comme le contrôle de flux et la logique de relance.

En résumé, la programmation asynchrone permet des implémentations très performantes qui sont nécessaires pour des langages bas-niveau comme Rust, tout en offrant les avantages ergonomiques aux processus et aux coroutines.

L'asynchrone en Rust et dans les autres langages

Bien que la programmation asynchrone soit prise en charge dans de nombreux langages, certains détails changent selon les implémentations. L'implémentation en Rust de async se distingue des autres langages de plusieurs manières :

  • Les futures sont inertes en Rust et progressent uniquement lorsqu'elles sont sollicitées. Libérer une future va arrêter sa progression.
  • L'asynchrone n'a pas de coût en Rust, ce qui signifie que vous ne payez que ce que vous utilisez. Plus précisément, vous pouvez utiliser async sans allouer sur le tas et sans répartition dynamique, ce qui est très intéressant pour les performances ! Cela vous permet également d'utiliser async dans des environnements restreints, comme par exemple sur des systèmes embarqués.
  • Il n'y a pas d'environnement d'exécution intégré par défaut dans Rust. Par contre, des environnements d'exécution sont disponibles dans des crates maintenues par la communauté.
  • Des environnements d'exécution mono-processus et multi-processus existent en Rust, qui ont chacun leurs avantages et inconvénients.

L'asynchrone et les processus en Rust

La première alternative à l'asynchrone en Rust est d'utiliser les processus du Système d'Exploitation, soit directement via std::thread, soit indirectement via un groupe de processus. La migration des processus vers de l'asynchrone et vice-versa nécessite généralement un gros chantier de remaniement, que ce soit pour leur implémentation ou pour leurs interfaces publique (si vous écrivez une bibliothèque) . Par conséquent, vous pouvez vous épargner beaucoup de temps de développement si vous choisissez très tôt le modèle qui convient bien à vos besoins.

Les processus de Système d'Exploitation sont préférables pour un petit nombre de tâches, puisque les processus s'accompagnent d'une surcharge du processeur et de la mémoire. Créer et basculer entre les processus est assez gourmand, car même les processus inutilisés consomment des ressources système. Une bibliothèque implémentant des groupe de tâches peut aider à atténuer certains coûts, mais pas tous. Cependant, les processus vous permet de réutiliser du code synchrone existant sans avoir besoin de changement significatif du code — il n'y a pas besoin d'avoir de modèle de programmation en particulier. Avec certains systèmes d'exploitation, vous pouvez aussi changer la priorité d'un processus, ce qui peut être pratique pour les pilotes et les autres utilisations sensibles à la latence.

L'asynchrone permet de réduire significativement la surcharge du processeur et de la mémoire, en particulier pour les charges de travail avec un grand nombre de tâches liées à des entrées/sorties, comme les serveurs et les bases de données. Pour comparaison à la même échelle, vous pouvez avoir un nombre bien plus élevé de tâches qu'avec les processus du Système d'Exploitation, car comme un environnement d'exécution asynchrone utilise une petite partie des (coûteux) processus pour gérer une grande quantité de tâches (peu coûteuses). Cependant, le Rust asynchrone produit des binaires plus lourds à cause des machines à états générés à partir des fonctions asynchrones et que par conséquent chaque exécutable embarque un environnement d'exécution asynchrone.

Une dernière remarque, la programmation asynchrone n'est pas meilleure que les processus, c'est différent. Si vous n'avez pas besoin de l'asynchrone pour des raisons de performance, les processus sont souvent une alternative plus simple.

Exemple : un téléchargement concurrent

Dans cet exemple, notre objectif est de télécharger deux pages web en concurrence. Dans une application traditionnelle avec des processus nous avons besoin de créer des processus pour appliquer la concurrence :

fn recuperer_deux_sites() {
    // Crée deux tâches pour faire le travail.
    let premiere_tache = std::thread::spawn(|| telecharger("https://www.foo.com"));
    let seconde_tache = std::thread::spawn(|| telecharger("https://www.bar.com"));

    // Attente que les deux tâches se terminent.
    premiere_tache.join().expect("la première tâche a paniqué");
    seconde_tache.join().expect("la deuxième tâche a paniqué");
}

Cependant, le téléchargement d'une page web est une petite tâche, donc créer un processus pour une si petite quantité de travail est un peu du gaspillage. Pour une application plus importante, cela peut rapidement devenir un goulot d'étranglement. Grâce au Rust asynchrone, nous pouvons exécuter ces tâches en concurrence sans avoir besoin de processus supplémentaires :

async fn recuperer_deux_sites_asynchrone() {
    // Crée deux différentes "futures" qui, lorsqu'elles sont menée à terme,
    // va télécharger les pages web de manière asynchrone.
    let premier_future = telecharger_asynchrone("https://www.foo.com");
    let second_future = telecharger_asynchrone("https://www.bar.com");

    // Exécute les deux futures en même temps jusqu'à leur fin.
    futures::join!(premier_future, second_future);
}

Notez bien que ici, il n'y a pas de processus supplémentaires qui sont créés. De plus, tous les appels à des fonctions sont distribués statiquement, et il n'y a pas d'allocation sur le tas ! Cependant, nous avons d'abord besoin d'écrire le code pour être asynchrone, ce que ce livre va vous aider à accomplir.

Les modèles personnalisés de concurrence en Rust

Une dernière remarque, Rust ne vous forçait pas à choisir entre les processus et l'asynchrone. Vous pouvez utiliser ces deux modèles au sein d'une même application, ce qui peut être utile lorsque vous mélangez les dépendances de processus et d'asynchrone. En fait, vous pouvez même utiliser un modèle de concurrence complètement différent en même temps, du moment que vous trouvez une bibliothèque qui l'implémente.