Rusty Full Stack

El blog para los amantes de Rust, Ionic y Vuejs

Jaime Blandón
Jaime Blandón Desarrollador de software desde 2009, entusiasta de Rust, Vuejs y Ionic!. Fundador de este blog, espero que las publicaciones te sean de utilidad y si tienes comentarios para mejorar, son bienvenidos.

Optimiza El Manejo De Memoria De Tu Programa Con OnceCell y OnceLock

En las últimas actualizaciones de Rust (desde la versión 1.7.0 y superior), se ha logrado estabilizar dos componentes que son parte de la librería standard de Rust, estos componentes son OnceCell y OnceLock cuyo propósito es crear celdas o bloques de memoria que pueden ser escritas una única vez y permitir obtener su valor mediante referencias sin necesidad de copiar todos sus datos o permitir su reemplazo, optimizando de esta forma el manejo de memoria 🥳.

¿Por qué es útil que únicamente se pueda escribir una vez?

Adicional a la la pregunta, también es de utilidad que no se pueda copiar el valor. Obviamente en nuestros programas, generalmente las variables pueden cambiar de valor en base a cálculos, pero hay algunos tipos de variables bastante complejos que queremos sean constantes,  por ejemplo una sesión de base de datos o alguna estructura que queremos iniciar una única vez y reutilizar su valor en todo nuestro programa, similar al patrón singleton 🤓.

En Rust, lograr que un objeto complejo no cambie su valor era una tarea no tan sencilla puesto que al no conocer el tamaño final de nuestro objeto, era necesario muchas veces declararlo como una celda de memoria o Cell, el problema era que estos últimos podían permitir la sobre escritura, es decir, que para obtener o pasar su valor, muchas veces era necesario copiar o reescribirlos y esto, como puedes imaginar, implica muchas operaciones de memoria.

Ahora es posible declarar una celda que podemos iniciar su valor una única vez, y podemos obtener su valor sin necesidad de copiarlo, es decir como una referencia, lo cual es mucho más óptimo a nivel de manejo de memoria, también es mucho más seguro puesto nos permite evitar algún problema con desbordamientos de memoria.

¿En qué casos puede ser necesario el no cambiar los valores de una estructura?

Sé que lo primero que se te puede venir a la mente al ver esta pregunta es el uso de constantes o const en Rust, pero, generalmente, los valores constantes se utilizan con tipos de datos primitivos como por ejemplo u8, i32, etc.

Sin embargo, imagínate casos de uso más complicados como por ejemplo una sesión de base de datos, la cual, a nivel de memoria puede significar una tarea bastante grande el abrir la conexión y almacenar la sesión. Si intentamos hacer esto con el uso de constantes, es probable que nos topemos con muchas complicaciones, en especial si diferentes funciones o estructuras deben de hacer uso de esa sesión.

Por consiguiente OnceCell y OnceLock, son bastante útiles cuando tenemos estructuras o datos complejos que no queremos cambien constantemente, o que puedan estar disponibles durante todo el ciclo de vida de nuestro programa y que no implique un gran esfuerzo, a nivel de memoria, el mantenerlos disponibles.

¿Cómo Utilizar OnceCell?

NOTA 🧐: Es importante tener la versión de Rust 1.7.0 o superior, para actualizar tu versión de Rust, puedes ejecutar en una terminal el comando:

        
        
            
rustup update
        
        
    

La mejor forma para aprender a utilizar OnceCell, es haciendo un ejemplo. Vamos a crear una carpeta donde más nos guste y le pondremos por nombre ejemplo_oncecell.

Ahora dentro de nuestra nueva carpeta crearemos un archivo main.rs

Vamos simular que creamos un programa el cual tendrá una estructura que llamaremos ConexionBaseDeDatos, dicha estructura queremos inicializar sus valores una única vez y es importante que no pueda ser modificada debido a que "alteraría" los resultados de nuestro programa, pero, también queremos utilizar los valores de dicho objeto en todo nuestro programa sin forzar a Rust el hacer varias tareas de memoria para optimizar nuestro programa.

