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.
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.
- Definir
Synccomo la seguridad de compartir&Tentre varios hilos. - Demostrar la equivalencia
T: Syncsi y solo si&T: Send. - Entender por qué
Mutex<T>esSyncyRefCell<T>no lo es. - Identificar
UnsafeCellcomo el único tipo!Syncprimitivo, 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 interior —Cell, 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?—.
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.
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.
- Usa
thread::scopepara que dos hilos lean en paralelo un&Vec<i32>y explica por qué compila sinMutex. - Intenta compartir un
&RefCell<i32>entre dos hilos conthread::scopey lee el error: localiza la frase “cannot be shared between threads”. - Envuelve ese dato en un
Mutexen vez de unRefCelly comprueba que ahora compila. Explica qué añade elMutexque faltaba. - Demuestra en una frase la equivalencia
T: Syncsi y solo si&T: Sendaplicándola aRefCell. - Investiga por qué
Cell<i32>esSendpero noSync, y conecta tu respuesta conUnsafeCell.