Los ciclos de referencia pueden deslevar memoria

RustRustBeginner
Practicar Ahora

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

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Ciclos de Referencia Pueden Deslevar Memoria. Esta práctica es parte del Libro de Rust. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos cómo las garantías de seguridad de memoria de Rust hacen difícil pero no imposible accidentalmente crear fugas de memoria, específicamente cuando se utilizan Rc<T> y RefCell<T> lo que puede resultar en ciclos de referencia que impiden que los valores se eliminen y, por lo tanto, desleven memoria.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} rust/integer_types -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} rust/function_syntax -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} rust/expressions_statements -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} rust/method_syntax -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} rust/traits -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} rust/operator_overloading -.-> lab-100436{{"Los ciclos de referencia pueden deslevar memoria"}} end

Ciclos de Referencia Pueden Deslevar Memoria

Las garantías de seguridad de memoria de Rust hacen difícil, pero no imposible, accidentalmente crear memoria que nunca se limpie (conocida como una fuga de memoria). Prevenir completamente las fugas de memoria no es una de las garantías de Rust, lo que significa que las fugas de memoria son seguras en Rust. Podemos ver que Rust permite fugas de memoria al usar Rc<T> y RefCell<T>: es posible crear referencias donde los elementos se refieren mutuamente en un ciclo. Esto crea fugas de memoria porque el recuento de referencias de cada elemento en el ciclo nunca alcanzará 0, y los valores nunca se eliminarán.

Creando un Ciclo de Referencia

Veamos cómo podría ocurrir un ciclo de referencia y cómo evitarlo, comenzando con la definición del enum List y un método tail en la Lista 15-25.

Nombre de archivo: 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,
        }
    }
}

Lista 15-25: Definición de una lista cons que contiene un RefCell<T> para que podamos modificar a qué se refiere una variante Cons

Estamos usando otra variación de la definición de List de la Lista 15-5. El segundo elemento en la variante Cons ahora es RefCell<Rc<List>> [1], lo que significa que en lugar de tener la capacidad de modificar el valor i32 como hicimos en la Lista 15-24, queremos modificar el valor de List al que apunta una variante Cons. También estamos agregando un método tail [2] para que sea conveniente acceder al segundo elemento si tenemos una variante Cons.

En la Lista 15-26, estamos agregando una función main que utiliza las definiciones de la Lista 15-25. Este código crea una lista en a y una lista en b que apunta a la lista en a. Luego modifica la lista en a para que apunte a b, creando un ciclo de referencia. Hay declaraciones println! a lo largo del camino para mostrar cuáles son los recuentos de referencias en varios puntos de este proceso.

Nombre de archivo: 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)
    );

    // Descomenta la siguiente línea para ver que tenemos un ciclo;
    // desbordará la pila
    // println!("a next item = {:?}", a.tail());
}

Lista 15-26: Creando un ciclo de referencia de dos valores List que se apuntan mutuamente

Creamos una instancia Rc<List> que contiene un valor de List en la variable a con una lista inicial de 5, Nil [1]. Luego creamos una instancia Rc<List> que contiene otro valor de List en la variable b que contiene el valor 10 y apunta a la lista en a [2].

Modificamos a para que apunte a b en lugar de Nil, creando un ciclo. Hacemos eso usando el método tail para obtener una referencia al RefCell<Rc<List>> en a, que ponemos en la variable link [3]. Luego usamos el método borrow_mut en el RefCell<Rc<List>> para cambiar el valor interno de un Rc<List> que contiene un valor Nil al Rc<List> en b [4].

Cuando ejecutamos este código, manteniendo la última declaración println! comentada por el momento, obtendremos esta salida:

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

El recuento de referencias de las instancias Rc<List> en tanto a como b es 2 después de cambiar la lista en a para que apunte a b. Al final de main, Rust elimina la variable b, lo que reduce el recuento de referencias de la instancia Rc<List> de b de 2 a 1. La memoria que tiene Rc<List> en el montón no se eliminará en este punto porque su recuento de referencias es 1, no 0. Luego Rust elimina a, lo que también reduce el recuento de referencias de la instancia Rc<List> de a de 2 a 1. La memoria de esta instancia tampoco se puede eliminar, porque la otra instancia Rc<List> todavía la referencia. La memoria asignada a la lista permanecerá sin recopilar para siempre. Para visualizar este ciclo de referencia, hemos creado un diagrama en la Figura 15-4.

Figura 15-4: Un ciclo de referencia de listas a y b que se apuntan mutuamente

Si descomentas la última declaración println! y ejecutas el programa, Rust intentará imprimir este ciclo con a apuntando a b apuntando a a y así sucesivamente hasta que desborde la pila.

En comparación con un programa del mundo real, las consecuencias de crear un ciclo de referencia en este ejemplo no son muy graves: justo después de crear el ciclo de referencia, el programa termina. Sin embargo, si un programa más complejo asignara mucha memoria en un ciclo y la mantuviera durante mucho tiempo, el programa usaría más memoria de la necesaria y podría abrumar el sistema, causando que se agote la memoria disponible.