La estructura, ConexionBaseDeDatos, hace una simulación de lo que en la práctica sería un manejador de conexión. 

Empecemos escribiendo el siguiente código en nuestro archivo main.rs

(Puedes ver el código completo desde el repositorio en github dando click acá)

        
        
            // main.rs

use std::cell::OnceCell;

fn main() {
    println!("Hello World");
}

        
        
    

Hasta el momento no estamos haciendo nada mas que importar OnceCell, ahora vamos a crear una estructura que simule la conexión a nuestra base de datos, siempre en nuestro archivo main.rs agregamos:

        
        
            // main.rs

use std::cell::OnceCell;

struct ConexionBaseDeDatos {
    session_id: String,
    hash: String,
}

impl ConexionBaseDeDatos {

    fn ejecutar_query(&self, query: &str) -> String {
        format!(
            "Query Ejecutado con el session_id {}, hash {}, query: {}",
            self.session_id.as_str(), 
            self.hash.as_str(), 
            query
        )
    }

}

fn main() {
    println!("Hello World");
}

        
        
    

En el código anterior únicamente estamos definiendo una estructura que contendrá dos valores ficticios, pero que simulan una conexión a una base de datos, uno de los valores es el session_id y el otro valor es hash.

También se define el método ejecutar_query, que lo único que hace es retornar un String con el session_id, hash y el query que se deberían de ejecutar.

Si bien es cierto, un verdadero manejar de base de datos es muchísimo más complejo que el código anterior, esto nos ayuda a ejemplificar los beneficios de OnceCell.

Ahora vamos a suponer que nuestro programa abre la conexión a la base de datos y se inicializarán los valores del session_id y del hash, en nuestro archivo main.rs agregamos:

        
        
            // main.rs

use std::cell::OnceCell;

struct ConexionBaseDeDatos {
    session_id: String,
    hash: String,
}

impl ConexionBaseDeDatos {
    fn ejecutar_query(&self, query: &str) -> String {
        return format!(
            "Query Ejecutado con el session_id {}, hash {}, query: {}",
            self.session_id.as_str(),
            self.hash.as_str(),
            query
        );
    }
}

fn main() {
    // Aca creamos una nueva celda de memoria, pero unicamente
    // se inicializa una vez, luego mantendra el mismo valor
    // sin cambiarse ni hacer copy o reescribir, lo cual es optimio
    // a nivel de memoria.
    let cell = OnceCell::new();

    // Simulando que abrimos una conexion, es importante destacar
    // que el metodo get_or_init, crea una nueva instancia si no se ha hecho antes
    // pero si ya la instancia habia sido creada, devolvera siempre el mismo
    // objeto pero como una referencia &
    // tambien es importante mencionar que no regresa un &mut por lo que luego
    // no se pueden cambiar los valores, los escribe solo una vez
    let conexion: &ConexionBaseDeDatos = cell.get_or_init(|| ConexionBaseDeDatos {
        session_id: "123456789".to_string(),
        hash: "abcde1234".to_string(),
    });

    println!("Session id: {}", conexion.session_id);
}

        
        
    

la línea:

let cell = OnceCell::new();

indica que vamos a crear un nuevo OnceCell, luego podremos hacer uso del método get_or_init, el cual nos permitirá inicializar u obtener el valor si ha sido inicializado anteriormente, es importante mencionar que get_or_init, devuelve una referencia no mutable (por lo que no pueden alterarse los valores directamente) de la estructura que hemos definido ConexionBaseDeDatos.

Si ahora compilamos nuestro programa ejecutando en nuestra terminal:

rustc main.rs

Veremos algunos warnings que por el momento vamos a ignorar, pero nuestro programa debería compilar sin problema.

Ahora podemos ejecutarlo desde nuestra terminal con:./main

