Concurrencia de Estado Compartido en Rust

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 Shared-State Concurrency. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploramos el concepto de concurrencia de memoria compartida y por qué los entusiastas del paso de mensajes advierten sobre ella.


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{{"Concurrencia de Estado Compartido en Rust"}} rust/mutable_variables -.-> lab-100439{{"Concurrencia de Estado Compartido en Rust"}} rust/for_loop -.-> lab-100439{{"Concurrencia de Estado Compartido en Rust"}} rust/function_syntax -.-> lab-100439{{"Concurrencia de Estado Compartido en Rust"}} rust/expressions_statements -.-> lab-100439{{"Concurrencia de Estado Compartido en Rust"}} rust/method_syntax -.-> lab-100439{{"Concurrencia de Estado Compartido en Rust"}} end

Concurrencia de Estado Compartido

El paso de mensajes es una forma adecuada de manejar la concurrencia, pero no es la única. Otro método sería que múltiples subprocesos accedan a los mismos datos compartidos. Consideremos nuevamente esta parte del eslogan de la documentación del lenguaje Go: "No comunique mediante la compartición de memoria".

¿Cómo sería comunicarse mediante la compartición de memoria? Además, ¿por qué los entusiastas del paso de mensajes recomiendan no utilizar la compartición de memoria?

De cierto modo, los canales en cualquier lenguaje de programación son similares a la propiedad única, ya que una vez que se transfiere un valor a través de un canal, ya no se debe utilizar ese valor. La concurrencia de memoria compartida es como la propiedad múltiple: múltiples subprocesos pueden acceder a la misma ubicación de memoria al mismo tiempo. Como viste en el Capítulo 15, donde los punteros inteligentes hicieron posible la propiedad múltiple, la propiedad múltiple puede agregar complejidad porque estos diferentes propietarios necesitan ser administrados. El sistema de tipos y las reglas de propiedad de Rust ayudan en gran medida a que esta administración sea correcta. Para un ejemplo, veamos los mutex, uno de los primitivos de concurrencia más comunes para la memoria compartida.

Usando Mutex para Permitir el Acceso a Datos de un Hilo a la Vez

Mutex es la abreviatura de mutual exclusion (exclusión mutua), ya que un mutex permite que solo un hilo acceda a ciertos datos en cualquier momento dado. Para acceder a los datos de un mutex, un hilo debe primero señalar que desea acceder pidiendo adquirir el cerrojo del mutex. El cerrojo es una estructura de datos que es parte del mutex y que lleva un registro de quién tiene actualmente acceso exclusivo a los datos. Por lo tanto, el mutex se describe como protegiendo los datos que contiene a través del sistema de bloqueo.

Los mutex tienen la reputación de ser difíciles de usar porque hay que recordar dos reglas:

  1. Debes intentar adquirir el cerrojo antes de usar los datos.
  2. Cuando hayas terminado con los datos que el mutex protege, debes desbloquear los datos para que otros hilos puedan adquirir el cerrojo.

Para una metáfora del mundo real de un mutex, imagina una mesa redonda en una conferencia con solo un micrófono. Antes de que un ponente pueda hablar, tiene que pedir o señalar que desea usar el micrófono. Cuando obtiene el micrófono, puede hablar durante el tiempo que desee y luego entregar el micrófono al siguiente ponente que solicite hablar. Si un ponente olvida entregar el micrófono cuando ha terminado con él, nadie más podrá hablar. Si la gestión del micrófono compartido sale mal, la mesa redonda no funcionará como se planeó.

La gestión de los mutex puede resultar increíblemente complicada de hacer bien, lo que es por lo que mucha gente está entusiasmada por los canales. Sin embargo, gracias al sistema de tipos y las reglas de propiedad de Rust, no se puede equivocar al bloquear y desbloquear.

La API de Mutex<T>{=html}

Como ejemplo de cómo usar un mutex, comenzaremos por usarlo en un contexto de un solo hilo, como se muestra en la Lista 16-12.

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

Lista 16-12: Explorando la API de Mutex<T> en un contexto de un solo hilo para mayor simplicidad

Como con muchos tipos, creamos un Mutex<T> usando la función asociada new [1]. Para acceder a los datos dentro del mutex, usamos el método lock para adquirir el cerrojo [2]. Esta llamada bloqueará el hilo actual, por lo que no podrá realizar ningún trabajo hasta que sea su turno para tener el cerrojo.

