Concurrency d'état partagé en Rust

RustRustBeginner
Pratiquer maintenant

This tutorial is from open-source community. Access the source code

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Bienvenue dans Shared-State Concurrency. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous explorons le concept de concurrence en mémoire partagée et pourquoi les partisans de la communication par message s'y opposent.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/BasicConceptsGroup -.-> rust/mutable_variables("Mutable Variables") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") subgraph Lab Skills rust/variable_declarations -.-> lab-100439{{"Concurrency d'état partagé en Rust"}} rust/mutable_variables -.-> lab-100439{{"Concurrency d'état partagé en Rust"}} rust/for_loop -.-> lab-100439{{"Concurrency d'état partagé en Rust"}} rust/function_syntax -.-> lab-100439{{"Concurrency d'état partagé en Rust"}} rust/expressions_statements -.-> lab-100439{{"Concurrency d'état partagé en Rust"}} rust/method_syntax -.-> lab-100439{{"Concurrency d'état partagé en Rust"}} end

Shared-State Concurrency

La communication par message est un bon moyen de gérer la concurrence, mais ce n'est pas le seul. Une autre méthode consisterait à ce que plusieurs threads accèdent aux mêmes données partagées. Considérez encore cette partie de l'affiche de la documentation du langage Go : "Ne communiquez pas en partageant la mémoire."

Quel serait le fait de communiquer en partageant la mémoire? En outre, pourquoi les partisans de la communication par message recommandent-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 dans un canal, vous ne devriez plus utiliser cette valeur. La concurrence en 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. Comme vous l'avez vu au chapitre 15, où les pointeurs intelligents ont rendu la propriété multiple possible, la propriété multiple peut ajouter de la complexité car ces différents propriétaires ont besoin d'être gérés. Le système de types et les règles d'appartenance de Rust aident grandement à bien gérer cette opération. Pour un exemple, regardons les mutex, l'un des primitives de concurrence les plus courantes pour la mémoire partagée.

Utilisation de Mutex pour autoriser l'accès aux données par un seul thread à la fois

Mutex est une abréviation de mutual exclusion, car un mutex ne permet qu'un seul thread d'accéder à certaines données à un moment donné. Pour accéder aux données d'un mutex, un thread doit tout d'abord signaler qu'il souhaite accéder en demandant à acquérir le verrou du mutex. Le verrou est une structure de données qui fait partie du mutex et qui suit qui a actuellement un accès exclusif aux données. Par conséquent, le mutex est décrit comme gardant les données qu'il détient via le système de verrouillage.

Les mutex ont une réputation de difficulté d'utilisation car vous devez vous souvenir de deux règles :

  1. Vous devez tenter d'acquérir le verrou avant d'utiliser les données.
  2. Lorsque vous avez fini avec les données protégées par le mutex, vous devez déverrouiller les données pour que d'autres threads puissent acquérir le verrou.

Pour une métaphore du monde réel d'un mutex, imaginez une discussion de panel lors d'une conférence avec un seul micro. Avant qu'un intervenant puisse parler, il doit demander ou signaler qu'il souhaite utiliser le micro. Lorsqu'il obtient le micro, il peut parler aussi longtemps qu'il le souhaite puis remettre le micro au prochain intervenant qui demande à parler. Si un intervenant oublie de remettre le micro lorsqu'il a fini, personne d'autre ne peut parler. Si la gestion du micro partagé se passe mal, le panel ne fonctionnera pas comme prévu!

La gestion des mutex peut être incroyablement difficile à faire correctement, c'est pourquoi tant de gens sont enthousiastes pour les canaux. Cependant, grâce au système de types et aux règles d'appartenance de Rust, vous ne pouvez pas faire de fautes dans le verrouillage et le déverrouillage.

L'API de Mutex<T>{=html}

Pour illustrer comment utiliser un mutex, commençons par l'utiliser dans un contexte mono-fil, comme montré dans la liste 16-12.

Nom de fichier : src/main.rs

