Rust

Rust

Tutorial Rust: Memoria compartida segura

Aprende a gestionar memoria compartida segura en Rust usando Arc, Mutex y los traits Send y Sync para programación concurrente eficiente.

Aprende Rust y certifícate

Arc para referencias

Cuando trabajamos con concurrencia en Rust, uno de los desafíos principales es compartir datos entre múltiples hilos de ejecución. El sistema de propiedad y préstamo de Rust garantiza la seguridad de memoria, pero también impone restricciones importantes cuando queremos que varios hilos accedan a los mismos datos.

El tipo Arc<T> (Atomic Reference Counted) es una estructura fundamental para compartir datos de forma segura entre hilos. Antes de profundizar en Arc, es importante entender por qué necesitamos una solución específica para este problema.

El problema de la propiedad compartida entre hilos

En Rust, cada valor tiene un único propietario. Cuando pasamos un valor a un hilo, la propiedad se transfiere:

use std::thread;

fn main() {
    let data = vec![1, 2, 3];
    
    let handle = thread::spawn(move || {
        // Este hilo toma propiedad de data
        println!("El vector contiene: {:?}", data);
    });
    
    // Error: no podemos usar data aquí porque su propiedad se movió al hilo
    // println!("El vector contiene: {:?}", data);
    
    handle.join().unwrap();
}

¿Qué ocurre si queremos que múltiples hilos accedan al mismo dato? Podríamos pensar en usar Rc<T> (Reference Counted), que permite múltiples propietarios de un valor:

use std::rc::Rc;
use std::thread;

fn main() {
    let data = Rc::new(vec![1, 2, 3]);
    let data_clone = data.clone();
    
    let handle = thread::spawn(move || {
        // Este código NO compila
        println!("El vector en el hilo contiene: {:?}", data_clone);
    });
    
    println!("El vector en el hilo principal contiene: {:?}", data);
    
    handle.join().unwrap();
}

Este código no compila porque Rc<T> no es seguro para usar entre hilos. El contador de referencias en Rc<T> no está protegido contra accesos concurrentes, lo que podría llevar a condiciones de carrera y comportamiento indefinido.

Arc: Conteo de referencias atómico

Aquí es donde entra Arc<T> (Atomic Reference Counted). Arc funciona de manera similar a Rc, pero utiliza operaciones atómicas para actualizar su contador de referencias, lo que garantiza la seguridad entre hilos:

use std::sync::Arc;
use std::thread;

fn main() {
    // Creamos un Arc que contiene nuestros datos
    let data = Arc::new(vec![1, 2, 3]);
    
    // Creamos múltiples hilos, cada uno con su propia referencia a los datos
    let mut handles = vec![];
    
    for i in 0..3 {
        // Clonamos el Arc (no los datos subyacentes)
        let data_clone = Arc::clone(&data);
        
        let handle = thread::spawn(move || {
            println!("Hilo {} accediendo a: {:?}", i, data_clone);
        });
        
        handles.push(handle);
    }
    
    // Esperamos a que todos los hilos terminen
    for handle in handles {
        handle.join().unwrap();
    }
    
    // El hilo principal aún tiene acceso a los datos
    println!("Hilo principal: {:?}", data);
}

Características clave de Arc

  • Inmutabilidad por defecto: Arc<T> proporciona acceso compartido pero inmutable a los datos. Para modificar los datos dentro de un Arc, necesitarás combinarlo con tipos como Mutex o RwLock.

  • Clonación eficiente: Cuando llamas a Arc::clone(), solo se clona el puntero y se incrementa el contador de referencias, no los datos subyacentes:

use std::sync::Arc;

fn main() {
    let original = Arc::new(String::from("Hola, mundo"));
    
    // Esto solo clona el Arc, no la cadena
    let copia = Arc::clone(&original);
    
    println!("¿Son iguales? {}", Arc::ptr_eq(&original, &copia)); // true
    println!("Contenido: {}", *copia);
}
  • Liberación automática: Cuando el último Arc que apunta a un valor se destruye, los datos subyacentes se liberan automáticamente:
use std::sync::Arc;

fn main() {
    let data = Arc::new(vec![1, 2, 3]);
    
    {
        let _data_ref1 = Arc::clone(&data);
        let _data_ref2 = Arc::clone(&data);
        
        println!("Contador de referencias: {}", Arc::strong_count(&data)); // 3
    } // _data_ref1 y _data_ref2 salen del ámbito aquí
    
    println!("Contador de referencias: {}", Arc::strong_count(&data)); // 1
}