Si todo sale bien, vamos a ver el siguiente resultado.

Ok a lo mejor hasta el momento no hemos obtenido un gran provecho, pero que pasaría si ahora intentamos volver a inicializar el objeto que creo OnceCell, agreguemos el siguiente código en main.rs

        
        
            // main.rs

use std::cell::OnceCell;

struct ConexionBaseDeDatos {
    session_id: String,
    hash: String,
}

impl ConexionBaseDeDatos {
    fn ejecutar_query(&self, query: &str) -> String {
        return format!(
            "Query Ejecutado con el session_id {}, hash {}, query: {}",
            self.session_id.as_str(),
            self.hash.as_str(),
            query
        );
    }
}

fn main() {
    // Aca creamos una nueva celda de memoria, pero unicamente
    // se inicializa una vez, luego mantendra el mismo valor
    // sin cambiarse ni hacer copy o reescribir, lo cual es optimio
    // a nivel de memoria.
    let cell = OnceCell::new();

    // Simulando que abrimos una conexion, es importante destacar
    // que el metodo get_or_init, crea una nueva instancia si no se ha hecho antes
    // pero si ya la instancia habia sido creada, devolvera siempre el mismo
    // objeto pero como una referencia &
    // tambien es importante mencionar que no regresa un &mut por lo que luego
    // no se pueden cambiar los valores, los escribe solo una vez
    let conexion: &ConexionBaseDeDatos = cell.get_or_init(|| ConexionBaseDeDatos {
        session_id: "123456789".to_string(),
        hash: "abcde1234".to_string(),
    });

    println!(
        "Session id: {} - Hash: {}",
        conexion.session_id, conexion.hash
    );

    // Aca volvemos a pedir la referencia con get_or_init
    // Como OnceCell ya habia iniciliazado una vez la referencia
    // A ConexionBaseDeDatos, esta no deberia de cambiar sus valores
    // Aun si se pasan nuevos parametros para session_id y hash
    let conexion2: &ConexionBaseDeDatos = cell.get_or_init(|| ConexionBaseDeDatos {
        session_id: "cambiara?".to_string(),
        hash: "cambiara?".to_string(),
    });

    // Aca vamos a verificar si los valores cambiaron
    println!(
        "Nuevo Print Desde main, ha cambiado? Session id: {} - Hash: {}",
        conexion2.session_id, conexion2.hash
    );
}

        
        
    

Como podemos ver, en main() estamos creando una nueva "variable" conexion2 y estamos volviendo a invocar get_or_init, el cual no debería cambiar los valores aún si se pasan otros parámetros como en el código.

Si volvemos a compilar nuestro programa con:

rustc main

Siempre volveremos a ver un warning que ya arreglaremos después 😇.

Y ahora si ejecutamos nuestro programa, veremos en el resultado que los valores no han cambiado nada, y esto se debe que OnceCell ya había inicializado nuestra estructura ConexionBaseDeDatos!!

./main

Puedes ver el código completo desde el repositorio en github dando click acá

Utilizando OnceCell En Otras Partes De Nuestro Programa.

Ahora podemos utilizar nuestro OnceCell en otras partes de nuestro programa, aún si utilizamos get_or_init, veremos que los valores no se alteran.

Agreguemos una función a nuestro archivo main.rs, ejecutemos get_or_init en la función (aunque si sabemos que ya ha sido inicializado, basta utilizar el método get) para que vemos que nada se altera, también vamos a quitar el warning al momento de compilar, agrega el siguiente código en main.rs

        
        
            // main.rs

use std::cell::OnceCell;

struct ConexionBaseDeDatos {
    session_id: String,
    hash: String,
}

impl ConexionBaseDeDatos {
    fn ejecutar_query(&self, query: &str) -> String {
        return format!(
            "Query Ejecutado con el session_id {}, hash {}, query: {}",
            self.session_id.as_str(),
            self.hash.as_str(),
            query
        );
    }
}