use std::sync::Mutex;

fn main() {
  1 let m = Mutex::new(5);

    {
      2 let mut num = m.lock().unwrap();
      3 *num = 6;
  4 }

  5 println!("m = {:?}", m);
}

Liste 16-12 : Exploration de l'API de Mutex<T> dans un contexte mono-fil pour plus de simplicité

Comme pour de nombreux types, nous créons un Mutex<T> en utilisant la fonction associée new [1]. Pour accéder aux données à l'intérieur du mutex, nous utilisons la méthode lock pour acquérir le verrou [2]. Cet appel bloquera le fil actuel, de sorte qu'il ne pourra pas effectuer de travail tant que ce n'est pas à notre tour d'avoir le verrou.

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

Une fois que nous avons acquis le verrou, nous pouvons traiter la valeur de retour, appelée num dans ce cas, comme une référence mutable aux données à l'intérieur. Le système de types garantit que nous acquérons un verrou avant d'utiliser la valeur dans m. Le type de m est Mutex<i32>, pas i32, donc nous devons appeler lock pour être en mesure d'utiliser la valeur i32. Nous ne pouvons pas oublier ; le système de types ne nous permettra pas d'accéder à l'i32 interne autrement.

Comme vous le soupçonnez peut-être, Mutex<T> est un pointeur intelligent. Plus précisément, l'appel à lock renvoie un pointeur intelligent appelé MutexGuard, encapsulé dans un LockResult que nous avons traité avec l'appel à unwrap. Le pointeur intelligent MutexGuard implémente Deref pour pointer vers nos données internes ; le pointeur intelligent a également une implémentation de Drop qui libère automatiquement le verrou lorsque le MutexGuard sort de portée, ce qui se produit à la fin de la portée interne [4]. En conséquence, nous ne courons pas le risque d'oublier de libérer le verrou et de bloquer l'utilisation du mutex par d'autres threads car la libération du verrou se produit automatiquement.

Après avoir libéré le verrou, nous pouvons afficher la valeur du mutex et constater que nous avons été en mesure de changer l'i32 interne en 6 [5].

Partage d'un Mutex<T>{=html} entre plusieurs threads

Maintenant, essayons de partager une valeur entre plusieurs threads en utilisant Mutex<T>. Nous allons démarrer 10 threads et leur demander chacun d'incrémenter d'un 1 une valeur de compteur, de sorte que le compteur passe de 0 à 10. L'exemple de la liste 16-13 entraînera une erreur de compilation, et nous utiliserons cette erreur pour en apprendre plus sur l'utilisation de Mutex<T> et sur la manière dont Rust nous aide à l'utiliser correctement.

Nom de fichier : src/main.rs

use std::sync::Mutex;
use std::thread;

fn main() {
  1 let counter = Mutex::new(0);
    let mut handles = vec![];

  2 for _ in 0..10 {
      3 let handle = thread::spawn(move || {
          4 let mut num = counter.lock().unwrap();

          5 *num += 1;
        });
      6 handles.push(handle);
    }

    for handle in handles {
      7 handle.join().unwrap();
    }

  8 println!("Result: {}", *counter.lock().unwrap());
}

Liste 16-13 : Dix threads, chacun incrémentant un compteur protégé par un Mutex<T>

Nous créons une variable counter pour stocker un i32 à l'intérieur d'un Mutex<T> [1], comme nous l'avons fait dans la liste 16-12. Ensuite, nous créons 10 threads en itérant sur une plage de nombres [2]. Nous utilisons thread::spawn et donnons à tous les threads la même fermeture : une fermeture qui déplace le compteur dans le thread [3], acquiert un verrou sur le Mutex<T> en appelant la méthode lock [4], puis ajoute 1 à la valeur dans le mutex [5]. Lorsqu'un thread a fini d'exécuter sa fermeture, num sortira de portée et libérera le verrou pour que le verrou puisse être acquis par un autre thread.

