Les cycles de référence peuvent fuitier la mémoire

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 Reference Cycles Can Leak Memory. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous explorons comment les garanties de sécurité mémoire de Rust rendent difficile mais pas impossible de créer accidentellement des fuites mémoire, en particulier lorsqu'on utilise Rc<T> et RefCell<T> qui peuvent entraîner des cycles de référence empêchant les valeurs d'être supprimées et donc fuitant la mémoire.

Reference Cycles Can Leak Memory

Les garanties de sécurité mémoire de Rust rendent difficile, mais pas impossible, de créer accidentellement de la mémoire qui n'est jamais nettoyée (connu sous le nom de fuite mémoire). Empêcher totalement les fuites mémoire n'est pas l'une des garanties de Rust, ce qui signifie que les fuites mémoire sont sécurisées en mémoire en Rust. Nous pouvons voir que Rust autorise les fuites mémoire en utilisant Rc<T> et RefCell<T> : il est possible de créer des références où les éléments se réfèrent les uns aux autres dans un cycle. Cela crée des fuites mémoire car le compteur de références de chaque élément dans le cycle ne pourra jamais atteindre 0, et les valeurs ne seront jamais supprimées.

Création d'un cycle de référence

Regardons comment un cycle de référence peut se produire et comment le prévenir, en commençant par la définition de l'énumération List et d'une méthode tail dans la liste 15-25.

Nom de fichier : src/main.rs

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
  1 Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
  2 fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

Liste 15-25 : Une définition de liste cons qui contient un RefCell<T> pour pouvoir modifier ce que le variant Cons pointe

Nous utilisons une autre variante de la définition de List de la liste 15-5. Le deuxième élément du variant Cons est désormais RefCell<Rc<List>> [1], ce qui signifie que, au lieu d'avoir la possibilité de modifier la valeur i32 comme nous l'avons fait dans la liste 15-24, nous voulons modifier la valeur de List vers laquelle un variant Cons pointe. Nous ajoutons également une méthode tail [2] pour faciliter l'accès au deuxième élément si nous avons un variant Cons.

Dans la liste 15-26, nous ajoutons une fonction main qui utilise les définitions de la liste 15-25. Ce code crée une liste dans a et une liste dans b qui pointe vers la liste dans a. Ensuite, il modifie la liste dans a pour qu'elle pointe vers b, créant ainsi un cycle de référence. Il y a des instructions println! tout au long pour montrer quels sont les comptages de références à différents points de ce processus.

Nom de fichier : src/main.rs

fn main() {
  1 let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

  2 let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!(
        "a rc count after b creation = {}",
        Rc::strong_count(&a)
    );
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

  3 if let Some(link) = a.tail() {
      4 *link.borrow_mut() = Rc::clone(&b);
    }

    println!(
        "b rc count after changing a = {}",
        Rc::strong_count(&b)
    );
    println!(
        "a rc count after changing a = {}",
        Rc::strong_count(&a)
    );

    // Désactivez la ligne suivante pour voir qu'il y a un cycle ;
    // cela entraînera un débordement de pile
    // println!("a next item = {:?}", a.tail());
}

Liste 15-26 : Création d'un cycle de référence entre deux valeurs List qui se pointent mutuellement

Nous créons une instance Rc<List> contenant une valeur List dans la variable a avec une liste initiale de 5, Nil [1]. Ensuite, nous créons une instance Rc<List> contenant une autre valeur List dans la variable b qui contient la valeur 10 et pointe vers la liste dans a [2].

Nous modifions a pour qu'elle pointe vers b au lieu de Nil, créant ainsi un cycle. Nous le faisons en utilisant la méthode tail pour obtenir une référence au RefCell<Rc<List>> dans a, que nous mettons dans la variable link [3]. Ensuite, nous utilisons la méthode borrow_mut sur le RefCell<Rc<List>> pour changer la valeur à l'intérieur d'un Rc<List> qui contient une valeur Nil en l'Rc<List> dans b [4].

Lorsque nous exécutons ce code, en laissant la dernière instruction println! commentée pour le moment, nous obtiendrons cette sortie :

a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