Crear ciclos de referencia no es fácil de hacer, pero tampoco es imposible. Si tienes valores RefCell<T> que contienen valores Rc<T> o combinaciones anidadas similares de tipos con mutabilidad interior y conteo de referencias, debes asegurarte de no crear ciclos; no puedes confiar en que Rust los capture. Crear un ciclo de referencia sería un error lógico en tu programa que debes minimizar utilizando pruebas automatizadas, revisiones de código y otras prácticas de desarrollo de software.

Otra solución para evitar los ciclos de referencia es reorganizar tus estructuras de datos de modo que algunas referencias expresen propiedad y otras no. Como resultado, puedes tener ciclos formados por algunas relaciones de propiedad y algunas relaciones no de propiedad, y solo las relaciones de propiedad afectan a si un valor puede ser eliminado. En la Lista 15-25, siempre queremos que las variantes Cons posean su lista, por lo que no es posible reorganizar la estructura de datos. Veamos un ejemplo usando gráficos compuestos por nodos padre y nodos hijo para ver cuándo las relaciones no de propiedad son una forma adecuada de prevenir ciclos de referencia.

Evitando Ciclos de Referencia Usando Weak<T>{=html}

Hasta ahora, hemos demostrado que llamar a Rc::clone aumenta el strong_count de una instancia Rc<T>, y una instancia Rc<T> solo se limpia si su strong_count es 0. También puedes crear una referencia débil al valor dentro de una instancia Rc<T> llamando a Rc::downgrade y pasando una referencia a la Rc<T>. Las referencias fuertes son cómo puedes compartir la propiedad de una instancia Rc<T>. Las referencias débiles no expresan una relación de propiedad, y su recuento no afecta a cuando se limpia una instancia Rc<T>. No causarán un ciclo de referencia porque cualquier ciclo que involucre algunas referencias débiles se romperá una vez que el recuento de referencias fuertes de los valores involucrados sea 0.

Cuando llamas a Rc::downgrade, obtienes un puntero inteligente del tipo Weak<T>. En lugar de aumentar el strong_count en la instancia Rc<T> en 1, llamar a Rc::downgrade aumenta el weak_count en 1. El tipo Rc<T> utiliza weak_count para llevar un registro de cuántas referencias Weak<T> existen, similar a strong_count. La diferencia es que el weak_count no necesita ser 0 para que la instancia Rc<T> se limpie.

Debido a que el valor al que se refiere Weak<T> podría haber sido eliminado, para hacer algo con el valor al que apunta un Weak<T> debes asegurarte de que el valor todavía existe. Haz esto llamando al método upgrade en una instancia Weak<T>, que devolverá un Option<Rc<T>>. Obtendrás un resultado de Some si el valor Rc<T> todavía no ha sido eliminado y un resultado de None si el valor Rc<T> ha sido eliminado. Debido a que upgrade devuelve un Option<Rc<T>>, Rust asegurará que se manejen los casos Some y None, y no habrá un puntero inválido.

Como ejemplo, en lugar de usar una lista cuyos elementos solo conocen sobre el siguiente elemento, crearemos un árbol cuyos elementos conocen sobre sus elementos hijos y sus elementos padres.

Creando una Estructura de Datos de Árbol: Un Nodo con Nodos Hijos

Para comenzar, construiremos un árbol con nodos que conocen sobre sus nodos hijos. Crearemos un struct llamado Node que contendrá su propio valor i32 así como referencias a los valores de sus nodos hijos:

Nombre de archivo: src/main.rs

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

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

Queremos que un Node posea a sus hijos, y queremos compartir esa propiedad con variables para que podamos acceder directamente a cada Node en el árbol. Para hacer esto, definimos los elementos de Vec<T> como valores del tipo Rc<Node>. También queremos modificar qué nodos son hijos de otro nodo, por lo que tenemos un RefCell<T> en children alrededor de Vec<Rc<Node>>.

A continuación, usaremos nuestra definición de struct y crearemos una instancia de Node llamada leaf con el valor 3 y sin hijos, y otra instancia llamada branch con el valor 5 y leaf como uno de sus hijos, como se muestra en la Lista 15-27.

Nombre de archivo: 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)]),
    });
}

Lista 15-27: Creando un nodo leaf sin hijos y un nodo branch con leaf como uno de sus hijos

Clonamos el Rc<Node> en leaf y lo almacenamos en branch, lo que significa que el Node en leaf ahora tiene dos propietarios: leaf y branch. Podemos llegar de branch a leaf a través de branch.children, pero no hay forma de llegar de leaf a branch. La razón es que leaf no tiene una referencia a branch y no sabe que están relacionados. Queremos que leaf sepa que branch es su padre. Lo haremos a continuación.

Agregando una Referencia de un Hijo a su Padre

