TP 09 - Fils d'Execution

Objectifs

Le but de ce TP est d'apprendre à utiliser

  • Comment utiliser les fils
  • Comment utiliser les mutex et les canaux

Assignment

Vous devez accepter le assignment d'ici et travailler avec ce repository: Lab9

Fearless Concurrency

Gérer la programmation simultanée de manière sûre et efficace est un autre des principaux objectifs de Rust. La programmation simultanée, où différentes parties d'un programme s'exécutent indépendamment, et la programmation parallèle, où différentes parties d'un programme s'exécutent en même temps, deviennent de plus en plus importantes à mesure que de plus en plus d'ordinateurs tirent parti de leurs multiples processeurs. Historiquement, la programmation dans ces contextes a été difficile et sujette aux erreurs : Rust espère changer cela.

En tirant parti de la propriété et de la vérification de type, de nombreuses erreurs de concurrence sont des erreurs de compilation dans Rust plutôt que des erreurs d'exécution. Par conséquent, plutôt que de vous faire passer beaucoup de temps à essayer de reproduire les circonstances exactes dans lesquelles un bogue de concurrence d'exécution se produit, un code incorrect refusera de compiler et présentera une erreur expliquant le problème. Par conséquent, vous pouvez corriger votre code pendant que vous y travaillez plutôt qu'éventuellement après son envoi en production. Nous avons surnommé cet aspect de la concurrence intrépide de Rust. La concurrence intrépide vous permet d'écrire du code exempt de bogues subtils et facile à refactoriser sans introduire de nouveaux bogues.

Utilisation de threads pour exécuter du code simultanément

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 ajoute également de la complexité. Étant donné que les threads peuvent s'exécuter simultanément, il n'y a aucune garantie inhérente quant à l'ordre dans lequel les parties de votre code sur différents threads seront exécutées. Cela peut entraîner des problèmes, tels que :

  • Conditions de concurrence, où les threads accèdent aux données ou aux ressources dans un ordre incohérent
  • Deadlocks, où deux threads s'attendent, empêchant les deux threads de continuer
  • Bugs qui ne se produisent 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 de threads, mais la programmation dans un contexte multithread nécessite toujours une réflexion approfondie et nécessite une structure de code différente de celle des programmes exécutés dans un seul thread.

spawn

Pour créer un nouveau thread, nous appelons la fonction thread::spawn et lui passons une closure contenant le code que nous voulons exécuter dans le nouveau thread.

use std::thread;
use std::time::Duration;
 
fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
 
    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

Notez que lorsque le thread principal d'un programme Rust se termine, tous les threads générés sont arrêtés, qu'ils aient ou non fini de s'exécuter. La sortie de ce programme peut être un peu différente à chaque fois, mais elle ressemblera à ce qui suit :

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 thread différent de s'exécuter. Les threads prendront probablement leur tour, 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 s'imprime en premier, même si l'instruction d'impression du thread généré apparaît en premier dans le code. Et même si nous avons dit au thread généré d'imprimer jusqu'à ce que i soit 9, il n'est arrivé qu'à 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.

join handle

Le code precedent arrête non seulement le thread généré prématurément la plupart du temps en raison de la fin du thread principal, mais comme il n'y a aucune garantie sur l'ordre dans lequel les threads s'exécutent, nous ne pouvons pas non plus garantir que le thread généré sera courir du tout!

Nous pouvons résoudre le problème du thread généré qui ne s'exécute pas ou se termine prématurément en enregistrant 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 dessus, attendra que son thread se termine. Le Lcode suivant montre comment utiliser le JoinHandle du thread que nous avons créé dans le code precedent et appeler join pour s'assurer que le thread généré se termine avant les sorties principales :

use std::thread;
use std::time::Duration;
 
fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
 
    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
 
    handle.join().unwrap();
}

L'appel de join sur le handle bloque le thread en cours d'exécution jusqu'à ce que le thread représenté par le handle se termine. Le blocage d'un thread signifie que le thread est empêché d'effectuer un travail ou de se fermer. Comme nous avons placé l'appel à join après la boucle for du thread principal, l'exécution devrait produire une sortie similaire à celle-ci :

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 termine pas tant que le thread généré n'est pas terminé.