Le comptage de références des instances Rc<List> dans a et b est de 2 après avoir modifié la liste dans a pour qu'elle pointe vers b. À la fin de main, Rust supprime la variable b, ce qui diminue le comptage de références de l'instance Rc<List> de b de 2 à 1. La mémoire que Rc<List> a sur le tas ne sera pas supprimée à ce moment-là car son comptage de références est 1, pas 0. Ensuite, Rust supprime a, ce qui diminue également le comptage de références de l'instance Rc<List> de a de 2 à 1. La mémoire de cette instance ne peut pas non plus être supprimée, car l'autre instance Rc<List> la référence toujours. La mémoire allouée à la liste restera incollectée pour toujours. Pour visualiser ce cycle de référence, nous avons créé un diagramme dans la figure 15-4.

Figure 15-4 : Un cycle de référence entre les listes a et b qui se pointent mutuellement

Si vous activez la dernière instruction println! et exécutez le programme, Rust tentera d'afficher ce cycle avec a qui pointe vers b qui pointe vers a et ainsi de suite jusqu'à ce qu'il y ait un débordement de pile.

Comparé à un programme du monde réel, les conséquences de la création d'un cycle de référence dans cet exemple ne sont pas très graves : juste après avoir créé le cycle de référence, le programme se termine. Cependant, si un programme plus complexe allouait beaucoup de mémoire dans un cycle et la conservait longtemps, le programme utiliserait plus de mémoire qu'il n'en avait besoin et pourrait saturer le système, entraînant ainsi une insuffisance de mémoire disponible.

La création de cycles de référence n'est pas facile, mais ce n'est pas impossible non plus. Si vous avez des valeurs RefCell<T> qui contiennent des valeurs Rc<T> ou des combinaisons imbriquées similaires de types avec mutabilité interne et comptage de références, vous devez vous assurer de ne pas créer de cycles ; vous ne pouvez pas compter sur Rust pour les détecter. La création d'un cycle de référence serait une erreur logique dans votre programme que vous devriez minimiser en utilisant des tests automatisés, des révisions de code et d'autres pratiques de développement logiciel.

Une autre solution pour éviter les cycles de référence est de reorganiser vos structures de données de manière à ce que certaines références expriment la propriété et que certaines références n'en expriment pas. En conséquence, vous pouvez avoir des cycles composés de certaines relations de propriété et de certaines relations non de propriété, et seule la relation de propriété affecte la possibilité de supprimer une valeur. Dans la liste 15-25, nous voulons toujours que les variants Cons possèdent leur liste, donc il n'est pas possible de reorganiser la structure de données. Regardons un exemple utilisant des graphes composés de nœuds parents et de nœuds enfants pour voir dans quels cas les relations non de propriété sont un moyen approprié de prévenir les cycles de référence.

Prévention des cycles de référence en utilisant Weak<T>

Jusqu'à présent, nous avons démontré que l'appel de Rc::clone augmente le strong_count d'une instance Rc<T>, et qu'une instance Rc<T> n'est nettoyée que si son strong_count est égal à 0. Vous pouvez également créer une référence faible à la valeur contenue dans une instance Rc<T> en appelant Rc::downgrade et en passant une référence à l'Rc<T>. Les références fortes sont le moyen de partager la propriété d'une instance Rc<T>. Les références faibles n'expriment pas de relation de propriété, et leur comptage n'affecte pas le moment où une instance Rc<T> est nettoyée. Elles ne causeront pas de cycle de référence car tout cycle impliquant des références faibles sera rompu une fois que le comptage de références fortes des valeurs impliquées est égal à 0.

Lorsque vous appelez Rc::downgrade, vous obtenez un pointeur intelligent du type Weak<T>. Au lieu d'augmenter le strong_count dans l'instance Rc<T> de 1, l'appel de Rc::downgrade augmente le weak_count de 1. Le type Rc<T> utilise weak_count pour suivre combien de références Weak<T> existent, de manière similaire à strong_count. La différence est que le weak_count n'a pas besoin d'être égal à 0 pour que l'instance Rc<T> soit nettoyée.

