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 ensemble : futures, tâches et fils d’exécution

Comme nous l’avons vu dans le chapitre 16, les fils d’exécution proposent une approche à la programmation concurrente. Nous avons vu une autre approche dans ce chapitre : l’utilisation de l’asynchronisme avec les futures et les flux. Si vous vous demandez quand choisir une méthode plutôt que l’autre, la réponse est : ça dépend ! Et dans bien des cas, le choix n’est pas tant fils d’exécution ou asynchronisme, mais plutôt à fils d’exécution et asynchronisme.

De nombreux systèmes d’exploitation proposent des modèles de concurrence basés sur les fils d’exécution depuis des décennies et, conséquemment, bien des langages de programmation les prennent en charge. Cependant, ces modèles ne sont pas sans inconvénients. Sur de nombreux systèmes d’exploitation, ils utilisent une quantité non négligeable de mémoire pour chaque fil d’exécution. Les fils d’exécution ne sont par ailleurs une solution envisageable qu’à la condition que le système d’exploitation ainsi que le matériel les prennent en charge. Contrairement aux ordinateurs de bureau et portables courants, certains systèmes embarqués ne disposent d’aucun système d’exploitation, ils n’ont donc pas non plus de fils d’exécution.

Le modèle asynchrone propose un ensemble différent — et, en fin de compte, complémentaire — de compromis. Dans le modèle asynchrone, les opérations concurrentes n’ont pas besoin d’avoir leurs fils d’exécution propres. À la place, elles peuvent s’exécuter sous forme de tâches, comme lorsque nous avons utilisé trpl::spawn_task pour lancer du travail à partir d’une fonction synchrone dans la section consacrée aux flux. Une tâche est semblable à un fil d’exécution, mais au lieu d’être gérée par le système d’exploitation, elle est gérée par le code au niveau de la bibliothèque : le moteur d’exécution.

Il y a une raison pour laquelle les APIs qui créent des fils d’exécution et celles qui créent des tâches sont si semblables. Les fils d’exécution servent de délimitation pour des ensembles d’opérations synchrones ; la concurrence est possible à la fois entre et à l’intérieur de tâches, car une tâche peut basculer entre plusieurs futures dans son corps. Enfin, les futures constituent l’unité de concurrence la plus granulaire, et chaque future peut représenter tout un arbre d’autres futures. Le moteur d’exécution — plus précisément son exécuteur — gère les tâches, et les tâches gèrent les futures. À cet égard, les tâches sont comparables à des fils d’exécution légers gérés par un moteur d’exécution plutôt que par le système d’exploitation.

Ceci ne veut pas dire que les tâches asynchrones seront toujours meilleures que les fils d’exécution (ou vice-versa). La concurrence avec les fils d’exécution est, d’une certaine manière, un modèle de programmation plus simple que la concurrence avec async. Cela peut être une force comme une faiblesse. Les fils d’exécution sont en quelque sorte du genre “tire et oublie” ; ils n’ont pas d’équivalent natif à une future, ils s’exécutent donc simplement jusqu’à leur terme sans être interrompus, sauf par le système d’exploitation lui-même.

Il se trouve que les fils d’exécution et les tâches cohabitent souvent très bien ensemble, de par le fait que les tâches peuvent (au moins avec certains moteurs d’exécution) être déplacées entre fils d’exécution. En fait, sous le capot, le moteur d’exécution que nous avons utilisé — y compris les fonctions spawn_blocking et spawn_task — est multifil par défaut ! De nombreux moteurs d’exécution utilisent une stratégie appelée vol de travail (NDT : work stealing) pour déplacer des tâches d’un fil d’exécution à l’autre de manière transparente, en se basant sur l’utilisation actuelle des fils d’exécution, ce afin d’améliorer les performances globales du système. Cette approche nécessite en fait à la fois des fils d’exécution et des tâches, et conséquemment des futures.

Quand vous vous demandez quelle méthode utiliser et quand, prenez en compte les règles empiriques suivantes :

  • Si le travail à faire est très facilement parallélisable (en d’autres termes, elle dépend principalement du processeur), comme par exemple le traitement d’un tas de données dont chaque partie peut être traitée séparément, les fils d’exécution sont le meilleur choix.
  • Si le travail est très concurrent (c’est-à-dire limité par les opérations d’entrées/sorties), comme le traitement de messages provenant de diverses sources qui peuvent arriver à des intervalles différents ou avec des débits différents, l’asynchronisme est le meilleur choix.

Et si vous avez besoin de parallélisme et de concurrence, vous n’avez pas à faire un choix entre les fils d’exécution et l’asynchronisme. Vous pouvez les utiliser librement ensemble, en laissant chacun faire ce qu’il sait faire le mieux. Par exemple, l’encart 17-25 montre un exemple assez courant de ce genre de combinaison dans du code Rust concret.

Filename: src/main.rs
extern crate trpl; // pour 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(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 de communication asynchrone, puis nous créons un fil d’exécution qui prend possession de la partie émettrice du canal avec le mot-clé move. À l’intérieur du fil d’exécution, nous envoyons les nombres de 1 à 10, en attendant une seconde entre chaque. Finalement, nous lançons une future créée avec un bloc asynchrone passé à trpl::block_on, exactement comme nous l’avons fait tout au long de ce chapitre. Dans cette future, nous attendons les messages, exactement comme dans les autres exemples de passation de messages que nous avons vus.

Pour revenir au scénario avec lequel nous avions commencé ce chapitre, imaginons l’exécution d’un ensemble de tâches d’encodage vidéo utilisant un fil d’exécution dédié (car l’encodage vidéo est très gourmand en ressources de calcul) mais en informant l’interface utilisateur que ces opérations se font avec un canal asynchrone. Il y a de nombreux exemples de ces types de combinaisons dans des cas d’utilisation concrets.

Résumé

Ce n’est pas la dernière fois que vous allez rencontrer de la concurrence dans ce livre : le projet du chapitre 21 va utiliser ces concepts dans une situation plus réaliste que les petits exemples que nous avons utilisés ici, et comparera plus directement la résolution de problèmes à l’aide des fils d’exécution par rapport aux tâches et aux futures.

Peu importe laquelle de ces approches vous choisissez, Rust vous fournit les outils nécessaires pour que vous écriviez du code sécurisé, rapide et concurrent — que ce soit pour un serveur web à haut débit ou pour un système d’exploitation embarqué.

Au chapitre suivant, nous allons voir des techniques adaptées pour modéliser des problèmes et structurer votre solution au fur et à mesure que vos programmes en Rust grandissent. De plus, nous analyserons les liens qui peuvent exister entre les idées de Rust et celles avec lesquelles vous êtes peut-être familier en programmation orientée objet.