Dans le thread principal, nous collectons tous les pointeurs d'attente de join [6]. Ensuite, comme nous l'avons fait dans la liste 16-2, nous appelons join sur chaque pointeur pour nous assurer que tous les threads se terminent [7]. À ce moment-là, le thread principal acquerra le verrou et affichera le résultat de ce programme [8].

Nous avons laissé entendre que cet exemple ne compilerait pas. Maintenant, voyons pourquoi!

error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which
does not implement the `Copy` trait
...
9  |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here,
in previous iteration of loop
10 |             let mut num = counter.lock().unwrap();
   |                           ------- use occurs due to use in closure

Le message d'erreur indique que la valeur counter a été déplacée dans l'itération précédente de la boucle. Rust nous dit que nous ne pouvons pas déplacer la propriété du verrou counter dans plusieurs threads. Corrigeons l'erreur de compilation avec la méthode de propriété multiple que nous avons discutée au chapitre 15.

Propriété multiple avec plusieurs threads

Au chapitre 15, nous avons donné une valeur à plusieurs propriétaires en utilisant le pointeur intelligent Rc<T> pour créer une valeur comptée en référence. Faisons de même ici et voyons ce qui se passe. Nous emballerons le Mutex<T> dans Rc<T> dans la liste 16-14 et clonerons le Rc<T> avant de transférer la propriété au thread.

Nom de fichier : src/main.rs

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::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());
}

Liste 16-14 : Tentative d'utilisation de Rc<T> pour autoriser plusieurs threads à posséder le Mutex<T>

Encore une fois, nous compilons et obtenons... des erreurs différentes! Le compilateur nous apprend beaucoup de choses.

error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely 1
   --> src/main.rs:11:22
    |
11  |           let handle = thread::spawn(move || {
    |  ______________________^^^^^^^^^^^^^_-
    | |                      |
    | |                      `Rc<Mutex<i32>>` cannot be sent between threads
safely
12  | |             let mut num = counter.lock().unwrap();
13  | |
14  | |             *num += 1;
15  | |         });
    | |_________- within this `[closure@src/main.rs:11:36: 15:10]`
    |
= help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not
implemented for `Rc<Mutex<i32>>` 2
    = note: required because it appears within the type
`[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`

Wow, ce message d'erreur est très long! Voici la partie importante sur laquelle se concentrer : Rc<Mutex<i32>>` cannot be sent between threads safely` [1]. Le compilateur nous dit également pourquoi : `the trait `Send` is not implemented for `Rc<Mutex<i32>> [2]. Nous parlerons de Send dans la section suivante : c'est l'un des traits qui garantit que les types que nous utilisons avec les threads sont destinés à être utilisés dans des situations concurrentes.

Malheureusement, il n'est pas sécurisé de partager Rc<T> entre les threads. Lorsque Rc<T> gère le compteur de références, il incrémente le compte pour chaque appel à clone et décrémente le compte lorsque chaque clone est supprimé. Mais il n'utilise aucun primitif de concurrence pour s'assurer que les modifications du compte ne peuvent pas être interrompues par un autre thread. Cela pourrait entraîner des comptes erronés - des bogues subtils qui pourraient à leur tour entraîner des fuites mémoire ou une valeur qui serait supprimée avant que nous ayons fini avec elle. Ce dont nous avons besoin, c'est un type exactement comme Rc<T> mais qui modifie le compteur de références de manière sécurisée pour les threads.

Compte-rendu de référence atomique avec Arc<T>{=html}

Heureusement, Arc<T> est un type similaire à Rc<T> qui est sécurisé à utiliser dans des situations concurrentes. Le a signifie atomique, ce qui signifie qu'il s'agit d'un type compté en référence de manière atomique. Les atomes sont un autre type de primitif de concurrence que nous ne détaillerons pas ici : consultez la documentation de la bibliothèque standard pour std::sync::atomic pour plus de détails. À ce stade, vous n'avez qu'à savoir que les atomes fonctionnent comme les types primitifs mais sont sécurisés à partager entre les threads.

