La tabla clave: Rc, Arc, RefCell y Mutex
Cuatro tipos que se emparejan de dos en dos condensan toda la teoría de Send y Sync. Rc frente a Arc, RefCell frente a Mutex: cada par ofrece la misma capacidad, uno sin proteger para un hilo y otro sincronizado para varios. La tabla no se memoriza, se deduce.
Cuatro tipos condensan toda la teoría de este nivel, y se ordenan en dos parejas perfectas. Rc frente a Arc resuelven la propiedad compartida; RefCell frente a Mutex resuelven la mutabilidad a través de una referencia. En cada pareja, el primer miembro es rápido y no está sincronizado —vale para un solo hilo— y el segundo paga el precio de la sincronización para funcionar entre varios. Dominar esta tabla es dominar Send y Sync en la práctica; y la buena noticia es que no hay que memorizarla, porque se deduce de lo ya visto.
- Contrastar
Rc(niSendniSync) conArc(ambos) y su contador atómico. - Contrastar
RefCell(Send, noSync) conMutex(ambos) y su candado. - Entender por qué
RefCellsí esSendaunque no seaSync. - Leer un error del compilador como el rechazo a compartir lo inseguro.
Las dos parejas
Cada pareja ofrece una capacidad idéntica con dos implementaciones: una sin coste de sincronización, restringida a un hilo, y otra sincronizada, apta para varios. La diferencia entera vive en Send y Sync.
| Tipo | Send | Sync | Mecanismo interno |
|---|---|---|---|
Rc<T> |
no | no | contador de referencias no atómico |
Arc<T> |
sí | sí | contador de referencias atómico |
RefCell<T> |
sí | no | contador de préstamos no atómico |
Mutex<T> |
sí | sí | candado atómico o del sistema |
Las condiciones exactas piden además que T sea a su vez Send o Sync; la tabla muestra el caso habitual, con un T bien portado. Léela en vertical y verás el patrón: pasar de la fila insegura a la segura es siempre “sustituir un entero normal por su versión atómica” o “añadir un candado”. La atomicidad es el peaje de la concurrencia, y estos tipos te dejan elegir si lo pagas.
Rc frente a Arc: el contador
Ya viste en la lección de Send por qué Rc no cruza fronteras: su contador es un entero corriente y dos hilos clonándolo o soltándolo lo corromperían. Arc —atomic reference counted— es idéntico en interfaz pero lleva el contador en un AtomicUsize. Incrementar y decrementar se vuelven operaciones atómicas, indivisibles ante cualquier entrelazado de hilos. Ese solo cambio lo convierte en Send + Sync.
use std::sync::Arc;
use std::thread;
fn main() {
let compartido = Arc::new(vec![1, 2, 3]);
let mut handles = vec![];
for i in 0..3 {
let clon = Arc::clone(&compartido); // +1 atomico
handles.push(thread::spawn(move || {
println!("hilo {i}: {}", clon[i]);
}));
}
for h in handles {
h.join().unwrap();
}
}
El precio de Arc es real —una operación atómica cuesta más que un += 1 normal— y por eso Rust no te lo impone: en código de un solo hilo usas Rc y no pagas nada; cuando de verdad cruzas hilos, subes a Arc. El sistema de tipos te obliga a elegir bien, porque intentar enviar un Rc sencillamente no compila.
Arc<T> te da varios dueños de lectura, pero —igual que Rc— solo entrega &T, nunca &mut T. Por sí solo no deja mutar el dato compartido. Para mutar entre hilos necesitas combinarlo con la otra pareja: Arc<Mutex<T>>. El Arc reparte la propiedad entre hilos; el Mutex concede el acceso exclusivo para mutar. Es el patrón más reconocible de la concurrencia en Rust, y nace de multiplicar las dos columnas de la tabla.
El patrón combinado se lee así: el Arc lleva la propiedad a cada hilo, el Mutex serializa la mutación.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let total = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let t = Arc::clone(&total); // comparte la propiedad entre hilos
handles.push(thread::spawn(move || {
*t.lock().unwrap() += 1; // muta en exclusiva bajo candado
}));
}
for h in handles {
h.join().unwrap();
}
println!("total = {}", *total.lock().unwrap()); // 4
}
RefCell frente a Mutex, y por qué RefCell sí es Send
RefCell y Mutex gestionan lo mismo —mutabilidad interior— con la misma diferencia de siempre: contador de préstamos normal frente a candado atómico. De ahí que RefCell sea !Sync y Mutex sea Sync, como ya dedujiste. Pero queda un matiz revelador: RefCell sí es Send. ¿Cómo puede ser seguro de mover pero no de compartir?
La respuesta está en la diferencia entre las dos preguntas. Mover un RefCell a otro hilo transfiere su propiedad entera: el hilo de origen lo pierde, y solo el hilo de destino lo usa. Con un único hilo tocándolo en cada momento, su contador de préstamos no atómico nunca sufre accesos simultáneos, y todo es seguro. Lo que rompe RefCell es compartirlo —dos hilos con &RefCell a la vez—, no mudarlo. De ahí el veredicto exacto: Send sí, Sync no.
use std::cell::RefCell;
use std::thread;
fn main() {
let celda = RefCell::new(0);
// Mover el RefCell entero a otro hilo: OK, RefCell es Send
let h = thread::spawn(move || {
*celda.borrow_mut() += 1; // un solo hilo lo posee: seguro
celda.into_inner()
});
println!("{}", h.join().unwrap());
}
El compilador rechazando lo inseguro
Cuando pides lo imposible, el mensaje del compilador es una demostración en miniatura. Intenta compartir un RefCell entre hilos con ámbito:
use std::cell::RefCell;
use std::thread;
fn main() {
let celda = RefCell::new(0);
thread::scope(|s| {
s.spawn(|| *celda.borrow_mut() += 1);
s.spawn(|| *celda.borrow_mut() += 1); // ERROR
});
}
El compilador responde, en esencia:
error[E0277]: `RefCell<i32>` cannot be shared between threads safely
= help: the trait `Sync` is not implemented for `RefCell<i32>`
= note: required because it is used within a closure sent between threads
Lee la cadena de razonamiento: para compartir &celda entre hilos, RefCell<i32> tendría que ser Sync; no lo es; por tanto, rechazado. No es un capricho del linter: es el compilador negándose a construir un programa cuya seguridad no puede demostrar. La solución es subir de fila en la tabla: cambia RefCell por Mutex y el mismo código compila, porque Mutex sí aporta la sincronización que faltaba.
flowchart TD need[Que necesitas] --> share[Propiedad compartida] need --> mutate[Mutar tras una ref compartida] share --> one1[Un hilo, Rc] share --> many1[Varios hilos, Arc] mutate --> one2[Un hilo, RefCell] mutate --> many2[Varios hilos, Mutex] many1 -. combinar .-> combo[Arc de Mutex de T] many2 -. combinar .-> combo style one1 fill:#fab387,color:#11111b style one2 fill:#fab387,color:#11111b style many1 fill:#a6e3a1,color:#11111b style many2 fill:#a6e3a1,color:#11111b style combo fill:#cba6f7,color:#11111b
Quien memoriza esta tabla la olvida; quien la deduce no la necesita. Los cuatro tipos son el producto cartesiano de dos preguntas independientes. Eje uno: ¿cuántos dueños necesito, uno o varios? Eje dos: ¿cuántos hilos van a tocar esto, uno o varios? El primer eje elige entre propiedad simple y propiedad compartida, Rc frente a Arc; el segundo decide si el mecanismo interno —el contador, el candado— debe ser atómico. Rc y RefCell son las versiones “un hilo, sin peaje”; Arc y Mutex son sus gemelos “varios hilos, con atomicidad”. Y como los dos ejes son ortogonales, se multiplican sin conflicto en el patrón Arc<Mutex<T>>, que responde “varios dueños” y “varios hilos que mutan” a la vez. Lo verdaderamente elegante es que tú no eliges los valores de Send y Sync: eliges el tipo según lo que necesitas, y Send y Sync se siguen como consecuencia, guardando la puerta para que nunca uses la versión barata donde hacía falta la cara. La tabla es, en el fondo, el sistema de tipos cobrando el peaje de la atomicidad solo cuando el paralelismo lo exige, ni antes ni después.
- Reconstruye la tabla de los cuatro tipos sin mirarla, justificando cada
síy cadanoa partir del mecanismo interno. - Explica en dos frases por qué
RefCellesSendpero noSync, y por quéRcno es ninguno de los dos. - Escribe un
Arc<Mutex<i32>>compartido entre cuatro hilos que lo incrementen, y razona qué papel juega cada mitad del tipo. - Provoca el error de compartir un
RefCellentre hilos y traduce el mensaje E0277 a la cadena lógica que lo genera. - Justifica por qué no existe un tipo “
Arcsin atomicidad para varios hilos”: qué garantía se rompería.