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

Cession du contrôle au moteur d’exécution

Rappelez-vous de la section “Notre premier programme asynchrone”, qu’à chaque point d’attente, Rust donne au moteur d’exécution l’occasion de suspendre la tâche en cours et de pouvoir basculer vers une autre tâche si la future attendue n’est pas prête. La réciproque est également vraie : Rust met uniquement en pause les blocs asynchrones et rend la main au moteur d’exécution à chaque point d’attente. Tout ce qui se trouve entre les points d’attente est synchrone.

Ceci implique que si vous faites toute une série d’opérations dans un bloc asynchrone sans aucun point d’attente, cette future va empêcher toutes les autres futures de pouvoir avancer. On parle parfois de ce phénomène comme d’une future qui affame les autres futures. Dans certains cas, cela n’a guère d’importance. Toutefois, si vous faites quelque chose comme une configuration complexe ou bien un travail de longue haleine, ou encore si vous avez une future qui va continuer à effectuer une tâche particulière indéfiniment, vous devrez réfléchir à l’endroit et au moment où rendre le contrôle au moteur d’exécution.

Simulons une opération de longue haleine pour illustrer ce problème de famine, puis voyons comment le résoudre. L’encart 17-14 montre une fonction lente.

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

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        // Plus tard, nous appellerons `lente` ici
    });
}

fn lente(nom: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nom}' s'est exécuté durant {ms}ms");
}
Listing 17-14: Using thread::sleep to simulate slow operations

Ce code utilise std::thread::sleep à la place de trpl::sleep, de manière à ce que l’appel à slow bloquer la tâche courante pour un nombre défini de millisecondes. Nous pouvons utiliser lente pour simuler des opérations réelles qui sont à la fois longues et bloquantes.

Dans l’encart 17-15, nous utilisons lente pour simuler l’exécution de ce type de tâche gourmande en ressources CPE dans quelques futures.

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

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' a démarré.");
            lente("a", 30);
            lente("a", 10);
            lente("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' a terminé.");
        };

        let b = async {
            println!("'b' a démarré.");
            lente("b", 75);
            lente("b", 10);
            lente("b", 15);
            lente("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' a terminé.");
        };

        trpl::select(a, b).await;
    });
}

fn lente(nom: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nom}' s'est exécuté durant {ms}ms");
}
Listing 17-15: Calling the slow function to simulate slow operations

Chaque future ne rend le contrôle au moteur d’exécution qu’après avoir effectué tout un tas d’opérations lentes. Si vous exécutez ce code, vous obtiendrez la sortie suivante :

'a' a démarré.
'a' s'est exécuté durant 30ms
'a' s'est exécuté durant 10ms
'a' s'est exécuté durant 20ms
'b' a démarré.
'b' s'est exécuté durant 75ms
'b' s'est exécuté durant 10ms
'b' s'est exécuté durant 15ms
'b' s'est exécuté durant 350ms
'a' a terminé.

Comme pour l’encart 17-5 où nous avions utilisé trpl::select pour faire concourir des futures récupérant deux URLs, select se termine toujours dès que a a terminé. Il n’y a toutefois pas d’entrelacement entre les appels à lente dans les deux futures. La future a effectue l’entièreté de son travail jusqu’à ce que l’appel à trpl::sleep soit attendu, puis la future b effectue tout son travail jusqu’à ce que son propre appel à trpl::sleep soit attendu, et enfin la future a se termine. Afin de permettre à toutes les deux futures de progresser entre leurs tâches lentes, nous avons besoin de points d’attente afin de pouvoir rendre le contrôle au moteur d’exécution. Cela significant que nous avons besoin de quelque chose que nous pouvons attendre !

Nous pouvons déjà voir ce type de passation dans l’encart 17-15 : si nous supprimions le trpl::sleep à la fin de la future a, cette dernière se terminerait sans que la future b ne s’exécute du tout. Essayons d’utiliser la fonction trpl::sleep comme point de départ pour permettre aux opérations de suspendre leur exécution, comme le montre l’encart 17-16.

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

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let une_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' a démarré.");
            lente("a", 30);
            trpl::sleep(une_ms).await;
            lente("a", 10);
            trpl::sleep(une_ms).await;
            lente("a", 20);
            trpl::sleep(une_ms).await;
            println!("'a' a terminé.");
        };

        let b = async {
            println!("'b' a démarré.");
            lente("b", 75);
            trpl::sleep(une_ms).await;
            lente("b", 10);
            trpl::sleep(une_ms).await;
            lente("b", 15);
            trpl::sleep(une_ms).await;
            lente("b", 350);
            trpl::sleep(une_ms).await;
            println!("'b' a terminé.");
        };

        trpl::select(a, b).await;
    });
}