Vous vous demandez peut-être pourquoi tous les types primitifs ne sont pas atomiques et pourquoi les types de la bibliothèque standard ne sont pas implémentés pour utiliser Arc<T> par défaut. La raison est que la sécurité des threads entraîne une pénalité de performance que vous ne voulez payer que si vous avez vraiment besoin. Si vous effectuez seulement des opérations sur des valeurs dans un seul thread, votre code peut exécuter plus rapidement s'il n'a pas à appliquer les garanties que les atomes offrent.

Revenons à notre exemple : Arc<T> et Rc<T> ont la même API, donc nous corrigons notre programme en changeant la ligne use, l'appel à new et l'appel à clone. Le code de la liste 16-15 compilera enfin et s'exécutera.

Nom de fichier : src/main.rs

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

Liste 16-15 : Utilisation d'un Arc<T> pour emballer le Mutex<T> afin de pouvoir partager la propriété entre plusieurs threads

Ce code affichera ceci :

Result: 10

Nous y sommes arrivés! Nous avons compté de 0 à 10, ce qui peut ne pas sembler très impressionnant, mais cela nous a vraiment appris beaucoup sur Mutex<T> et la sécurité des threads. Vous pouvez également utiliser la structure de ce programme pour effectuer des opérations plus complexes que simplement incrémenter un compteur. En utilisant cette stratégie, vous pouvez diviser un calcul en parties indépendantes, répartir ces parties entre les threads, puis utiliser un Mutex<T> pour que chaque thread mette à jour le résultat final avec sa partie.

Notez que si vous effectuez des opérations numériques simples, il existe des types plus simples que les types Mutex<T> fournis par le module std::sync::atomic de la bibliothèque standard. Ces types offrent un accès sécurisé, concurrent et atomique aux types primitifs. Nous avons choisi d'utiliser Mutex<T> avec un type primitif pour cet exemple afin de pouvoir nous concentrer sur la manière dont Mutex<T> fonctionne.

Similitudes entre RefCell<T>{=html}/Rc<T>{=html} et Mutex<T>{=html}/Arc<T>{=html}

Vous avez peut-être remarqué que counter est immuable mais que nous pouvions obtenir une référence mutable à la valeur qu'elle contient ; cela signifie que Mutex<T> offre une mutabilité interne, comme la famille Cell. De la même manière que nous avons utilisé RefCell<T> au chapitre 15 pour pouvoir modifier le contenu à l'intérieur d'un Rc<T>, nous utilisons Mutex<T> pour modifier le contenu à l'intérieur d'un Arc<T>.

Un autre détail à noter est que Rust ne peut pas vous protéger contre tous les types d'erreurs logiques lorsque vous utilisez Mutex<T>. Rappelez-vous au chapitre 15 que l'utilisation de Rc<T> présentait le risque de créer des cycles de référence, où deux valeurs Rc<T> se réfèrent l'une à l'autre, entraînant des fuites mémoire. De manière similaire, Mutex<T> présente le risque de créer des verrouillages mortels. Ceux-ci se produisent lorsqu'une opération doit verrouiller deux ressources et que deux threads ont chacun acquis l'un des verrous, les rendant ainsi à l'attente l'un de l'autre à l'infini. Si vous êtes intéressé par les verrouillages mortels, essayez de créer un programme Rust qui présente un verrouillage mortel ; puis recherchez des stratégies de réduction des verrouillages pour les mutexes dans n'importe quelle langue et essayez de les implémenter en Rust. La documentation de l'API de la bibliothèque standard pour Mutex<T> et MutexGuard offre des informations utiles.

Nous terminerons ce chapitre en parlant des traits Send et Sync et de la manière dont nous pouvons les utiliser avec des types personnalisés.

Sommaire

Félicitations! Vous avez terminé le laboratoire de concurrence d'état partagé. Vous pouvez pratiquer d'autres laboratoires dans LabEx pour améliorer vos compétences.