Mais voyons ce qui se passe quand nous déplaçons plutôt 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 {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
 
    handle.join().unwrap();
 
    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

Le thread principal attendra que le thread généré se termine, puis exécutera sa boucle for, de sorte que la sortie ne sera plus entrelacée, comme indiqué 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!

Message sending concurrency

Pour réaliser la simultanéité d'envoi de messages, la bibliothèque standard de Rust fournit une implémentation de channels. Un canal est un concept de programmation général par lequel les données sont envoyées d'un thread à un autre.

Vous pouvez imaginer un canal dans la programmation comme étant comme un canal directionnel d'eau, tel qu'un ruisseau ou une rivière. Si vous mettez quelque chose comme un canard en caoutchouc dans une rivière, il se déplacera en aval jusqu'au bout de la voie navigable.

Un canal a deux moitiés : un émetteur et un récepteur. La moitié de l'émetteur est l'endroit en amont où vous mettez les canards en caoutchouc dans la rivière, et la moitié du récepteur est l'endroit où le canard en caoutchouc se termine en aval. Une partie de votre code appelle des méthodes sur l'émetteur avec les données que vous souhaitez envoyer, et une autre partie vérifie l'extrémité de réception pour les messages arrivant. Un canal est dit fermé si l'émetteur ou la moitié récepteur est abandonné.

Example de channel:

use std::sync::mpsc;
 
fn main() {
    let (tx, rx) = mpsc::channel();
}

Nous créons un nouveau canal en utilisant la fonction mpsc::channel ; mpsc signifie multiple producer, single consumer. En bref, la façon dont la bibliothèque standard de Rust implémente les canaux signifie qu'un canal peut avoir plusieurs extrémités émettrices qui produisent des valeurs, mais une seule extrémité réceptrice qui consomme ces valeurs. Imaginez plusieurs cours d'eau coulant ensemble dans une grande rivière : tout ce qui est envoyé dans l'un des cours d'eau se retrouvera dans une rivière à la fin. Nous allons commencer avec un seul producteur pour le moment, mais nous ajouterons plusieurs producteurs lorsque cet exemple fonctionnera.

La fonction mpsc::channel renvoie un tuple dont le premier élément est l'extrémité émettrice - l'émetteur - et le second élément est l'extrémité réceptrice - le récepteur. Les abréviations tx et rx sont traditionnellement utilisées dans de nombreux domaines pour l'émetteur et le récepteur respectivement, nous nommons donc nos variables en tant que telles pour indiquer chaque extrémité. Nous utilisons une instruction let avec un motif qui déstructure les tuples ; nous discuterons de l'utilisation des motifs dans les instructions let et de la déstructuration au chapitre 18. Pour l'instant, sachez que l'utilisation d'une instruction let de cette manière est une approche pratique pour extraire les morceaux du tuple renvoyé par mpsc::channel.

use std::sync::mpsc;
use std::thread;
 
fn main() {
    let (tx, rx) = mpsc::channel();
 
    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

Encore une fois, nous utilisons thread::spawn pour créer un nouveau thread, puis nous utilisons move pour déplacer tx dans la fermeture afin que le thread engendré possède tx. Le thread généré doit posséder l'émetteur pour pouvoir envoyer des messages via le canal. L'émetteur a une méthode d'envoi qui prend la valeur que nous voulons envoyer. La méthode d'envoi renvoie un type Result<T, E>, donc si le récepteur a déjà été supprimé et qu'il n'y a nulle part où envoyer une valeur, l'opération d'envoi renverra une erreur. Dans cet exemple, nous appelons unwrap pour paniquer en cas d'erreur.

use std::sync::mpsc;
use std::thread;
 
fn main() {
    let (tx, rx) = mpsc::channel();
 
    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
 
    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

Le receveur a deux méthodes utiles : recv et try_recv. Nous utilisons recv, abréviation de receive, qui bloquera l'exécution du thread principal et attendra qu'une valeur soit envoyée sur le canal. Une fois qu'une valeur est envoyée, recv la renverra dans un Result<T, E>. Lorsque l'émetteur se ferme, recv renverra une erreur pour signaler qu'aucune autre valeur n'arrivera.

Nous avons utilisé recv dans cet exemple pour plus de simplicité ; nous n'avons pas d'autre travail à faire pour le thread principal que d'attendre les messages, donc bloquer le thread principal est approprié.

Vous pouvez découvrir comment envoyer plusieurs valeurs au récepteur ici: multiple values

Aussi utile: Creating Multiple Producers by Cloning the Transmitter

Shared-State Concurrency

À quoi ressemblerait la communication en partageant la mémoire ? De plus, pourquoi les passionnés de transmission de messages avertiraient-ils de ne pas utiliser le partage de mémoire ?

D'une certaine manière, les canaux dans n'importe quel langage de programmation sont similaires à la propriété unique, car une fois que vous transférez une valeur sur un canal, vous ne devez plus utiliser cette valeur. La simultanéité de la mémoire partagée est comme la propriété multiple : plusieurs threads peuvent accéder au même emplacement mémoire en même temps.

mutex

Mutex est une abréviation d'exclusion mutuelle, car un mutex permet à un seul thread d'accéder à certaines données à un moment donné. Pour accéder aux données d'un mutex, un thread doit d'abord signaler qu'il veut y accéder en demandant d'acquérir le verrou du mutex. Le verrou est une structure de données qui fait partie du mutex qui garde une trace de qui a actuellement un accès exclusif aux données. Par conséquent, le mutex est décrit comme protégeant les données qu'il contient via le système de verrouillage.

Les mutex ont la réputation d'être difficiles à utiliser car il faut retenir deux règles :

  • Vous devez essayer d'acquérir le verrou avant d'utiliser les données.
  • Lorsque vous avez terminé avec les données que le mutex garde, vous devez déverrouiller les données afin que d'autres threads puissent acquérir le verrou.

Example d'utilisation:

use std::sync::Mutex;
 
fn main() {
    let m = Mutex::new(5);
 
    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }
 
    println!("m = {:?}", m);
}

Comme pour de nombreux types, nous créons un Mutex<T> en utilisant la fonction new associée. Pour accéder aux données à l'intérieur du mutex, nous utilisons la méthode de lock pour acquérir le verrou. Cet appel bloquera le thread en cours afin qu'il ne puisse effectuer aucun travail jusqu'à ce que ce soit notre tour d'avoir le verrou.

L'appel à lock échouerait si un autre thread détenant le verrou paniquait. Dans ce cas, personne ne pourrait jamais obtenir le verrou, nous avons donc choisi de déballer et de faire paniquer ce fil si nous sommes dans cette situation.

Après avoir acquis le verrou, nous pouvons traiter la valeur de retour, nommée num dans ce cas, comme une référence mutable aux données à l'intérieur. Le système de type garantit que nous acquérons un verrou avant d'utiliser la valeur en m. Le type de m est Mutex<i32>, pas i32, nous devons donc appeler lock pour pouvoir utiliser la valeur i32. Nous ne pouvons pas oublier; sinon, le système de type ne nous laissera pas accéder à l'i32 interne.

Comptage de référence atomique avec Arc<T>

Arc<T> est un type comme Rc<T> qui peut être utilisé en toute sécurité dans des situations simultanées. Le a signifie atomique, ce qui signifie qu'il s'agit d'un type à référence atomique. Les atomiques sont un type supplémentaire de primitive de concurrence que nous ne couvrirons pas en détail ici : consultez la documentation de la bibliothèque standard pour std::sync::atomic pour plus de détails. À ce stade, il vous suffit de savoir que les atomes fonctionnent comme des types primitifs, mais qu'ils peuvent être partagés en toute sécurité entre les threads.

Vous pourriez alors vous demander pourquoi tous les types primitifs ne sont pas atomiques et pourquoi les types de bibliothèque standard ne sont pas implémentés pour utiliser Arc<T> par défaut. La raison en est que la sécurité des threads s'accompagne d'une pénalité de performance que vous ne voulez payer que lorsque vous en avez vraiment besoin. Si vous effectuez simplement des opérations sur des valeurs dans un seul thread, votre code peut s'exécuter plus rapidement s'il n'a pas à appliquer les garanties fournies par atomics.

use std::sync::{Arc, Mutex};
use std::thread;
 
fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
 
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
 
            *num += 1;
        });
        handles.push(handle);
    }
 
    for handle in handles {
        handle.join().unwrap();
    }
 
    println!("Result: {}", *counter.lock().unwrap());
}

Plus des details ici: Shared-State Concurrency

Bibliographie

Sujets

  1. Exécutez le programme dans ex1. Répondez aux questions posées par le programme.
  2. Résolvez les lignes TODO (dans l'ordre) à partir de ex1.
  3. Résolvez les lignes TODO (dans l'ordre) de ex2.
  4. Résolvez les lignes TODO (dans l'ordre) de ex3.
  5. Résolvez les lignes TODO (dans l'ordre) de ex4.
sde2/laboratoare/09_rust.txt · Last modified: 2023/05/08 23:45 by cristiana.andrei
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0