fn ejecutando_un_query(cell: &OnceCell<ConexionBaseDeDatos>, query: &str) {
    // En esta funcion vamos a intentar reinicializar nuestra referencia
    // de OnceCell
    let conexion: &ConexionBaseDeDatos = cell.get_or_init(|| ConexionBaseDeDatos {
        session_id: "cambiara desde otra funcion?".to_string(),
        hash: "cambiara desde otra funcion?".to_string(),
    });
    // Aca "ejecutamos el query" el cual simplemente imprimira el session_id
    // hash los cuales no debieron haber cambiado, tambien imprime el query
    println!("{}", conexion.ejecutar_query(query));
}

fn main() {
    // Aca creamos una nueva celda de memoria, pero unicamente
    // se inicializa una vez, luego mantendra el mismo valor
    // sin cambiarse ni hacer copy o reescribir, lo cual es optimio
    // a nivel de memoria.
    let cell = OnceCell::new();

    // Simulando que abrimos una conexion, es importante destacar
    // que el metodo get_or_init, crea una nueva instancia si no se ha hecho antes
    // pero si ya la instancia habia sido creada, devolvera siempre el mismo
    // objeto pero como una referencia &
    // tambien es importante mencionar que no regresa un &mut por lo que luego
    // no se pueden cambiar los valores, los escribe solo una vez
    let conexion: &ConexionBaseDeDatos = cell.get_or_init(|| ConexionBaseDeDatos {
        session_id: "123456789".to_string(),
        hash: "abcde1234".to_string(),
    });

    println!(
        "Session id: {} - Hash: {}",
        conexion.session_id, conexion.hash
    );

    // Aca volvemos a pedir la referencia con get_or_init
    // Como OnceCell ya habia iniciliazado una vez la referencia
    // A ConexionBaseDeDatos, esta no deberia de cambiar sus valores
    // Aun si se pasan nuevos parametros para session_id y hash
    let conexion2: &ConexionBaseDeDatos = cell.get_or_init(|| ConexionBaseDeDatos {
        session_id: "cambiara?".to_string(),
        hash: "cambiara?".to_string(),
    });

    // Aca vamos a verificar si los valores cambiaron
    println!(
        "Nuevo Print Desde main, ha cambiado? Session id: {} - Hash: {}",
        conexion2.session_id, conexion2.hash
    );

    ejecutando_un_query(&cell, "SELECT * FROM mitabla");
    ejecutando_un_query(&cell, "SELECT * FROM usuarios");
}

        
        
    

Hemos agregado una nueva función ejecutando_un_query, el cual intenta reinicializar nuestra referencia a OnceCell, luego, en main() se hara el llamado a la función e imprimirá los resultados, si todo sale como se espera, los valores del session_id y hash no deberían de cambiar.

Compilemos nuestro programa con:

rustc main.rs

Vemos que el warning ya no está 😎.

Ahora si ejecutamos nuestro programa con:

./main

Veremos que tanto el session_id y el hash siguen intactos si incluso se intentan cambiar varias veces o desde diferentes funciones.

Hemos logrado utilizar OnceCell sin problemas, pero si nuestro programa utilizará múltiples hilos como por ejemplo correr un servidor web, OnceCell no sería tan seguro de utilizar y necesitaremos OnceLock.

¿Cómo Utilizar OnceLock?

OnceLock es la versión segura de OnceCell cuando se utilizan varios hilos en nuestro programa.

Su forma de utilizar es similar a OnceCell, también nos permite utilizar static para definición de nuestras celdas.

Vamos a hacer un nuevo ejemplo para lo cual crearemos un nuevo folder al que pondremos de nombre ejemplo_oncelock, dentro de nuestra nueva carpeta, crearemos un archivo de nombre main.rs

Anteriormente hicimos un ejemplo para OnceCell en el cual simulamos una sesión de base de datos. Otro tipo de estructura que puede llegar a ser bastante compleja, es aquella que hace las veces de manejador de memoria o storage de algún dispositivo como por ejemplo el storage de un navegador web o de un teléfono móvil.

