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

Utiliser les tâches pour exécuter simultanément du code

Dans la plupart des systèmes d’exploitation actuels, le code d’un programme est exécuté dans un processus, et le système d’exploitation gère plusieurs processus à la fois. Dans votre programme, vous pouvez vous aussi avoir des parties indépendantes qui s’exécutent simultanément. Les éléments qui font fonctionner ces parties indépendantes sont appelés les tâches. Par exemple, un serveur web peut disposer de plusieurs tâches afin de pouvoir répondre à plus d’une requête à la fois.

Le découpage des calculs de votre programme dans plusieurs tâches pour faire plusieurs choses à la fois peut améliorer sa performance, mais cela rajoute aussi de la complexité. Comme les tâches peuvent s’exécuter de manière simultanée, il n’y a pas de garantie absolue sur l’ordre d’exécution des différentes parties de votre code. Cela peut poser des problèmes, tels que :

  • les situations de concurrence, durant lesquelles les tâches accèdent à des données ou des ressources dans un ordre incohérent ;
  • des interblocages, durant lesquels deux tâches attendent mutuellement que l’autre finisse d’utiliser une ressource que l’autre tâche utilise, bloquant la progression des deux tâches ;
  • des bogues qui surgissent uniquement dans certaines situations et qui sont difficiles à reproduire et corriger durablement.

Rust cherche à atténuer les effets indésirables de l’utilisation des tâches, mais le développement dans un contexte multitâches exige toujours une attention particulière et nécessite une structure de code différente de celle des programmes qui s’exécutent dans une seule tâche.

Les langages de programmation implémentent les tâches de différentes manières, et de nombreux systèmes d’exploitation offrent une API que le langage de programmation peut appeler pour créer de nouvelles tâches. La bibliothèque standard de Rust utilise un modèle d’implémentation des tâches de type 1:1, dans lequel un programme utilise une tâche du système d’exploitation par tâche dans le langage de programmation. Il existe des crates qui implémentent d’autres modèles qui font des choix différents (le système asynchrone de Rust, que nous aborderons dans le chapitre suivant, fournit une autre approche de la concurrence).

Créer une nouvelle tâche avec spawn

Pour créer une nouvelle tâche, nous appelons la fonction thread::spawn et nous lui passons une fermeture (nous avons vu les fermetures au chapitre 13) qui contient le code que nous souhaitons exécuter dans la nouvelle tâche. L’exemple dans l’encart 16-1 affiche du texte à partir de la tâche principale et un autre texte à partir d’une nouvelle tâche.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("Bonjour n°{} à partir de la nouvelle tâche !", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Bonjour n°{} à partir de la tâche principale !", i);
        thread::sleep(Duration::from_millis(1));
    }
}
Listing 16-1: Creating a new thread to print one thing while the main thread prints something else

Remarquez que lorsque la tâche principale d’un programme Rust s’arrête, toutes les tâches créées sont arrêtées, qu’elles aient terminé leur exécution ou pas. La sortie de ce programme peut être différente à chaque fois, mais elle devrait ressembler à ceci :

Bonjour n°1 à partir de la tâche principale !
Bonjour n°1 à partir de la nouvelle tâche !
Bonjour n°2 à partir de la tâche principale !
Bonjour n°2 à partir de la nouvelle tâche !
Bonjour n°3 à partir de la tâche principale !
Bonjour n°3 à partir de la nouvelle tâche !
Bonjour n°4 à partir de la tâche principale !
Bonjour n°4 à partir de la nouvelle tâche !
Bonjour n°5 à partir de la nouvelle tâche !

Les appels à thread::sleep forcent une tâche à mettre en pause son exécution pendant une petite durée, permettant à une autre tâche de s’exécuter. Les tâches se relaieront probablement, mais ce n’est pas garanti : cela dépend de comment votre système d’exploitation agence les tâches. Lors de cette exécution, la tâche principale a écrit en premier, même si l’instruction d’écriture de la nouvelle tâche apparaissait d’abord dans le code. Et même si nous avons demandé à la nouvelle tâche d’écrire jusqu’à ce que i vaille 9, elle ne l’a fait que jusqu’à 5, moment où la tâche principale s’est arrêtée.

Si vous exécutez ce code et que vous ne voyez que du texte provenant de la tâche principale, ou que vous ne voyez aucun chevauchement, essayez d’augmenter les nombres dans les intervalles pour donner plus d’opportunités au système d’exploitation pour basculer entre les tâches.

Attendre que toutes les tâches aient fini

Le code dans l’encart 16-1 non seulement stoppe la nouvelle tâche prématurément la plupart du temps à cause de la fin de la tâche principale, mais parce qu’il n’y a pas de garantie sur l’ordre dans lequel les tâches vont s’exécuter, nous ne garantissons pas non plus que la nouvelle tâche va s’exécuter ne serait-ce qu’une seule fois !

