wandres.dev
SEND Y SYNC · la seguridad entre hilos

Sync: compartir una referencia entre hilos

Un tipo es Sync si es seguro que varios hilos compartan una referencia &T a la vez. La definición formal es exacta: T es Sync si y solo si &T es Send. Mutex es Sync porque sincroniza; RefCell no lo es porque su mutabilidad interior no está protegida.

⏱ 18 min

Si Send gobierna el mover, Sync gobierna el compartir. Un tipo es Sync cuando es seguro que varios hilos posean a la vez una referencia compartida &T al mismo valor y lo lean en paralelo. Su definición formal es de una elegancia desnuda —T es Sync si y solo si &T es Send— y de ella se deduce todo lo demás: por qué un Mutex puede compartirse entre hilos y un RefCell, que tanto se le parece dentro de un mismo hilo, no puede.

🎯 Al terminar esta lección sabrás
  • Definir Sync como la seguridad de compartir &T entre varios hilos.
  • Demostrar la equivalencia T: Sync si y solo si &T: Send.
  • Entender por qué Mutex<T> es Sync y RefCell<T> no lo es.
  • Identificar UnsafeCell como el único tipo !Sync primitivo, la raíz de todo.

Compartir no es mover

La referencia compartida &T es, por diseño, copiable: si un hilo tiene un &T, puede haber muchos &T al mismo valor circulando a la vez. Dentro de un solo hilo eso es inofensivo, porque &T solo concede lectura. Pero en varios hilos aparece una sutileza: que la referencia sea de solo lectura no significa que el valor apuntado sea inmutable. La mutabilidad interiorCell, RefCell, Mutex, los átomos— permite mutar a través de un &T. Y si dos hilos mutan a través de dos &T al mismo dato sin sincronización, tenemos otra vez la condición de carrera. Sync es la garantía de que ese escenario es imposible para el tipo T.

La forma limpia de compartir un &T entre hilos son los hilos con ámbito, que permiten prestar datos de la pila del padre:

use std::thread;

fn main() {
    let tabla = vec![10, 20, 30]; // Vec<i32> es Sync
    thread::scope(|s| {
        s.spawn(|| println!("primero: {}", tabla[0]));
        s.spawn(|| println!("ultimo: {}", tabla[2]));
    }); // ambos hilos comparten &tabla; el scope garantiza que terminan aqui
}

Dos hilos sostienen a la vez un &Vec<i32> al mismo vector. Compila porque Vec<i32> es Sync: como nadie puede mutar a través de un &Vec, las lecturas paralelas no compiten por nada.

La equivalencia que lo define todo

La definición de Sync no es una lista de casos, sino una sola bicondición:

// Conceptualmente, en la biblioteca estandar:
unsafe impl<T: Sync + ?Sized> Send for &T {}

En palabras: T es Sync si y solo si &T es Send. Compartir &T entre dos hilos es, literalmente, enviar un &T de un hilo a otro; y enviar algo es la pregunta de Send. Así que “es seguro compartir T” y “es seguro enviar &T” son la misma afirmación escrita de dos maneras. Esta reducción es lo que mantiene el sistema mínimo: Sync no es un concepto independiente, sino Send aplicado a las referencias.

De esta única ecuación cuelga todo el catálogo de la próxima lección. No hay que decidir caso por caso si un tipo es Sync: se calcula. i32 es Sync porque enviar un &i32 es inofensivo; RefCell no lo es porque enviar un &RefCell a otro hilo daría a dos hilos la capacidad de mutar el mismo dato sin orden. La pregunta difícil —¿es seguro compartir?— se reduce siempre a la pregunta ya resuelta —¿es seguro enviar la referencia?—.

ℹ️
La regla hermana para &mut T

Existe la afirmación paralela para las referencias exclusivas: &mut T es Send si y solo si T es Send. Tiene sentido: un &mut T es acceso exclusivo, casi un dueño temporal, así que enviarlo se rige por la misma pregunta que enviar el propio T. Entre las dos reglas —&T: Send pide T: Sync; &mut T: Send pide T: Send— queda cubierto todo cruce de referencias entre hilos.

Mutex sí, RefCell no

Aquí la teoría se vuelve tangible. RefCell<T> y Mutex<T> ofrecen lo mismo conceptualmente —mutar a través de una referencia compartida— pero pertenecen a mundos distintos.

RefCell<T> vigila la regla de préstamo en ejecución con un contador de préstamos que es un entero normal. borrow_mut comprueba ese contador, lo modifica y devuelve acceso exclusivo. Si dos hilos llamaran a borrow_mut a la vez sobre el mismo RefCell, esas comprobaciones se entrelazarían sin atomicidad: ambos podrían creerse con acceso exclusivo simultáneo, produciendo dos &mut vivos al mismo dato. Por eso RefCell<T> es !Sync: compartir &RefCell entre hilos es inseguro por construcción.