Caso de uso práctico: Caché compartida

Un caso de uso común para Arc es implementar una caché compartida entre hilos:

use std::sync::Arc;
use std::thread;
use std::collections::HashMap;

fn main() {
    // Creamos una caché compartida (inmutable)
    let cache = Arc::new(HashMap::from([
        ("clave1", "valor1"),
        ("clave2", "valor2"),
        ("clave3", "valor3"),
    ]));
    
    let mut handles = vec![];
    
    // Múltiples hilos consultan la caché
    for i in 0..5 {
        let cache_ref = Arc::clone(&cache);
        let key = format!("clave{}", (i % 3) + 1);
        
        let handle = thread::spawn(move || {
            match cache_ref.get(key.as_str()) {
                Some(valor) => println!("Hilo {} encontró: {} = {}", i, key, valor),
                None => println!("Hilo {} no encontró: {}", i, key),
            }
        });
        
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

Limitaciones de Arc

Es importante entender que Arc por sí solo tiene algunas limitaciones:

  • Solo proporciona acceso inmutable: No puedes modificar directamente el contenido de un Arc<T> a menos que T proporcione algún tipo de mutabilidad interior (como Cell o RefCell).

  • No es adecuado para todos los tipos: Algunos tipos no son seguros para compartir entre hilos (no implementan el trait Sync), por lo que no pueden ser envueltos directamente en un Arc.

  • Overhead de rendimiento: Las operaciones atómicas son más costosas que las operaciones normales, por lo que Arc tiene un pequeño costo de rendimiento comparado con Rc.

Cuándo usar Arc

Deberías considerar usar Arc cuando:

  • Necesitas compartir datos entre múltiples hilos
  • Los datos son inmutables o utilizarás otro mecanismo para gestionar la mutabilidad (como Mutex)
  • Necesitas que los datos persistan mientras cualquier hilo los esté utilizando
  • No puedes determinar estáticamente cuál será el último hilo en usar los datos
use std::sync::Arc;
use std::thread;
use std::time::Duration;

fn main() {
    let datos_compartidos = Arc::new(vec![10, 20, 30, 40, 50]);
    
    let mut handles = vec![];
    
    // Creamos hilos con diferentes tiempos de vida
    for i in 0..3 {
        let datos = Arc::clone(&datos_compartidos);
        
        let handle = thread::spawn(move || {
            // Simulamos diferentes duraciones de procesamiento
            thread::sleep(Duration::from_millis(i * 100));
            
            let suma: i32 = datos.iter().sum();
            println!("Hilo {}: La suma es {}", i, suma);
        });
        
        handles.push(handle);
    }
    
    // El hilo principal puede seguir usando los datos
    println!("Longitud en el hilo principal: {}", datos_compartidos.len());
    
    for handle in handles {
        handle.join().unwrap();
    }
}

Arc es una herramienta fundamental para la programación concurrente en Rust, pero para datos que necesitan ser modificados por múltiples hilos, necesitaremos combinarlo con otras estructuras como Mutex, que veremos en la siguiente sección.

Arc + Mutex pattern

En la sección anterior vimos cómo Arc nos permite compartir datos inmutables entre hilos. Sin embargo, en aplicaciones reales a menudo necesitamos compartir estado mutable entre múltiples hilos. Para esto, Rust ofrece un patrón idiomático que combina Arc con Mutex: el patrón Arc<Mutex<T>>.

Este patrón resuelve uno de los desafíos fundamentales de la programación concurrente: permitir que múltiples hilos modifiquen de forma segura un estado compartido sin causar condiciones de carrera o corrupción de datos.

El problema de la mutabilidad compartida

Intentar modificar datos compartidos entre hilos sin sincronización adecuada puede llevar a resultados impredecibles:

// Este código NO compila
use std::sync::Arc;
use std::thread;

fn main() {
    let contador = Arc::new(0); // Intentamos compartir un entero
    
    let mut handles = vec![];
    
    for _ in 0..10 {
        let contador_ref = Arc::clone(&contador);
        
        let handle = thread::spawn(move || {
            // Error: no podemos modificar a través de una referencia compartida
            *contador_ref += 1;
        });
        
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Contador final: {}", *contador);
}

Este código falla por dos razones:

  1. Arc solo proporciona acceso inmutable a su contenido
  2. Incluso si pudiéramos modificarlo, tendríamos accesos concurrentes no sincronizados

Mutex: exclusión mutua

Un Mutex (exclusión mutua) es una estructura de sincronización que garantiza que solo un hilo a la vez puede acceder a los datos protegidos. Funciona como un candado que un hilo debe adquirir antes de modificar los datos:

use std::sync::Mutex;

fn main() {
    let contador = Mutex::new(0);
    
    // Bloqueamos el mutex para obtener acceso exclusivo
    {
        let mut valor = contador.lock().unwrap();
        *valor += 1;
    } // El bloqueo se libera automáticamente aquí
    
    println!("Contador: {}", *contador.lock().unwrap());
}

Características importantes de Mutex:

  • Proporciona acceso exclusivo a los datos
  • El método lock() devuelve un MutexGuard que implementa Deref y DerefMut
  • El MutexGuard libera automáticamente el bloqueo cuando sale del ámbito
  • Si un hilo intenta bloquear un Mutex ya bloqueado, se bloqueará hasta que el mutex esté disponible

Combinando Arc y Mutex

Para compartir un estado mutable entre hilos, combinamos Arc (para compartir entre hilos) y Mutex (para acceso exclusivo):

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Creamos un contador compartido
    let contador = Arc::new(Mutex::new(0));
    
    let mut handles = vec![];
    
    // Lanzamos 10 hilos que incrementarán el contador
    for _ in 0..10 {
        let contador_ref = Arc::clone(&contador);
        
        let handle = thread::spawn(move || {
            // Bloqueamos el mutex para obtener acceso exclusivo
            let mut valor = contador_ref.lock().unwrap();
            *valor += 1;
            // El bloqueo se libera automáticamente cuando valor sale del ámbito
        });
        
        handles.push(handle);
    }
    
    // Esperamos a que todos los hilos terminen
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Contador final: {}", *contador.lock().unwrap());
}

En este ejemplo:

  1. Arc permite que múltiples hilos tengan una referencia al Mutex
  2. Mutex garantiza que solo un hilo a la vez puede modificar el valor
  3. Cada hilo bloquea el mutex, incrementa el contador y libera el bloqueo

Manejo de errores con Mutex

El método lock() devuelve un Result porque el bloqueo puede fallar si el hilo que posee el bloqueo ha entrado en pánico:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let datos = Arc::new(Mutex::new(vec![1, 2, 3]));
    
    let datos_clone = Arc::clone(&datos);
    let handle = thread::spawn(move || {
        // Manejo adecuado de errores
        match datos_clone.lock() {
            Ok(mut vector) => vector.push(4),
            Err(e) => println!("Error al bloquear el mutex: {}", e),
        }
    });
    
    handle.join().unwrap();
    
    // Usando unwrap() cuando estamos seguros de que no habrá error
    let vector = datos.lock().unwrap();
    println!("Vector final: {:?}", *vector);
}

En código de producción, es importante manejar estos errores adecuadamente, aunque en ejemplos simples a menudo se usa unwrap() por brevedad.

Caso práctico: Caché mutable compartida

Veamos un ejemplo más completo: una caché compartida que puede ser actualizada por múltiples hilos:

use std::sync::{Arc, Mutex};
use std::thread;
use std::collections::HashMap;

fn main() {
    // Creamos una caché compartida y mutable
    let cache = Arc::new(Mutex::new(HashMap::new()));
    
    let mut handles = vec![];
    
    // Varios hilos actualizan la caché
    for i in 0..5 {
        let cache_ref = Arc::clone(&cache);
        
        let handle = thread::spawn(move || {
            // Cada hilo agrega una entrada a la caché
            let clave = format!("clave{}", i);
            let valor = format!("valor-desde-hilo-{}", i);
            
            let mut cache_guard = cache_ref.lock().unwrap();
            cache_guard.insert(clave.clone(), valor.clone());
            println!("Hilo {} insertó: {} = {}", i, clave, valor);
        });
        
        handles.push(handle);
    }
    
    // Esperamos a que todos los hilos terminen
    for handle in handles {
        handle.join().unwrap();
    }
    
    // Mostramos el contenido final de la caché
    let cache_final = cache.lock().unwrap();
    println!("\nContenido final de la caché:");
    for (clave, valor) in cache_final.iter() {
        println!("{} = {}", clave, valor);
    }
}

Consideraciones de rendimiento

El patrón Arc<Mutex<T>> es muy útil, pero tiene algunas implicaciones de rendimiento:

  • Contención: Si muchos hilos intentan acceder al mismo Mutex simultáneamente, pueden pasar mucho tiempo esperando
  • Granularidad: Un Mutex de grano grueso (que protege muchos datos) puede causar más contención que varios Mutex de grano fino

Ejemplo de mejora de granularidad:

use std::sync::{Arc, Mutex};
use std::thread;
use std::collections::HashMap;

// En lugar de un único mutex para todo el HashMap
// let cache = Arc::new(Mutex::new(HashMap::new()));

// Podemos usar un HashMap de mutexes para reducir la contención
fn main() {
    let mut cache = HashMap::new();
    for i in 0..5 {
        let clave = format!("clave{}", i);
        cache.insert(clave, Arc::new(Mutex::new(String::new())));
    }
    let cache = Arc::new(cache);
    
    let mut handles = vec![];
    
    for i in 0..10 {
        let cache_ref = Arc::clone(&cache);
        
        let handle = thread::spawn(move || {
            // Cada hilo actualiza solo una entrada específica
            let clave = format!("clave{}", i % 5);
            
            if let Some(valor_mutex) = cache_ref.get(&clave) {
                let mut valor = valor_mutex.lock().unwrap();
                *valor = format!("actualizado-por-hilo-{}", i);
                println!("Hilo {} actualizó: {}", i, clave);
            }
        });
        
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("\nEstado final de la caché:");
    for (clave, valor_mutex) in cache.iter() {
        let valor = valor_mutex.lock().unwrap();
        println!("{} = {}", clave, valor);
    }
}

Evitando deadlocks

Un deadlock (interbloqueo) ocurre cuando dos o más hilos se bloquean mutuamente esperando recursos. Al usar múltiples Mutex, debemos tener cuidado con el orden en que los bloqueamos:

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    let recurso_a = Arc::new(Mutex::new(String::from("Recurso A")));
    let recurso_b = Arc::new(Mutex::new(String::from("Recurso B")));
    
    let recurso_a_clone = Arc::clone(&recurso_a);
    let recurso_b_clone = Arc::clone(&recurso_b);
    
    // Hilo 1: bloquea A, luego B
    let hilo_1 = thread::spawn(move || {
        let mut a = recurso_a_clone.lock().unwrap();
        println!("Hilo 1: Bloqueó recurso A");
        
        // Pequeña pausa para aumentar la probabilidad de deadlock
        thread::sleep(Duration::from_millis(10));
        
        let mut b = recurso_b_clone.lock().unwrap();
        println!("Hilo 1: Bloqueó recurso B");
        
        // Modificamos ambos recursos
        a.push_str(" (modificado por hilo 1)");
        b.push_str(" (modificado por hilo 1)");
    });
    
    // Hilo 2: bloquea B, luego A - ¡Potencial deadlock!
    // Para evitar deadlocks, ambos hilos deberían bloquear los recursos
    // en el mismo orden (primero A, luego B)
    let hilo_2 = thread::spawn(move || {
        let b = recurso_b.lock().unwrap();
        println!("Hilo 2: Bloqueó recurso B");
        
        thread::sleep(Duration::from_millis(10));
        
        let a = recurso_a.lock().unwrap();
        println!("Hilo 2: Bloqueó recurso A");
        
        // Este código podría no ejecutarse si ocurre un deadlock
        println!("Recursos: '{}', '{}'", a, b);
    });
    
    // Este join podría quedarse esperando indefinidamente si hay deadlock
    hilo_1.join().unwrap();
    hilo_2.join().unwrap();
}

Para evitar deadlocks, sigue estas buenas prácticas:

  • Bloquea múltiples mutexes siempre en el mismo orden
  • Minimiza el tiempo que mantienes los bloqueos
  • Considera usar try_lock() con reintentos en lugar de lock()
  • Diseña tu código para evitar la necesidad de bloqueos anidados

Alternativas a Mutex

Dependiendo de tus necesidades, puedes considerar estas alternativas:

  • RwLock: Permite múltiples lectores simultáneos o un único escritor
  • Atomic types: Para operaciones simples como contadores, los tipos atómicos son más eficientes
  • Canales: A veces, rediseñar usando canales puede eliminar la necesidad de estado compartido
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let datos = Arc::new(RwLock::new(vec![1, 2, 3]));
    
    let mut handles = vec![];
    
    // Múltiples lectores concurrentes
    for i in 0..3 {
        let datos_ref = Arc::clone(&datos);
        
        let handle = thread::spawn(move || {
            // Obtenemos un bloqueo de lectura (múltiples lectores permitidos)
            let datos = datos_ref.read().unwrap();
            println!("Hilo lector {}: datos = {:?}", i, *datos);
        });
        
        handles.push(handle);
    }
    
    // Un único escritor
    let datos_ref = Arc::clone(&datos);
    let handle = thread::spawn(move || {
        // Obtenemos un bloqueo de escritura (exclusivo)
        let mut datos = datos_ref.write().unwrap();
        datos.push(4);
        println!("Hilo escritor: datos actualizados = {:?}", *datos);
    });
    
    handles.push(handle);
    
    for handle in handles {
        handle.join().unwrap();
    }
}