fn lente(nom: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nom}' s'est exécuté durant {ms}ms");
}
Listing 17-16: Using trpl::sleep to let operations switch off making progress

Nous avons ajouté des appels à trpl::sleep avec des points d’attente avant chaque appel à lente. Le travail des deux futures est maintenant entrelacé.

'a' a démarré.
'a' s'est exécuté durant 30ms
'b' a démarré.
'b' s'est exécuté durant 75ms
'a' s'est exécuté durant 10ms
'b' s'est exécuté durant 10ms
'a' s'est exécuté durant 20ms
'b' s'est exécuté durant 15ms
'a' a terminé.

La future a continuer à s’exécuter un peu de temps avant de rendre le contrôle à b, car elle appelle lente avant même d’appeler trpl::sleep, mais après cela, les futures s’échangent la place à chaque fois que l’une d’elles atteint un point d’attente. Dans ce cas, nous avons procédé ainsi après chaque appel à lente, mais nous pourrions diviser le travail de n’importe quelle manière qui nous semble la plus appropriée.

Toutefois, nous ne voulons pas vraiment dormir (NDT : sleep) ici : nous voulons continuer à avancer le plus vite possible. Nous voulons simplement rendre le contrôle au moteur d’exécution. Nous pouvons faire cela directement, en utilisant la fonction trpl::yield_now. Dans l’encart 17-17, nous remplaçons tous ces appels à trpl::sleep par trpl::yield_now.

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

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(async {
        let a = async {
            println!("'a' a démarré.");
            lente("a", 30);
            trpl::yield_now().await;
            lente("a", 10);
            trpl::yield_now().await;
            lente("a", 20);
            trpl::yield_now().await;
            println!("'a' a terminé.");
        };

        let b = async {
            println!("'b' a démarré.");
            lente("b", 75);
            trpl::yield_now().await;
            lente("b", 10);
            trpl::yield_now().await;
            lente("b", 15);
            trpl::yield_now().await;
            lente("b", 350);
            trpl::yield_now().await;
            println!("'b' a terminé.");
        };

        trpl::select(a, b).await;
    });
}

fn lente(nom: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nom}' s'est exécuté durant {ms}ms");
}
Listing 17-17: Using yield_now to let operations switch off making progress

Ce code est à la fois plus clair en ce qui concerne l’intention réelle et il peut s’avérer significativement plus rapide que l’utilisation de sleep, car les temporisateurs comme celui utilisé par sleep ont souvent des limites quant à leur granularité. La version de sleep que nous utilisons, par exemple, attendra toujours au moins une milliseconde, même si nous lui passons une Duration d’une nanoseconde. Une fois de plus, les ordinateurs modernes sont rapides : ils peuvent accomplir beaucoup de choses en une seule milliseconde !

Ceci implique que la programmation asynchrone peut être utile, y compris pour des tâches se limitant à du calcul, selon ce que fait le reste du programme, car elle offre un outil pratique pour structurer les relations entre différentes parties du programme (mais avec le coût de la surcharge de la machine à états asynchrone). C’est là un genre de multitâches coopératif, où chaque future à le pouvoir de déterminer quand elle rend la main via des points d’attente. Chaque futurea donc aussi la responsabilité d’éviter de bloquer l’exécution pendant trop de temps. Dans certains systèmes d’exploitation embarqués basés sur Rust, il s’agit du seul mode de multitâches !

Dans du code du monde réel, vous ne basculerez généralement pas entre des appels de fonctions avec des points d’attente à chaque ligne, bien entendu. Bien que céder le contrôle de cette manière est relativement peu coûteux, ça n’est pas non plus totalement gratuit. Dans de nombreuses situations, tenter de morceler une tâche qui n’effectue que des calculs pourrait la rendre nettement plus lente ; donc il est parfois préférable, pour l’efficacité globale, de laisser une opération bloquer brièvement. Il vous faut toujours mesurer vos performances pour identifier les véritables goulots d’étranglement de votre code. Il est toutefois important de garder à l’esprit la dynamique sous-jacente, si vous constatez que beaucoup de travail se déroule en série alors que vous vous attendiez à ce qu’il se fasse en parallèle !

Construction de nos propres abstractions asynchrones

Nous pouvons également combiner ensemble des futures pour créer de nouveaux motifs. Par exemple, nous pouvons construire une fonction expiration_delai_attente avec des éléments asynchrones que nous avons déjà. Une fois cela terminé, le résultat constituera un autre élément que nous pourrons utiliser pour créer encore davantage d’abstractions asynchrones.