Parce que la valeur que référence Weak<T> peut avoir été supprimée, pour faire quoi que ce soit avec la valeur vers laquelle pointe un Weak<T>, vous devez vous assurer que la valeur existe toujours. Faites-le en appelant la méthode upgrade sur une instance Weak<T>, qui retournera un Option<Rc<T>>. Vous obtiendrez un résultat Some si la valeur Rc<T> n'a pas encore été supprimée et un résultat None si la valeur Rc<T> a été supprimée. Parce que upgrade retourne un Option<Rc<T>>, Rust s'assurera que le cas Some et le cas None sont gérés, et qu'il n'y aura pas de pointeur invalide.

En tant qu'exemple, au lieu d'utiliser une liste dont les éléments ne connaissent que l'élément suivant, nous allons créer un arbre dont les éléments connaissent leurs éléments enfants et leurs éléments parents.

Création d'une structure de données d'arbre : Un nœud avec des nœuds enfants

Pour commencer, nous allons construire un arbre avec des nœuds qui connaissent leurs nœuds enfants. Nous allons créer une structure nommée Node qui contient sa propre valeur i32 ainsi que des références à ses valeurs Node enfants :

Nom de fichier : src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

Nous voulons qu'un Node possède ses enfants, et nous voulons partager cette propriété avec des variables pour pouvoir accéder directement à chaque Node de l'arbre. Pour ce faire, nous définissons les éléments de Vec<T> comme étant des valeurs du type Rc<Node>. Nous voulons également modifier les nœuds qui sont les enfants d'un autre nœud, donc nous avons un RefCell<T> dans children autour de Vec<Rc<Node>>.

Ensuite, nous utiliserons notre définition de structure et créerons une instance Node nommée leaf avec la valeur 3 et aucun enfant, et une autre instance nommée branch avec la valeur 5 et leaf comme l'un de ses enfants, comme montré dans la liste 15-27.

Nom de fichier : src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Liste 15-27 : Création d'un nœud leaf sans enfant et d'un nœud branch avec leaf comme l'un de ses enfants

Nous clonons l'Rc<Node> dans leaf et le stockons dans branch, ce qui signifie que le Node dans leaf a maintenant deux propriétaires : leaf et branch. Nous pouvons passer de branch à leaf via branch.children, mais il n'y a aucun moyen de passer de leaf à branch. La raison est que leaf n'a pas de référence à branch et ne sait pas qu'ils sont liés. Nous voulons que leaf sache que branch est son parent. Nous allons le faire ensuite.

Ajout d'une référence d'un enfant à son parent

Pour que le nœud enfant soit conscient de son parent, nous devons ajouter un champ parent à notre définition de la structure Node. Le problème est de décider quel devrait être le type de parent. Nous savons qu'il ne peut pas contenir un Rc<T> car cela créerait un cycle de référence avec leaf.parent qui pointerait vers branch et branch.children qui pointerait vers leaf, ce qui ferait en sorte que leurs valeurs strong_count ne soient jamais égales à 0.

En considérant les relations d'une autre manière, un nœud parent devrait posséder ses enfants : si un nœud parent est supprimé, ses nœuds enfants devraient également être supprimés. Cependant, un enfant ne devrait pas posséder son parent : si nous supprimons un nœud enfant, le parent devrait toujours exister. C'est un cas pour les références faibles!

Donc, au lieu de Rc<T>, nous ferons en sorte que le type de parent utilise Weak<T>, plus précisément un RefCell<Weak<Node>>. Maintenant, notre définition de la structure Node ressemble à ceci :

Nom de fichier : src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

Un nœud sera capable de se référer à son nœud parent mais ne possède pas son parent. Dans la liste 15-28, nous mettons à jour main pour utiliser cette nouvelle définition de sorte que le nœud leaf ait un moyen de se référer à son parent, branch.