Vamos a crear un ejemplo en el cual simulemos que creamos un manejador de storage como si fuera un dispositivo móvil en el cual existen varios hilos o threads que se ejecutan al mismo tiempo en nuestro sistema operativo.

Nuestro programa inicializará el storage asignándole un valor de sesión y vamos a utilizar nuestra celda en dos hilos o threads que se ejecutarán en paralelo, cada hilo tendrá un intervalo de tiempo antes de intentar reinicializar el valor de la referencia del storage.

(Puedes ver el código completo del ejemplo desde el repositorio en github dando click acá.)

Puesto que ya hemos visto la mayoría de conceptos con OnceCell, vamos a colocar todo el código directamente en main.rs:

        
        
            // main.rs

use std::sync::OnceLock;

struct ConexionStorage {
    sesion: String,
}

// OnceLock permite utiliza static para la definicion
// de las celdas
static CELL: OnceLock<ConexionStorage> = OnceLock::new();

fn main() {
    // Ahora inicializamos tal cual se hace con OnceCell
    // utiliza el mismo concepto de retornar una referencia &
    // inmutable, por lo que no puede modificarse directamente
    CELL.get_or_init(|| ConexionStorage {
        sesion: "0123456".to_string(),
    });

    // creamos uno de los hilos, el cual intentara inicializar el valor
    // de nuestro storage hasta en 9 ocasiones, pero debemos ver que siempre
    // imprime el valor con el cual fue inicilizado, cada intento se realizara
    // en un intervalo de 2 segundos
    let hilo = std::thread::spawn(|| {
        for i in 1..10 {
            let value: &ConexionStorage = CELL.get_or_init(|| ConexionStorage {
                sesion: "No deberia de imprimir este mensaje, sino Sesion Abierta".to_string(),
            });

            println!("Hilo Primario intento {} sesion: {}", i, value.sesion);

            std::thread::sleep(std::time::Duration::from_millis(2000));
        }
    });

    // Ahora en nuestro hilo principal, vamos a intentar realizar la incializacion
    // hasta en 4 intentos con un intervalo de 3 segundos por intento
    for i in 1..5 {
        let value: &ConexionStorage = CELL.get_or_init(|| ConexionStorage {
            sesion: "No deberia de imprimir este mensaje, sino Sesion Abierta".to_string(),
        });

        println!("Hilo secundario, intento {} sesion: {}", i, value.sesion);

        std::thread::sleep(std::time::Duration::from_millis(3000));
    }

    // Aca definimos al hilo principal que hay que esperar el que el thread
    // "hilo" finalice antes de terminar el programa
    hilo.join().unwrap();
}


        
        
    

(Si quieres que hablemos con mayor profundidad sobre hilos y concurrencia con Rust, déjalo en la caja de comentarios 😋)

Si ahora compilamos nuestro programa con:

rustc main.rs

y lo ejecutamos con:

./main

Veremos que no importa que hilos diferentes tomen nuestro OnceLock y también que aunque cada hilo intenta cambiar el valor inicializado anteriormente, nuestra referencia se encuentra inmutable!

Puedes ver el código completo del ejemplo desde el repositorio en github dando click acá.

Ahora podemos utilizar OnceCell y OnceLock para el manejo eficiente de memoria para tipos de datos o estructuras que no necesitamos cambien en nuestro programa.

Si esta publicación te ha sido de utilidad compártela en tus redes sociales y con tus amigos!

Si tienes algún tema que te gustaría aprender sobre Rust, déjalo en la caja de comentarios al final de este artículo.

No te olvides de seguirme en twitter (ahora X) 

println!("hasta la próxima!");


 Utilizamos cookies propias y de terceros para mejorar tu experiencia, mostrar publicidad y análisis de navegación, puedes encontrar el detalle en nuestra Política de Cookies