El patrón Arc<Mutex<T>> es una herramienta fundamental en Rust para compartir estado mutable entre hilos de forma segura. Dominar este patrón te permitirá implementar estructuras de datos concurrentes complejas manteniendo las garantías de seguridad que ofrece Rust.

Send y Sync traits

En el núcleo del sistema de concurrencia de Rust se encuentran dos traits especiales que garantizan la seguridad entre hilos: Send y Sync. Estos traits son fundamentales para entender cómo Rust puede ofrecer garantías de seguridad en memoria durante la programación concurrente sin necesidad de un recolector de basura.

A diferencia de la mayoría de los traits en Rust, Send y Sync son marker traits (traits marcadores), lo que significa que no definen métodos que deban implementarse, sino que actúan como indicadores para el compilador sobre las propiedades de seguridad de un tipo.

El trait Send

Un tipo implementa Send cuando es seguro transferir la propiedad de valores de ese tipo entre hilos. En términos simples, si un tipo es Send, puedes moverlo de un hilo a otro sin causar problemas de seguridad:

use std::thread;

// Los tipos primitivos como i32 son Send
fn main() {
    let numero = 42;
    
    let handle = thread::spawn(move || {
        // Podemos mover 'numero' al nuevo hilo porque i32 es Send
        println!("El número en el hilo es: {}", numero);
    });
    
    handle.join().unwrap();
}