Para que el nodo hijo sea consciente de su padre, necesitamos agregar un campo parent a la definición de nuestro struct Node. El problema está en decidir cuál debe ser el tipo de parent. Sabemos que no puede contener un Rc<T> porque eso crearía un ciclo de referencia con leaf.parent apuntando a branch y branch.children apuntando a leaf, lo que haría que sus valores de strong_count nunca fueran 0.

Pensando en las relaciones de otra manera, un nodo padre debe poseer a sus hijos: si se elimina un nodo padre, sus nodos hijos también deben eliminarse. Sin embargo, un hijo no debe poseer a su padre: si eliminamos un nodo hijo, el padre debe seguir existiendo. ¡Este es un caso para referencias débiles!

Entonces, en lugar de Rc<T>, haremos que el tipo de parent utilice Weak<T>, específicamente un RefCell<Weak<Node>>. Ahora la definición de nuestro struct Node se ve así:

Nombre de archivo: 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 nodo será capaz de referirse a su nodo padre pero no posee a su padre. En la Lista 15-28, actualizamos main para usar esta nueva definición para que el nodo leaf tenga una forma de referirse a su padre, branch.

Nombre de archivo: 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()
    );
}

Lista 15-28: Un nodo leaf con una referencia débil a su nodo padre, branch

Crear el nodo leaf se parece a la Lista 15-27 con la excepción del campo parent: leaf comienza sin un padre, por lo que creamos una nueva instancia de referencia Weak<Node> vacía [1].

En este punto, cuando intentamos obtener una referencia al padre de leaf usando el método upgrade, obtenemos un valor None. Vemos esto en la salida de la primera declaración println! [2]:

leaf parent = None

Cuando creamos el nodo branch, también tendrá una nueva referencia Weak<Node> en el campo parent [3] porque branch no tiene un nodo padre. Todavía tenemos leaf como uno de los hijos de branch. Una vez que tenemos la instancia Node en branch, podemos modificar leaf para darle una referencia Weak<Node> a su padre [4]. Usamos el método borrow_mut en el RefCell<Weak<Node>> en el campo parent de leaf, y luego usamos la función Rc::downgrade para crear una referencia Weak<Node> a branch a partir del Rc<Node> en branch.

Cuando imprimimos el padre de leaf nuevamente [5], esta vez obtendremos una variante Some que contiene branch: ahora leaf puede acceder a su padre. Cuando imprimimos leaf, también evitamos el ciclo que eventualmente terminó en un desbordamiento de pila como tuvimos en la Lista 15-26; las referencias Weak<Node> se imprimen como (Weak):

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

La ausencia de una salida infinita indica que este código no creó un ciclo de referencia. También podemos saberlo al ver los valores que obtenemos al llamar a Rc::strong_count y Rc::weak_count.

Visualizando los Cambios en strong_count y weak_count

Veamos cómo cambian los valores de strong_count y weak_count de las instancias Rc<Node> creando un nuevo ámbito interno y moviendo la creación de branch a ese ámbito. Al hacerlo, podemos ver qué pasa cuando se crea branch y luego se elimina cuando sale del ámbito. Las modificaciones se muestran en la Lista 15-29.

Nombre de archivo: 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),
    );
}

Lista 15-29: Creando branch en un ámbito interno y examinando los recuentos de referencias fuertes y débiles

Después de crear leaf, su Rc<Node> tiene un recuento fuerte de 1 y un recuento débil de 0 [1]. En el ámbito interno [2], creamos branch y lo asociamos con leaf, momento en el que al imprimir los recuentos [3], el Rc<Node> en branch tendrá un recuento fuerte de 1 y un recuento débil de 1 (por leaf.parent apuntando a branch con un Weak<Node>). Cuando imprimimos los recuentos en leaf [4], veremos que tendrá un recuento fuerte de 2 porque branch ahora tiene una copia del Rc<Node> de leaf almacenado en branch.children, pero todavía tendrá un recuento débil de 0.

Cuando finaliza el ámbito interno [5], branch sale del ámbito y el recuento fuerte del Rc<Node> disminuye a 0, por lo que su Node se elimina. El recuento débil de 1 de leaf.parent no tiene nada que ver con si se elimina o no Node, por lo que no tenemos fugas de memoria.

Si intentamos acceder al padre de leaf después del final del ámbito, obtendremos None nuevamente [6]. Al final del programa [7], el Rc<Node> en leaf tiene un recuento fuerte de 1 y un recuento débil de 0 porque la variable leaf ahora es la única referencia al Rc<Node> nuevamente.

Toda la lógica que gestiona los recuentos y la eliminación de valores está integrada en Rc<T> y Weak<T> y sus implementaciones del trato Drop. Al especificar que la relación de un hijo a su padre debe ser una referencia Weak<T> en la definición de Node, puedes tener nodos padres que apunten a nodos hijos y viceversa sin crear un ciclo de referencia y fugas de memoria.

Resumen

¡Felicitaciones! Has completado el laboratorio Ciclos de Referencia Pueden Deslevar Memoria. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.