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.

Le découpage des calculs de votre programme dans plusieurs tâches peut améliorer sa performance car le programme fait plusieurs choses à la fois, 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. De nombreux systèmes d'exploitation offrent des API pour créer de nouvelles tâches. L'appel à cette API du système d'exploitation pour créer des tâches par un langage est parfois qualifié de 1:1, ce qui signifie une tâche du système d'exploitation par tâche dans le langage de programmation. La bibliothèque standard de Rust fournit une seule implémentation 1:1 ; il existe des crates qui implémentent d'autres modèles qui font des choix différents.

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 :

Fichier : 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));
    }
}

Encart 16-1 : création d'une nouvelle tâche pour afficher une chose pendant que la tâche principale affiche autre chose

Remarquez qu'avec cette fonction, la nouvelle tâche s'arrêtera lorsque la tâche principale s'arrêtera, qu'elle ait fini ou non de s'exécuter. 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 !

L'appel à thread::sleep force 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 en utilisant join

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 il ne garantit pas non plus que la nouvelle tâche va s'exécuter ne serait-ce qu'une seule fois. La raison à cela est qu'il n'y a pas de garantie sur l'ordre dans lequel les tâches vont s'exécuter !

Nous pouvons régler le problème des nouvelles tâches qui ne s'exécutent pas, ou pas complètement, en sauvegardant la valeur de retour de thread::spawn dans une variable. Le type de retour de thread::spawn est JoinHandle. Un JoinHandle 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 de la tâche que nous avons créée dans l'encart 16-1 en appelant la méthode join pour s'assurer que la nouvelle tâche finit bien avant que main ne se termine :

Fichier : 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();
}

Encart 16-2 : sauvegarde d'un JoinHandle d'un thread::spawn pour garantir que la tâche est exécutée jusqu'à la fin

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 :

Fichier : 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

Le mot-clé move est souvent utilisé 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 une section du chapitre 13, nous avons présenté move dans le contexte des fermetures. A 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.

Fichier : 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();
}

Encart 16-3 : tentative d'utilisation d'un vecteur créé par la tâche principale dans une autre tâche

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!("Here's a vector: {:?}", v);
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` due to 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 est a plus de chance d'avoir une référence à v qui ne sera plus valide :

Fichier : 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();
}

Encart 16-4 : une tâche dont la fermeture essaye de capturer une référence à v à partir de la tâche principale, qui va ensuite libérer v

Si nous étions autorisés à 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 :

Fichier : 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();
}

Encart 16-5 : utilisation du mot-clé move pour forcer une fermeture à prendre possession des valeurs qu'elle utilise

Qu'est-ce qui arriverait au code de l'encart 16-4 dans lequel la tâche principale fait appel à drop si nous utilisions la fermeture avec move ? Est-ce que le move résoudrait le problème ? Malheureusement, non ; nous obtiendrions une erreur différente parce que 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 `std::vec::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

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads`.

To learn more, run the command again with --verbose.
error[E0382]: use of moved value: `v`
  -- > src/main.rs:10:10
   |
6  |     let manipulateur = thread::spawn(move || {
   |                                      ------- value moved (into closure) here
...
10 |     drop(v); // oh non, le vecteur est libéré !
   |          ^ value used here after move
   |
   = note: move occurs because `v` has type `std::vec::Vec<i32>`, which does
   not implement the `Copy` trait

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.

Armé de cette connaissance de base des tâches et de leur API, découvrons ce que nous pouvons faire avec les tâches.