La mayoría de los tipos en Rust son Send por defecto, incluyendo:

  • Tipos primitivos (i32, bool, etc.)
  • Estructuras y enumeraciones compuestas únicamente de tipos Send
  • Punteros únicos como Box<T>, Vec<T> y String (si T es Send)
  • Tipos de sincronización como Mutex<T> y Arc<T> (si T es Send)

Sin embargo, algunos tipos no son Send:

use std::rc::Rc;
use std::thread;

fn main() {
    let datos = Rc::new(vec![1, 2, 3]);
    
    // Este código NO compila
    let handle = thread::spawn(move || {
        // Error: Rc<Vec<i32>> no implementa Send
        println!("Datos en el hilo: {:?}", datos);
    });
    
    handle.join().unwrap();
}

El tipo Rc<T> no es Send porque su contador de referencias no está protegido contra modificaciones concurrentes, lo que podría llevar a comportamientos indefinidos si se compartiera entre hilos.

El trait Sync

Un tipo es Sync cuando es seguro compartirlo entre hilos mediante referencias. Técnicamente, un tipo T es Sync si y solo si &T es Send. Esto significa que múltiples hilos pueden acceder al mismo valor simultáneamente a través de referencias inmutables sin causar condiciones de carrera:

use std::sync::Arc;
use std::thread;

