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