L’encart 17-18 montre comment nous pourrions envisager le travail de cette fonction expiration_delai_attente avec une future lente.

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

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let lente = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finalement terminé"
        };

        match expiration_delai_attente(lente, Duration::from_secs(2)).await {
            Ok(message) => println!("Réussi avec '{message}'"),
            Err(duree) => {
                println!("Échec après {} secondes", duree.as_secs())
            }
        }
    });
}
Listing 17-18: Using our imagined timeout to run a slow operation with a time limit

Implémentons cela ! Pour commencer, réfléchissons à l’API pour expiration_delai_attente :

  • Elle doit être elle-même une fonction asynchrone, de manière à ce que nous puissions l’attendre.
  • Son premier paramètre devrait être une future à exécuter. Nous pouvons la rendre générique afin de lui permettre de fonctionner avec n’importe quelle future.
  • Son second paramètre sera le laps de temps maximal à attendre. Si nous utilisons une Duration, cela rendra aisé le passage à trpl::sleep.
  • Elle doit renvoyer un Result. Si la future se termine correctement, le Result sera un Ok avec la valeur produite par la future. Si le délai arrive à terme en premier, le Result sera Err avec la durée qui a été attendue.

L’encart 17-19 montre cette déclaration.

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

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let lente = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finalement terminé"
        };

        match expiration_delai_attente(lente, Duration::from_secs(2)).await {
            Ok(message) => println!("Réussi avec '{message}'"),
            Err(duree) => {
                println!("Échec après {} secondes", duree.as_secs())
            }
        }
    });
}

async fn expiration_delai_attente<F: Future>(
    future_a_essayer: F,
    delai_maxi: Duration,
) -> Result<F::Output, Duration> {
    // Notre implémentation viendra ici !
}
Listing 17-19: Defining the signature of timeout

Voilà qui satisfait nos objectifs en ce qui concerne les types. Réfléchissons maintenant au comportement dont nous avons besoin : nous voulons faire concourir la future passée en argument contre la durée. Nous pouvons utiliser trpl::sleep pour faire une minuterie future à partir de la durée, et utiliser trpl::sleep avec la future que l’appelant a transmise.

Dans l’encart 17-20, nous implémentons expiration_delai_attente en faisant une comparaison avec le résultat de l’attente de trpl::select.

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

use std::time::Duration;

use trpl::Either;

// -- partie masquée ici --

fn main() {
    trpl::block_on(async {
        let lente = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finalement terminé"
        };

        match expiration_delai_attente(lente, Duration::from_secs(2)).await {
            Ok(message) => println!("Réussi avec '{message}'"),
            Err(duree) => {
                println!("Échec après {} secondes", duree.as_secs())
            }
        }
    });
}

async fn expiration_delai_attente<F: Future>(
    future_a_essayer: F,
    delai_maxi: Duration,
) -> Result<F::Output, Duration> {
    match trpl::select(future_a_essayer, trpl::sleep(delai_maxi)).await {
        Either::Left(sortie) => Ok(sortie),
        Either::Right(_) => Err(delai_maxi),
    }
}
Listing 17-20: Defining timeout with select and sleep

L’implémentation de trpl::select n’est pas équitable : elle interroge toujours les arguments dans l’ordre où ils sont passés (d’autres implementations de select choisissent au hasard quel argument est à interroger en premier). De ce fait, nous passons future_a_essayer à select en premier de façon à ce qu’elle puisse avoir une chance de se terminer quand bien même delai_maxi serait court. Si future_a_essayer se termine en premier, select va renvoyer Left avec la sortie de future_a_essayer. Si expiration_delai_attente finit en premier, alors select va renvoyer Right avec la sortie de la minuterie de ().

Si future_a_essayer réussit et que nous obtenons Left(sortie), nous renvoyons Ok(sortie). Si, au contraire, le délai d’expiration est atteint et que nous obtenons un Right(()), nous ignorons le () et renvoyons Err(delai_maxi) à la place.

Avec tout ça, nous avons un expiration_delai_attente opérationnel, construit à partir de deux autres fonctions d’aide asynchrones. Si nous exécutons notre code, il affichera le message d’erreur après expiration du délai.

Échec après 2 secondes

Comme les futures s’assemblent avec d’autres futures, vous pouvez créer des outils très puissants à partir de petites briques asynchrones. Par exemple, vous pouvez utiliser cette même approche pour combiner des délais d’expiration avec des tentatives de réessai, puis les utiliser à leur tour dans des opérations telles que des appels réseau (comme ceux de l’encart 17-5).

En pratique, vous travaillerez généralement avec async et await, puis accessoirement avec des fonctions telles que select et des macros comme join! pour contrôler la manière dont les futures les plus externes sont exécutées.

Nous avons vu jusqu’ici plusieurs manières de travailler avec plusieurs futures en même temps. Nous allons maintenant voir comment nous pouvons travailler avec plusieurs futures de manière séquentielle dans le temps à l’aide des flux (NDT : streams).