fn main() {
    // Arc<Vec<i32>> es Sync porque &Vec<i32> es Send
    let datos = Arc::new(vec![1, 2, 3]);
    
    let mut handles = vec![];
    
    for i in 0..3 {
        let datos_ref = Arc::clone(&datos);
        
        let handle = thread::spawn(move || {
            // Múltiples hilos pueden acceder a los datos inmutables
            println!("Hilo {} accediendo a: {:?}", i, datos_ref);
        });
        
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

Al igual que con Send, la mayoría de los tipos en Rust son Sync por defecto, incluyendo:

  • Tipos primitivos
  • Estructuras y enumeraciones compuestas únicamente de tipos Sync
  • Tipos inmutables como &T (si T es Sync)
  • Tipos de sincronización como Mutex<T> y RwLock<T> (si T es Sync)

Ejemplos de tipos que no son Sync:

  • Cell<T> y RefCell<T> (proporcionan mutabilidad interior sin sincronización)
  • Rc<T> (su contador de referencias no es seguro para acceso concurrente)

Relación entre Send y Sync

Existe una relación importante entre estos dos traits:

  • Si T es Sync, entonces &T es Send
  • Si T es Send y Copy, entonces T es Sync

Sin embargo, un tipo puede ser Send pero no Sync, o viceversa (aunque esto último es menos común).

use std::cell::Cell;
use std::sync::Arc;

// Cell<i32> es Send pero no Sync
fn ejemplo_send_no_sync() {
    let celda = Cell::new(42);
    
    // Podemos mover la celda a otro hilo (es Send)
    std::thread::spawn(move || {
        celda.set(10);
    });
    
    // Pero no podemos compartir &Cell entre hilos (no es Sync)
}

// Ejemplo teórico de un tipo que podría ser Sync pero no Send
struct ContenedorExternoNoMovible<T>(T);
// (Este es un ejemplo conceptual, en la práctica es difícil crear un tipo
// que sea Sync pero no Send sin usar características inseguras)

Cómo Rust utiliza Send y Sync

El compilador de Rust utiliza estos traits para garantizar la seguridad en concurrencia de varias maneras:

  1. Verificación en tiempo de compilación: El compilador verifica que solo los tipos Send se transfieran entre hilos:
use std::thread;
use std::rc::Rc;

fn main() {
    let datos = Rc::new(42);
    
    // Error de compilación: Rc<i32> no implementa Send
    let handle = thread::spawn(move || {
        println!("Valor: {}", *datos);
    });
    
    handle.join().unwrap();
}
  1. Inferencia automática: Rust infiere automáticamente si un tipo personalizado es Send o Sync basándose en sus componentes:
struct MiEstructura {
    campo1: String,        // Send + Sync
    campo2: Vec<i32>,      // Send + Sync
}
// MiEstructura es automáticamente Send + Sync

struct EstructuraNoSync {
    campo1: String,        // Send + Sync
    campo2: std::cell::Cell<i32>,  // Send pero no Sync
}
// EstructuraNoSync es automáticamente Send pero no Sync
  1. Restricciones en funciones genéricas: Puedes requerir que los tipos genéricos implementen Send o Sync:
use std::thread;

// Esta función requiere que T sea Send
fn procesar_en_hilo<T: Send + 'static>(valor: T) {
    thread::spawn(move || {
        // Procesamiento seguro en otro hilo
        println!("Procesando en hilo separado");
    });
}

fn main() {
    procesar_en_hilo(String::from("Hola"));  // Funciona: String es Send
    // procesar_en_hilo(std::rc::Rc::new(5));  // Error: Rc<i32> no es Send
}

Implementación manual de Send y Sync

Normalmente, no necesitas implementar manualmente estos traits, ya que Rust los deriva automáticamente. Sin embargo, hay situaciones donde podrías necesitar implementarlos o inhibirlos explícitamente:

use std::marker::{Send, Sync};

// Forzar que un tipo sea Send y Sync (¡peligroso si no es seguro!)
struct MiTipo {
    // campos...
}

// Implementación manual (requiere unsafe porque estamos haciendo una promesa al compilador)
unsafe impl Send for MiTipo {}
unsafe impl Sync for MiTipo {}

// O inhibir Send/Sync para un tipo que normalmente los implementaría
struct TipoNoThreadSafe<T>(T);

// Evitamos que se derive automáticamente
impl<T> !Send for TipoNoThreadSafe<T> {}
impl<T> !Sync for TipoNoThreadSafe<T> {}

Estas implementaciones manuales son poco comunes y generalmente solo se necesitan cuando:

  • Estás implementando tipos que contienen punteros crudos
  • Estás creando abstracciones de bajo nivel que gestionan su propia sincronización
  • Estás integrando con código no seguro o bibliotecas externas

Caso práctico: Implementación de un contador compartido

Veamos cómo Send y Sync influyen en el diseño de un contador compartido entre hilos:

use std::sync::{Arc, Mutex};
use std::thread;

// Esta estructura es Send + Sync gracias a Arc y Mutex
struct ContadorCompartido {
    valor: Arc<Mutex<i64>>,
}

impl ContadorCompartido {
    fn new(inicial: i64) -> Self {
        ContadorCompartido {
            valor: Arc::new(Mutex::new(inicial)),
        }
    }
    
    fn incrementar(&self) {
        let mut valor = self.valor.lock().unwrap();
        *valor += 1;
    }
    
    fn obtener(&self) -> i64 {
        *self.valor.lock().unwrap()
    }
    
    // Esta función requiere que Self sea Clone + Send + 'static
    fn incrementar_en_hilo(&self) -> thread::JoinHandle<()> {
        let contador_clone = self.clone();
        
        thread::spawn(move || {
            contador_clone.incrementar();
        })
    }
}

// Implementamos Clone manualmente
impl Clone for ContadorCompartido {
    fn clone(&self) -> Self {
        ContadorCompartido {
            valor: Arc::clone(&self.valor),
        }
    }
}

fn main() {
    let contador = ContadorCompartido::new(0);
    
    let mut handles = vec![];
    
    // Lanzamos 10 hilos que incrementan el contador
    for _ in 0..10 {
        handles.push(contador.incrementar_en_hilo());
    }
    
    // Esperamos a que todos los hilos terminen
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Valor final del contador: {}", contador.obtener());
}

En este ejemplo:

  • ContadorCompartido es Send porque contiene un Arc<Mutex<i64>>, que es Send
  • ContadorCompartido es Sync porque Arc<Mutex<i64>> es Sync
  • La función incrementar_en_hilo puede mover un clon del contador a un nuevo hilo porque el contador es Send

Implicaciones prácticas de Send y Sync

Entender Send y Sync te ayuda a:

  • Diseñar APIs thread-safe: Al crear bibliotecas, puedes garantizar que tus tipos sean seguros para usar en entornos concurrentes.

  • Identificar problemas potenciales: Si el compilador se queja de que un tipo no es Send o Sync, es una señal de que podrías tener un problema de concurrencia.

  • Optimizar rendimiento: A veces, puedes elegir tipos que no son thread-safe (como RefCell en lugar de Mutex) cuando sabes que el código solo se ejecutará en un único hilo.

use std::cell::RefCell;
use std::sync::Mutex;

// En código de un solo hilo, RefCell es más eficiente
fn codigo_single_thread() {
    let datos = RefCell::new(vec![1, 2, 3]);
    
    // Mutabilidad interior sin overhead de sincronización
    datos.borrow_mut().push(4);
    
    println!("Datos: {:?}", datos.borrow());
}

// En código multi-hilo, necesitamos Mutex
fn codigo_multi_thread() {
    let datos = Mutex::new(vec![1, 2, 3]);
    
    std::thread::spawn(move || {
        datos.lock().unwrap().push(4);
    }).join().unwrap();
}

Buenas prácticas con Send y Sync

Para escribir código concurrente seguro y eficiente:

  • Prefiere tipos inmutables cuando sea posible, ya que son inherentemente thread-safe
  • Usa tipos específicos para concurrencia (Arc, Mutex, RwLock, etc.) en lugar de implementar manualmente sincronización
  • Minimiza el alcance de los bloqueos para reducir contención
  • Considera la granularidad de la sincronización (múltiples mutexes pequeños vs. uno grande)
  • Verifica en tiempo de compilación en lugar de en tiempo de ejecución cuando sea posible

Los traits Send y Sync son la base del sistema de concurrencia de Rust, permitiéndole ofrecer garantías de seguridad en tiempo de compilación que otros lenguajes solo pueden verificar en tiempo de ejecución o, peor aún, dejar como responsabilidad del programador.

Aprende Rust online

Otras lecciones de Rust

Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Rust y certifícate

Ejercicios de programación de Rust

Evalúa tus conocimientos de esta lección Memoria compartida segura con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender el problema de compartir datos entre múltiples hilos en Rust y las limitaciones del sistema de propiedad.
  • Aprender a usar Arc para compartir datos inmutables de forma segura entre hilos.
  • Entender cómo combinar Arc con Mutex para permitir acceso mutable seguro a datos compartidos.
  • Conocer los traits Send y Sync y su papel en la seguridad de concurrencia en Rust.
  • Identificar buenas prácticas y limitaciones en el uso de Arc, Mutex y traits para evitar condiciones de carrera y deadlocks.