Utiliser les tâches pour exécuter du code simultanément
Dans la plupart des systèmes d’exploitation actuels, le code d’un programme exécuté s’exécute dans un processus, et le système d’exploitation gère plusieurs processus en même temps. Au sein d’un programme, vous pouvez aussi avoir des parties indépendantes qui s’exécutent simultanément. Les fonctionnalités qui exécutent ces parties indépendantes sont appelées threads. Par exemple, un serveur web pourrait avoir plusieurs threads pour pouvoir répondre à plus d’une requête en même temps.
Diviser le calcul de votre programme en plusieurs threads pour exécuter plusieurs tâches en même temps peut améliorer les performances, mais cela ajouté aussi de la complexité. Comme les threads peuvent s’exécuter simultanément, il n’y à aucune garantie inhérente sur l’ordre dans lequel les parties de votre code sur différents threads s’exécuteront. Cela peut mener à des problèmes, tels que : - Les conditions de course (race conditions), dans lesquelles les threads accèdent à des données ou des ressources dans un ordre incohérent - Les interblocages (deadlocks), dans lesquels deux threads s’attendent mutuellement, empêchant les deux threads de continuer - Les bogues qui ne se produisent que dans certaines situations et sont difficiles à reproduire et à corriger de manière fiable
- Les conditions de course, où les threads accèdent à des données ou des ressources dans un ordre incohérent
- Les interblocages, où deux threads s’attendent mutuellement, empêchant les deux threads de continuer
- Les bogues qui ne surviennent que dans certaines situations et sont difficiles à reproduire et à corriger de manière fiable
Rust tente d’atténuer les effets négatifs de l’utilisation des threads, mais programmer dans un contexte multi-thread nécessite toujours une réflexion soigneuse et une structure de code différente de celle des programmes s’exécutant dans un seul thread.
Les langages de programmation implémentent les threads de différentes manières, et de nombreux systèmes d’exploitation fournissent une API que le langage de programmation peut appeler pour créer de nouveaux threads. La bibliothèque standard de Rust utilise un modèle d’implémentation de threads 1:1, dans lequel un programme utilise un thread du système d’exploitation par thread du langage. Il existe des crates qui implémentent d’autres modèles de threading avec des compromis différents du modèle 1:1. (Le système async de Rust, que nous verrons dans le prochain chapitre, fournit aussi une autre approche de la concurrence.)
Créer un nouveau thread avec spawn
Pour créer un nouveau thread, nous appelons la fonction thread::spawn et lui passons une closure (nous avons parlé des closures au chapitre 13) contenant le code que nous voulons exécuter dans le nouveau thread. L’exemple de l’encart 16-1 affiche du texte depuis le thread principal et d’autre texte depuis un nouveau thread.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
Notez que lorsque le thread principal d’un programme Rust se terminé, tous les threads créés sont arrêtés, qu’ils aient fini ou non de s’exécuter. La sortie de ce programme peut être légèrement différente à chaque fois, mais elle ressemblera à ceci :
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
Les appels à thread::sleep forcent un thread à arrêter son exécution pendant une courte durée, permettant à un autre thread de s’exécuter. Les threads alterneront probablement, mais ce n’est pas garanti : cela dépend de la façon dont votre système d’exploitation planifie les threads. Dans cette exécution, le thread principal a affiché en premier, même si l’instruction d’affichage du thread créé apparaît en premier dans le code. Et même si nous avons demandé au thread créé d’afficher jusqu’à ce que i soit 9, il n’a atteint que 5 avant que le thread principal ne s’arrête.
Si vous exécutez ce code et ne voyez que la sortie du thread principal, ou ne voyez aucun chevauchement, essayez d’augmenter les nombres dans les plages pour créer plus d’opportunités pour le système d’exploitation de basculer entre les threads.
Attendre que tous les threads aient fini
Le code de l’encart 16-1 non seulement arrête prématurément le thread créé la plupart du temps en raison de la fin du thread principal, mais comme il n’y à aucune garantie sur l’ordre dans lequel les threads s’exécutent, nous ne pouvons pas non plus garantir que le thread créé s’exécutera du tout !
Nous pouvons corriger le problème du thread créé qui ne s’exécute pas ou qui se terminé 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, quand nous appelons la méthode join dessus, attendra que son thread finisse. L’encart 16-2 montre comment utiliser le JoinHandle<T> du thread que nous avons créé dans l’encart 16-1 et comment appeler join pour s’assurer que le thread créé finisse avant que main ne se terminé.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
JoinHandle<T> from thread::spawn to guarantee the thread is run to completionAppeler join sur le handle bloque le thread actuellement en cours d’exécution jusqu’à ce que le thread représenté par le handle se terminé. Bloquer un thread signifie que ce thread est empêché de travailler ou de se terminer. Comme nous avons placé l’appel à join après la boucle for du thread principal, l’exécution de l’encart 16-2 devrait produire une sortie similaire à ceci :
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
Les deux threads continuent d’alterner, mais le thread principal attend à cause de l’appel à handle.join() et ne se terminé pas tant que le thread créé n’a pas fini.
Mais voyons ce qui se passe quand nous déplaçons handle.join() avant la boucle for dans main, comme ceci :
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
}
Le thread principal attendra que le thread créé finisse puis exécutera sa boucle for, donc la sortie ne sera plus entrelacée, comme montré ici :
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
De petits détails, comme l’endroit où join est appelé, peuvent affecter le fait que vos threads s’exécutent ou non en même temps.
Utiliser les closures move avec les threads
Nous utiliserons souvent le mot-clé move avec les closures passées à thread::spawn car la closure prendra alors la possession des valeurs qu’elle utilise de l’environnement, transférant ainsi la possession de ces valeurs d’un thread à un autre. Dans [“Capturer des références ou transférer la possession”][capture] au chapitre 13, nous avons discuté de move dans le contexte des closures. Maintenant, nous allons nous concentrer davantage sur l’interaction entre move et thread::spawn.
Remarquez dans l’encart 16-1 que la closure que nous passons à thread::spawn ne prend aucun argument : nous n’utilisons aucune donnée du thread principal dans le code du thread créé. Pour utiliser des données du thread principal dans le thread créé, la closure du thread créé doit capturer les valeurs dont elle a besoin. L’encart 16-3 montre une tentative de créer un vecteur dans le thread principal et de l’utiliser dans le thread créé. Cependant, cela ne fonctionnera pas encore, comme vous le verrez dans un instant.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
La closure utilise v, donc elle va capturer v et en faire partie de l’environnement de la closure. Comme thread::spawn exécute cette closure dans un nouveau thread, nous devrions pouvoir accéder à v dans ce nouveau thread. Mais quand nous compilons cet exemple, nous obtenons l’erreur suivante : console {{#include ../listings/ch16-fearless-concurrency/listing-16-03/output.txt}}
$ 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 handle = 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 handle = 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` (bin "threads") due to 1 previous error
Rust infère comment capturer v, et comme println! n’a besoin que d’une référence vers v, la closure essaie d’emprunter v. Cependant, il y à un problème : Rust ne peut pas déterminer combien de temps le thread créé s’exécutera, donc il ne sait pas si la référence vers v sera toujours valide.
L’encart 16-4 fournit un scénario qui a plus de chances d’avoir une référence vers v qui ne sera pas valide.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {v:?}");
});
drop(v); // oh no!
handle.join().unwrap();
}
v from a main thread that drops vSi Rust nous permettait d’exécuter ce code, il y aurait une possibilité que le thread créé soit immédiatement mis en arrière-plan sans s’exécuter du tout. Le thread créé contient une référence vers v, mais le thread principal libère immédiatement v, en utilisant la fonction drop dont nous avons discuté au chapitre 15. Ensuite, quand le thread créé commence à s’exécuter, v n’est plus valide, donc une référence vers lui est également invalide. Oh non !
Pour corriger l’erreur de compilation de l’encart 16-3, nous pouvons suivre 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 handle = thread::spawn(move || {
| ++++
En ajoutant le mot-clé move avant la closure, nous forçons la closure à prendre la possession des valeurs qu’elle utilise plutôt que de permettre à Rust d’inférer qu’elle devrait emprunter les valeurs. La modification de l’encart 16-3 montrée dans l’encart 16-5 compilera et s’exécutera comme nous le souhaitons.
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {v:?}");
});
handle.join().unwrap();
}
move keyword to force a closure to take ownership of the values it usesNous pourrions être tentés d’essayer la même chose pour corriger le code de l’encart 16-4 où le thread principal appelait drop en utilisant une closure move. Cependant, cette correction ne fonctionnera pas car ce que l’encart 16-4 essaie de faire est interdit pour une raison différente. Si nous ajoutions move à la closure, nous déplacerions v dans l’environnement de la closure, et nous ne pourrions plus appeler drop dessus dans le thread principal. Nous obtiendrions plutôt cette erreur de compilation : console {{#include ../listings/ch16-fearless-concurrency/output-only-01-move-drop/output.txt}}
$ 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 handle = thread::spawn(move || {
| ------- value moved into closure here
7 | println!("Here's a vector: {v:?}");
| - variable moved due to use in closure
...
10 | drop(v); // oh no!
| ^ value used here after move
|
help: consider cloning the value before moving it into the closure
|
6 ~ let value = v.clone();
7 ~ let handle = thread::spawn(move || {
8 ~ println!("Here's a vector: {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 sauvés encore une fois ! Nous avons eu une erreur du code de l’encart 16-3 car Rust était conservateur et n’empruntait que v pour le thread, ce qui signifiait que le thread principal pouvait théoriquement invalider la référence du thread créé. En disant à Rust de déplacer la possession de v vers le thread créé, nous garantissons à Rust que le thread principal n’utilisera plus v. Si nous modifions l’encart 16-4 de la même manière, nous violons alors les règles de possession quand nous essayons d’utiliser v dans le thread principal. Le mot-clé move remplace le comportement conservateur par défaut de Rust qui est d’emprunter ; il ne nous permet pas de violer les règles de possession.
Maintenant que nous avons couvert ce que sont les threads et les méthodes fournies par l’API des threads, examinons quelques situations dans lesquelles nous pouvons utiliser les threads.