Nous pouvons régler le problème des nouvelles tâches qui ne s’exécutent pas, ou qui se terminent prématurément, en sauvegardant la valeur de retour de thread::spawn dans une variable. Le type de retour de thread::spawn est JoinHandle<T>. Un JoinHandle<T> est une valeur possédée qui, lorsque nous appelons la méthode join sur elle, va attendre que ses tâches finissent. L’encart 16-2 montre comment utiliser le JoinHandle<T> de la tâche que nous avons créée dans l’encart 16-1 et comment appeler la méthode join pour s’assurer que la nouvelle tâche finit bien avant que main ne se termine.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let manipulateur = thread::spawn(|| {
        for i in 1..10 {
            println!("Bonjour n°{} à partir de la nouvelle tâche !", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Bonjour n°{} à partir de la tâche principale !", i);
        thread::sleep(Duration::from_millis(1));
    }

    manipulateur.join().unwrap();
}
Listing 16-2: Saving a JoinHandle<T> from thread::spawn to guarantee the thread is run to completion

L’appel à join sur le manipulateur bloque la tâche qui s’exécute actuellement jusqu’à ce que la tâche représentée par le manipulateur se termine. Bloquer une tâche signifie que cette tâche est empêchée d’accomplir un quelconque travail ou de se terminer. Comme nous avons inséré l’appel à join après la boucle for de la tâche principale, l’exécution de l’encart 16-2 devrait produire un résultat similaire à celui-ci :

Bonjour n°1 à partir de la tâche principale !
Bonjour n°2 à partir de la tâche principale !
Bonjour n°1 à partir de la nouvelle tâche !
Bonjour n°3 à partir de la tâche principale !
Bonjour n°2 à partir de la nouvelle tâche !
Bonjour n°4 à partir de la tâche principale !
Bonjour n°3 à partir de la nouvelle tâche !
Bonjour n°4 à partir de la nouvelle tâche !
Bonjour n°5 à partir de la nouvelle tâche !
Bonjour n°6 à partir de la nouvelle tâche !
Bonjour n°7 à partir de la nouvelle tâche !
Bonjour n°8 à partir de la nouvelle tâche !
Bonjour n°9 à partir de la nouvelle tâche !

Les deux tâches continuent à alterner, mais la tâche principale attend à cause de l’appel à manipulateur.join() et ne se termine pas avant que la nouvelle tâche ne soit finie.

Mais voyons maintenant ce qui se passe lorsque nous déplaçons le manipulateur.join() avant la boucle for du main comme ceci :

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let manipulateur = thread::spawn(|| {
        for i in 1..10 {
            println!("Bonjour n°{} à partir de la nouvelle tâche !", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    manipulateur.join().unwrap();

    for i in 1..5 {
        println!("Bonjour n°{} à partir de la tâche principale !", i);
        thread::sleep(Duration::from_millis(1));
    }
}

La tâche principale va attendre que la nouvelle tâche se finisse et ensuite exécuter sa boucle for, ainsi la sortie ne sera plus chevauchée, comme ci-dessous :

Bonjour n°1 à partir de la nouvelle tâche !
Bonjour n°2 à partir de la nouvelle tâche !
Bonjour n°3 à partir de la nouvelle tâche !
Bonjour n°4 à partir de la nouvelle tâche !
Bonjour n°5 à partir de la nouvelle tâche !
Bonjour n°6 à partir de la nouvelle tâche !
Bonjour n°7 à partir de la nouvelle tâche !
Bonjour n°8 à partir de la nouvelle tâche !
Bonjour n°9 à partir de la nouvelle tâche !
Bonjour n°1 à partir de la tâche principale !
Bonjour n°2 à partir de la tâche principale !
Bonjour n°3 à partir de la tâche principale !
Bonjour n°4 à partir de la tâche principale !

Des petits détails, comme l’endroit où join est appelé, peuvent déterminer si vos tâches peuvent être exécutées ou non en même temps.

Utiliser les fermetures move avec les tâches

Nous utiliserons souvent le mot-clé move avec des fermetures passées à thread::spawn car la fermeture va alors prendre possession des valeurs de son environnement qu’elle utilise, ce qui transfère la possession des valeurs d’une tâche à une autre. Dans “Capturing References or Moving Ownership” dans le chapitre 13, nous avons présenté move dans le contexte des fermetures. À présent, nous allons plus nous concentrer sur l’interaction entre move et thread::spawn.

Remarquez dans l’encart 16-1 que la fermeture que nous donnons à thread::spawn ne prend pas d’arguments : nous n’utilisons aucune donnée de la tâche principale dans le code de la nouvelle tâche. Pour utiliser des données de la tâche principale dans la nouvelle tâche, la fermeture de la nouvelle tâche doit capturer les valeurs dont elle a besoin. L’encart 16-3 montre une tentative de création d’un vecteur dans la tâche principale et son utilisation dans la nouvelle tâche. Cependant, cela ne fonctionne pas encore, comme vous allez le constater dans un moment.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let manipulateur = thread::spawn(|| {
        println!("Voici un vecteur : {v:?}");
    });

    manipulateur.join().unwrap();
}
Listing 16-3: Attempting to use a vector created by the main thread in another thread

La fermeture utilise v, donc elle va capturer v et l’intégrer dans son environnement. Comme thread::spawn exécute cette fermeture dans une nouvelle tâche, nous devrions pouvoir accéder à v dans cette nouvelle tâche. Mais lorsque nous compilons cet exemple, nous obtenons l’erreur suivante :

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let manipulateur = thread::spawn(|| {
  |                                      ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {:?}", v);
  |                                           - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let manipulateur = thread::spawn(|| {
  |  ________________________^
7 | |         println!("Voici un vecteur :{v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let manipulateur = thread::spawn(move || {
  |                                      ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust déduit comment capturer v, et comme println! n’a besoin que d’une référence à v, la fermeture essaye d’emprunter v. Cependant, il y a un problème : Rust ne peut pas savoir combien de temps la tâche va s’exécuter, donc il ne peut pas savoir si la référence à v sera toujours valide.

L’encart 16-4 propose un scénario qui a plus de chances d’avoir une référence à v qui ne sera plus valide.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let manipulateur = thread::spawn(|| {
        println!("Voici un vecteur : {v:?}");
    });

    drop(v); // oh, non !

    manipulateur.join().unwrap();
}
Listing 16-4: A thread with a closure that attempts to capture a reference to v from a main thread that drops v

Si Rust nous autorisait à exécuter ce code, il y aurait une possibilité que la nouvelle tâche soit immédiatement placée en arrière-plan sans être exécutée du tout. La nouvelle tâche a une référence à v en son sein, mais la tâche principale libère immédiatement v, en utilisant la fonction drop que nous avons vue au chapitre 15. Ensuite, lorsque la nouvelle tâche commence à s’exécuter, v n’est plus en vigueur, donc une référence à cette dernière est elle aussi invalide !

Pour corriger l’erreur de compilation de l’encart 16-3, nous pouvons appliquer le conseil du message d’erreur :

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let manipulateur = thread::spawn(move || {
  |                                      ++++

En ajoutant le mot-clé move avant la fermeture, nous forçons la fermeture à prendre possession des valeurs qu’elle utilise au lieu de laisser Rust déduire qu’il doit emprunter les valeurs. Les modifications à l’encart 16-3 proposées dans l’encart 16-5 devraient se compiler et s’exécuter comme prévu.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let manipulateur = thread::spawn(move || {
        println!("Voici un vecteur : {v:?}");
    });

    manipulateur.join().unwrap();
}
Listing 16-5: Using the move keyword to force a closure to take ownership of the values it uses

Nous pourrions être tentés de tenter la même chose pour rectifier le code de l’encart 16-4 dans lequel la tâche principale fait appel à drop en utilisant une fermeture move. Cependant, ceci ne fonctionnera pas car ce que l’encart 16-4 essaye de faire n’est pas autorisé pour une raison différente de la précédente. Si nous ajoutions move à la fermeture, nous déplacerions v dans l’environnement de la fermeture, et nous ne pourrions plus appeler drop sur v dans la tâche principale. Nous obtiendrons à la place cette erreur de compilation :

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
 4 |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
 5 |
 6 |     let manipulateur = thread::spawn(move || {
   |                                      ------- value moved into closure here
 7 |         println!("Voici un vecteur : {v:?}");
   |                                       - variable moved due to use in closure
...
10 |     drop(v); // oh, non !
   |          ^ value used here after move
   |
help: consider cloning the value before moving it into the closure
   |
 6 ~     let value = v.clone();
 7 ~     let manipulateur = thread::spawn(move || {
 8 ~         println!("Voici un vecteur : {value:?}");
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Les règles de possession de Rust nous ont encore sauvé la mise ! Nous obtenions une erreur avec le code de l’encart 16-3 car Rust a été conservateur et a juste emprunté v pour la tâche, ce qui signifie que la tâche principale pouvait théoriquement neutraliser la référence de la tâche créée. En demandant à Rust de déplacer la possession de v à la nouvelle tâche, nous avons garanti à Rust que la tâche principale n’utiliserait plus v. Si nous changeons l’encart 16-4 de la même manière, nous violons les règles de possession lorsque nous essayons d’utiliser v dans la tâche principale. Le mot-clé move remplace le comportement d’emprunt conservateur par défaut de Rust; il ne nous laisse pas enfreindre les règles de possession.

Maintenant que nous avons vu ce que sont les tâches et les méthodes fournies par leur API, découvrons quelques situations dans lesquelles nous pouvons utiliser les tâches.