La llamada a lock fallaría si otro hilo que tiene el cerrojo se descontrolara. En ese caso, nadie podría obtener el cerrojo, por lo que hemos elegido unwrap y que este hilo se descontrolara si estamos en esa situación.

Después de haber adquirido el cerrojo, podemos tratar el valor devuelto, denominado num en este caso, como una referencia mutable a los datos dentro. El sistema de tipos garantiza que adquiramos un cerrojo antes de usar el valor en m. El tipo de m es Mutex<i32>, no i32, por lo que debemos llamar a lock para poder usar el valor i32. No podemos olvidarlo; el sistema de tipos no nos permitirá acceder al i32 interno de otra manera.

Como es de esperar, Mutex<T> es un puntero inteligente. Más precisamente, la llamada a lock devuelve un puntero inteligente llamado MutexGuard, envuelto en un LockResult que manejamos con la llamada a unwrap. El puntero inteligente MutexGuard implementa Deref para apuntar a nuestros datos internos; el puntero inteligente también tiene una implementación de Drop que libera el cerrojo automáticamente cuando un MutexGuard sale del ámbito, lo que sucede al final del ámbito interno [4]. Como resultado, no corremos el riesgo de olvidar liberar el cerrojo y bloquear el mutex para que no pueda ser usado por otros hilos porque la liberación del cerrojo se produce automáticamente.

Después de liberar el cerrojo, podemos imprimir el valor del mutex y ver que pudimos cambiar el i32 interno a 6 [5].

Compartiendo un Mutex<T>{=html} Entre Varios Hilos

Ahora intentemos compartir un valor entre varios hilos usando Mutex<T>. Iniciaremos 10 hilos y les pediremos que cada uno incremente en 1 un valor de contador, de modo que el contador pase de 0 a 10. El ejemplo de la Lista 16-13 tendrá un error de compilación, y usaremos ese error para aprender más sobre el uso de Mutex<T> y cómo Rust nos ayuda a usarlo correctamente.

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

Lista 16-13: Diez hilos, cada uno incrementando un contador protegido por un Mutex<T>

Creamos una variable counter para almacenar un i32 dentro de un Mutex<T> [1], como hicimos en la Lista 16-12. A continuación, creamos 10 hilos iterando sobre un rango de números [2]. Usamos thread::spawn y le damos a todos los hilos la misma clausura: una que mueve el contador al hilo [3], adquiere un cerrojo en el Mutex<T> llamando al método lock [4], y luego suma 1 al valor en el mutex [5]. Cuando un hilo termina de ejecutar su clausura, num saldrá del ámbito y liberará el cerrojo para que otro hilo pueda adquirirlo.

En el hilo principal, recolectamos todos los manejadores de unión [6]. Luego, como hicimos en la Lista 16-2, llamamos a join en cada manejador para asegurarnos de que todos los hilos terminen [7]. En ese momento, el hilo principal adquirirá el cerrojo y mostrará el resultado de este programa [8].

Indicamos que este ejemplo no se compilaría. Ahora averigüemos por qué.

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

El mensaje de error indica que el valor counter se movió en la iteración anterior del bucle. Rust está diciéndonos que no podemos mover la propiedad del cerrojo counter a varios hilos. Vamos a corregir el error de compilación con el método de propiedad múltiple que discutimos en el Capítulo 15.

Propiedad Múltiple con Varios Hilos

En el Capítulo 15, le dimos un valor a múltiples propietarios usando el puntero inteligente Rc<T> para crear un valor con referencia contada. Hagamos lo mismo aquí y veamos qué pasa. Envolveremos el Mutex<T> en Rc<T> en la Lista 16-14 y clonaremos el Rc<T> antes de transferir la propiedad al hilo.

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

Lista 16-14: Intentando usar Rc<T> para permitir que varios hilos posean el Mutex<T>

Una vez más, compilamos y obtenemos... errores diferentes. El compilador está enseñándonos mucho.

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, ese mensaje de error es muy largo. Aquí está la parte importante en la que centrarse: Rc<Mutex<i32>>` no se puede enviar entre hilos de forma segura` [1]. El compilador también nos está diciendo el por qué: `la característica `Send` no está implementada para `Rc<Mutex<i32>> [2]. Hablaremos de Send en la siguiente sección: es una de las características que garantiza que los tipos que usamos con los hilos son adecuados para su uso en situaciones concurrentes.

