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

Tout assembler : futures, tâches et threads

Comme nous l’avons vu au chapitre 16, les threads fournissent une approche de la concurrence. Nous en avons vu une autre dans ce chapitre, à savoir l’utilisation d’async avec des futures et des streams. Si vous vous demandez quand vous utiliseriez l’une plutôt que l’autre, la réponse est : cela dépend{N}! Et dans de nombreux cas, le choix n’est pas threads ou async mais plutôt threads et async.

De nombreux systèmes d’exploitation fournissent des modèles de concurrence basés sur les threads depuis des décennies maintenant, et de nombreux langages de programmation les supportent en conséquence. Cependant, ces modèles ne sont pas sans compromis. Sur de nombreux systèmes d’exploitation, ils utilisent pas mal de mémoire pour chaque thread. Les threads ne sont aussi une option que quand votre système d’exploitation et votre matériel les supportent. Contrairement aux ordinateurs de bureau et mobiles grand public, certains systèmes embarqués n’ont pas du tout de système d’exploitation, donc ils n’ont pas non plus de threads.

Le modèle async fournit un ensemble de compromis différent — et finalement complémentaire. Dans le modèle async, les opérations concurrentes ne nécessitent pas leurs propres threads. À la place, elles peuvent s’exécuter sur des tâches, comme quand nous avons utilisé trpl::spawn_task pour lancer du travail depuis une fonction synchrone dans la section sur les streams. Une tâche est similaire à un thread, mais au lieu d’être gérée par le système d’exploitation, elle est gérée par du code au niveau de la bibliothèque : le runtime.

Il y à une raison pour laquelle les API pour lancer des threads et lancer des tâches sont si similaires. Les threads agissent comme une frontière pour des ensembles d’opérations synchrones ; la concurrence est possible entre les threads. Les tâches agissent comme une frontière pour des ensembles d’opérations asynchrones ; la concurrence est possible à la fois entre et au sein des tâches, car une tâche peut alterner entre les futures dans son corps. Enfin, les futures sont l’unité de concurrence la plus granulaire de Rust, et chaque future peut représenter un arbre d’autres futures. Le runtime — spécifiquement, son exécuteur — gère les tâches, et les tâches gèrent les futures. À cet égard, les tâches sont similaires à des threads légers gérés par le runtime avec des capacités supplémentaires qui viennent du fait d’être gérées par un runtime plutôt que par le système d’exploitation.

Cela ne signifie pas que les tâches async sont toujours meilleures que les threads (ou vice versa). La concurrence avec les threads est à certains égards un modèle de programmation plus simple que la concurrence avec async. Cela peut être une force ou une faiblesse. Les threads sont quelque peu « lancer et oublier » ; ils n’ont pas d’équivalent natif à une future, donc ils s’exécutent simplement jusqu’à la fin sans être interrompus sauf par le système d’exploitation lui-même.

Et il s’avère que les threads et les tâches fonctionnent souvent très bien ensemble, car les tâches peuvent (au moins dans certains runtimes) être déplacées entre les threads. En fait, en coulisses, le runtime que nous avons utilisé — y compris les fonctions spawn_blocking et spawn_task — est multithread par défaut ! Beaucoup de runtimes utilisent une approche appelée vol de travail (work stealing) pour déplacer de manière transparente les tâches entre les threads, en fonction de la façon dont les threads sont actuellement utilisés, pour améliorer les performances globales du système. Cette approche nécessite en fait des threads et des tâches, et donc des futures.

Quand vous réfléchissez à quelle méthode utiliser et quand, considérez ces règles empiriques :

  • Si le travail est très parallélisable (c’est-à-dire limité par le CPU), comme traiter un ensemble de données où chaque partie peut être traitée séparément, les threads sont un meilleur choix.
  • Si le travail est très concurrent (c’est-à-dire limité par les E/S), comme gérer des messages provenant de nombreuses sources différentes qui peuvent arriver à différents intervalles ou à différentes fréquences, l’async est un meilleur choix.

Et si vous avez besoin à la fois du parallélisme et de la concurrence, vous n’avez pas à choisir entre les threads et l’async. Vous pouvez les utiliser ensemble librement, en laissant chacun jouer le rôle dans lequel il excelle. Par exemple, l’encart 17-25 montre un exemple assez courant de ce type de mélange dans le code Rust du monde réel.

Filename: src/main.rs
extern crate trpl; // for mdbook test

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(message) = rx.recv().await {
            println!("{message}");
        }
    });
}
Listing 17-25: Sending messages with blocking code in a thread and awaiting the messages in an async block

Nous commençons par créer un canal async, puis lançons un thread qui prend possession du côté émetteur du canal en utilisant le mot-clé move. Dans le thread, nous envoyons les nombres de 1 à 10, en dormant une seconde entre chaque. Enfin, nous exécutons une future créée avec un bloc async passé à trpl::block_on comme nous l’avons fait tout au long du chapitre. Dans cette future, nous attendons ces messages, comme dans les autres exemples de passage de messages que nous avons vus.

Pour revenir au scénario avec lequel nous avons ouvert le chapitre, imaginez exécuter un ensemble de tâches d’encodage vidéo en utilisant un thread dédié (parce que l’encodage vidéo est limité par le calcul) mais notifier l’interface utilisateur que ces opérations sont terminées avec un canal async. Il existe d’innombrables exemples de ce genre de combinaisons dans les cas d’utilisation du monde réel.

Résumé

Ce n’est pas la dernière fois que vous verrez la concurrence dans ce livre. Le projet du chapitre 21 utilisera les concepts de ce chapitre dans une situation plus réaliste que les petits exemples abordés ici — et les comparera de manière plus directe à ce à quoi ressemble la tâche de le faire avec des threads.

Quelle que soit l’approche que vous choisissez, Rust vous donne les outils dont vous avez besoin pour écrire du code concurrent sûr et rapide — que ce soit pour un serveur web à haut débit ou un système d’exploitation embarqué.

Ensuite, nous parlerons des façons idiomatiques de modéliser les problèmes et de structurer les solutions à mesure que vos programmes Rust grandissent. De plus, nous discuterons de la relation entre les idiomes de Rust et ceux que vous pourriez connaître de la programmation orientée objet.