Nom de fichier : src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
      1 parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

  2 println!(
        "leaf parent = {:?}",
        leaf.parent.borrow().upgrade()
    );

    let branch = Rc::new(Node {
        value: 5,
      3 parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

  4 *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

  5 println!(
        "leaf parent = {:?}",
        leaf.parent.borrow().upgrade()
    );
}

Liste 15-28 : Un nœud leaf avec une référence faible à son nœud parent, branch

La création du nœud leaf ressemble à la liste 15-27 avec la différence du champ parent : leaf commence sans parent, donc nous créons une nouvelle instance de référence Weak<Node> vide [1].

À ce stade, lorsque nous essayons d'obtenir une référence au parent de leaf en utilisant la méthode upgrade, nous obtenons une valeur None. Nous le voyons dans la sortie de la première instruction println! [2] :

leaf parent = None

Lorsque nous créons le nœud branch, il aura également une nouvelle référence Weak<Node> dans le champ parent [3] car branch n'a pas de nœud parent. Nous avons toujours leaf comme l'un des enfants de branch. Une fois que nous avons l'instance Node dans branch, nous pouvons modifier leaf pour lui donner une référence Weak<Node> à son parent [4]. Nous utilisons la méthode borrow_mut sur le RefCell<Weak<Node>> dans le champ parent de leaf, puis nous utilisons la fonction Rc::downgrade pour créer une référence Weak<Node> à branch à partir de l'Rc<Node> dans branch.

Lorsque nous affichons à nouveau le parent de leaf [5], cette fois-ci nous obtiendrons un variant Some contenant branch : maintenant leaf peut accéder à son parent! Lorsque nous affichons leaf, nous évitons également le cycle qui aboutissait finalement à un débordement de pile comme dans la liste 15-26 ; les références Weak<Node> sont affichées comme (Weak) :

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

Le manque de sortie infinie indique que ce code n'a pas créé de cycle de référence. Nous pouvons également le constater en examinant les valeurs que nous obtenons en appelant Rc::strong_count et Rc::weak_count.

Visualisation des modifications de strong_count et weak_count

Regardons comment les valeurs de strong_count et weak_count des instances Rc<Node> changent en créant un nouveau scope interne et en déplaçant la création de branch dans ce scope. En faisant ainsi, nous pouvons voir ce qui se passe lorsque branch est créée puis supprimée lorsqu'elle sort de portée. Les modifications sont montrées dans la liste 15-29.

Nom de fichier : src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

  1 println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

  2 {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

      3 println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

      4 println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
  5 }

  6 println!(
        "leaf parent = {:?}",
        leaf.parent.borrow().upgrade()
    );
  7 println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

Liste 15-29 : Création de branch dans un scope interne et examen des comptages de références fortes et faibles

Après la création de leaf, son Rc<Node> a un comptage fort de 1 et un comptage faible de 0 [1]. Dans le scope interne [2], nous créons branch et le associons à leaf, auquel moment lorsque nous affichons les comptages [3], l'Rc<Node> dans branch aura un comptage fort de 1 et un comptage faible de 1 (pour leaf.parent qui pointe vers branch avec un Weak<Node>). Lorsque nous affichons les comptages dans leaf [4], nous verrons qu'il aura un comptage fort de 2 car branch a maintenant une copie de l'Rc<Node> de leaf stockée dans branch.children, mais aura toujours un comptage faible de 0.

Lorsque le scope interne se termine [5], branch sort de portée et le comptage fort de l'Rc<Node> diminue à 0, donc son Node est supprimé. Le comptage faible de 1 de leaf.parent n'a pas d'incidence sur la suppression ou non de Node, donc nous n'avons pas de fuites mémoire!

Si nous essayons d'accéder au parent de leaf après la fin du scope, nous obtiendrons None à nouveau [6]. À la fin du programme [7], l'Rc<Node> dans leaf a un comptage fort de 1 et un comptage faible de 0 car la variable leaf est maintenant la seule référence à l'Rc<Node> à nouveau.

Toute la logique qui gère les comptages et la suppression des valeurs est intégrée dans Rc<T> et Weak<T> et leurs implémentations du trait Drop. En spécifiant que la relation d'un enfant à son parent devrait être une référence Weak<T> dans la définition de Node, vous êtes capable d'avoir des nœuds parents qui pointent vers des nœuds enfants et vice versa sans créer de cycle de référence et de fuites mémoire.

Sommaire

Félicitations! Vous avez terminé le laboratoire Cycles de référence peuvent fuitier la mémoire. Vous pouvez pratiquer d'autres laboratoires sur LabEx pour améliorer vos compétences.