Mutex<T> resuelve el mismo problema con sincronización real: un candado respaldado por operaciones atómicas y, si hace falta, por el sistema operativo. Cuando un hilo llama a lock, o bien obtiene acceso exclusivo, o bien espera. Nunca hay dos accesos vivos. Por eso Mutex<T> sí es Sync, y puede compartirse entre hilos.

use std::sync::Mutex;
use std::thread;

fn main() {
    let contador = Mutex::new(0); // Mutex<i32> es Sync
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                let mut guard = contador.lock().unwrap();
                *guard += 1; // acceso exclusivo garantizado por el candado
            });
        }
    });
    println!("total = {}", contador.into_inner().unwrap());
}
flowchart TD
R[Compartir ref T entre hilos] --> M[Como se controla la mutacion interior]
M -->|contador no atomico| RC[RefCell no es Sync]
M -->|candado atomico| MX[Mutex es Sync]
RC --> BAD[Dos accesos exclusivos a la vez, carrera]
MX --> GOOD[Un acceso a la vez, seguro]
style RC fill:#f38ba8,color:#11111b
style MX fill:#a6e3a1,color:#11111b
style BAD fill:#f38ba8,color:#11111b
style GOOD fill:#a6e3a1,color:#11111b

UnsafeCell: la única grieta autorizada

Bajo Cell, RefCell, Mutex y todos los átomos late el mismo primitivo: UnsafeCell<T>. Es el único tipo de todo Rust que el compilador declara !Sync de fábrica, y por una razón profunda: UnsafeCell es la única vía legal para mutar a través de un &T. Es la puerta por la que la mutabilidad interior entra en el lenguaje, y por eso es también la puerta que, por defecto, se cierra a la compartición entre hilos. Todo tipo con mutabilidad interior contiene un UnsafeCell en su núcleo, hereda su !Sync, y solo recupera la condición Sync si —como Mutex o AtomicUsize— añade encima la sincronización que hace seguros los accesos concurrentes.

En la práctica nunca manipulas UnsafeCell a mano —lo hacen Mutex, RwLock y los átomos por ti— pero conocer su papel explica cada !Sync del ecosistema. Su forma resume el trato que ofrece:

// El unico agujero !Sync del lenguaje.
pub struct UnsafeCell<T: ?Sized> {
    value: T,
}
// Cualquier tipo que lo contenga pierde Sync, salvo que
// vuelva a demostrar por su cuenta la seguridad concurrente.

Cuando te encuentres un tipo !Sync, sigue el rastro hacia dentro: al final del camino siempre hay un UnsafeCell cuyo acceso concurrente nadie ha probado seguro.

Sync no es un axioma nuevo: es Send mirandose en un espejo

La belleza estructural de Sync es que casi no existe como concepto independiente. Podrías borrar Sync de tu cabeza y quedarte con una sola frase —“T es Sync cuando &T es Send”— y reconstruirlo entero. Esa economía revela cómo está diseñado el modelo de concurrencia de Rust: sobre un único primitivo, la seguridad de enviar, y una única reflexión de ese primitivo sobre las referencias. De ahí se deduce en cascada todo el catálogo. Mutex es Sync no por decreto, sino porque su candado hace que enviar un &Mutex a otro hilo sea seguro. RefCell no lo es porque enviar un &RefCell permitiría dos borrow_mut en carrera. Y ambos descansan sobre UnsafeCell, la única grieta que el lenguaje deja abierta en su muro de inmutabilidad, marcada con un !Sync que obliga a quien la use a volver a demostrar la seguridad que ha suspendido. No hay una regla para cada tipo: hay un principio, su reflejo y una raíz común. Cuando lo veas así, la tabla de la próxima lección no será una lista que memorizar, sino un teorema que ya sabes demostrar.

⚔️ Comparte con criterio
  1. Usa thread::scope para que dos hilos lean en paralelo un &Vec<i32> y explica por qué compila sin Mutex.
  2. Intenta compartir un &RefCell<i32> entre dos hilos con thread::scope y lee el error: localiza la frase “cannot be shared between threads”.
  3. Envuelve ese dato en un Mutex en vez de un RefCell y comprueba que ahora compila. Explica qué añade el Mutex que faltaba.
  4. Demuestra en una frase la equivalencia T: Sync si y solo si &T: Send aplicándola a RefCell.
  5. Investiga por qué Cell<i32> es Send pero no Sync, y conecta tu respuesta con UnsafeCell.