Lamentablemente, Rc<T> no es seguro para compartir entre hilos. Cuando Rc<T> gestiona la cuenta de referencias, aumenta la cuenta para cada llamada a clone y la disminuye cuando se elimina cada clon. Pero no utiliza ningún primitivo de concurrencia para asegurarse de que los cambios en la cuenta no puedan ser interrumpidos por otro hilo. Esto podría llevar a cuentas incorrectas, errores sutiles que a su vez podrían causar fugas de memoria o que un valor se elimine antes de que hayamos terminado con él. Lo que necesitamos es un tipo exactamente como Rc<T> pero que haga los cambios en la cuenta de referencias de manera segura para los hilos.

Conteo de Referencias Atómico con Arc<T>{=html}

Afortunadamente, Arc<T> es un tipo como Rc<T> que es seguro de usar en situaciones concurrentes. La a significa atómico, lo que significa que es un tipo con conteo de referencias atómico. Los atómicos son un tipo adicional de primitivos de concurrencia que no cubriremos en detalle aquí: consulte la documentación de la biblioteca estándar para std::sync::atomic para obtener más detalles. En este momento, solo necesita saber que los atómicos funcionan como los tipos primitivos pero son seguros para compartir entre hilos.

Entonces, puede preguntarse por qué no todos los tipos primitivos son atómicos y por qué los tipos de la biblioteca estándar no se implementan para usar Arc<T> por defecto. La razón es que la seguridad para los hilos tiene un costo de rendimiento que solo quieres pagar cuando realmente lo necesitas. Si solo estás realizando operaciones en valores dentro de un solo hilo, tu código puede ejecutarse más rápido si no tiene que cumplir con las garantías que proporcionan los atómicos.

Volvamos a nuestro ejemplo: Arc<T> y Rc<T> tienen la misma API, por lo que corregimos nuestro programa cambiando la línea use, la llamada a new y la llamada a clone. El código de la Lista 16-15 finalmente se compilará y ejecutará.

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

Lista 16-15: Usando un Arc<T> para envolver el Mutex<T> para poder compartir la propiedad entre varios hilos

Este código imprimirá lo siguiente:

Result: 10

¡Lo hicimos! Contamos de 0 a 10, lo que puede no parecer muy impresionante, pero realmente nos enseñó mucho sobre Mutex<T> y la seguridad para los hilos. También podrías usar la estructura de este programa para hacer operaciones más complicadas que solo incrementar un contador. Usando esta estrategia, puedes dividir un cálculo en partes independientes, dividir esas partes entre hilos y luego usar un Mutex<T> para que cada hilo actualice el resultado final con su parte.

Tenga en cuenta que si está realizando operaciones numéricas simples, hay tipos más simples que los tipos Mutex<T> proporcionados por el módulo std::sync::atomic de la biblioteca estándar. Estos tipos proporcionan acceso seguro, concurrente y atómico a los tipos primitivos. Elegimos usar Mutex<T> con un tipo primitivo para este ejemplo para que pudiéramos concentrar nuestra atención en cómo funciona Mutex<T>.

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

Es posible que hayas notado que counter es inmutable pero que pudimos obtener una referencia mutable al valor dentro de él; esto significa que Mutex<T> proporciona mutabilidad interior, como lo hace la familia Cell. De la misma manera que usamos RefCell<T> en el Capítulo 15 para permitirnos mutar el contenido dentro de un Rc<T>, usamos Mutex<T> para mutar el contenido dentro de un Arc<T>.

Otro detalle a destacar es que Rust no puede protegerte de todos los tipos de errores de lógica cuando usas Mutex<T>. Recuerda del Capítulo 15 que usar Rc<T> venía con el riesgo de crear ciclos de referencias, donde dos valores Rc<T> se refieren el uno al otro, lo que causa fugas de memoria. Del mismo modo, Mutex<T> viene con el riesgo de crear bloqueos mortales. Estos ocurren cuando una operación necesita bloquear dos recursos y dos hilos han adquirido cada uno uno de los bloques, lo que los hace esperar el uno al otro para siempre. Si estás interesado en los bloqueos mortales, intenta crear un programa Rust que tenga un bloqueo mortal; luego investiga estrategias para mitigar los bloqueos mortales para los mutexes en cualquier lenguaje y prueba implementarlas en Rust. La documentación de la API de la biblioteca estándar para Mutex<T> y MutexGuard ofrece información útil.

Terminaremos este capítulo hablando sobre las características Send y Sync y cómo podemos usarlas con tipos personalizados.

Resumen

¡Felicitaciones! Has completado el laboratorio de Concurrencia de Estado Compartido. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.