Arc y Mutex: el patrón canónico del estado mutable compartido
Un Mutex protege, pero no comparte. Para que varios hilos posean el mismo dato hace falta propiedad compartida y contada, y como Rc no cruza hilos, se usa Arc. El patrón Arc de Mutex de T combina dos capacidades ortogonales: Arc reparte la posesión, Mutex arbitra la mutación. El contador atómico canónico lo ilustra.
Mutex<T> resuelve quién puede mutar ahora, pero deja intacta otra pregunta: quién es el dueño del dato. Un Mutex tiene un solo propietario, y para lanzar un hilo con thread::spawn tienes que mover cosas dentro de su clausura: no puedes mover el mismo Mutex a diez hilos. Necesitas que diez hilos posean a la vez el mismo Mutex. Esa es propiedad compartida, y en Rust se expresa con recuento de referencias. Pero el Rc que ya conoces no sirve: su contador no es atómico y no cruza fronteras de hilo. Su hermano sincronizado sí: Arc. La combinación Arc<Mutex<T>> es el patrón más repetido de toda la concurrencia en Rust, y su elegancia está en que no es un tipo monolítico, sino dos capacidades ortogonales encajadas.
- Distinguir dos ejes independientes: compartir la posesión (
Arc) y arbitrar la mutación (Mutex). - Entender por qué
Rcno esSendyArcsí, y qué cuesta esa atomicidad. - Construir el patrón
Arc<Mutex<T>>y clonar elArchacia cada hilo conArc::clone. - Implementar el contador compartido canónico y razonar por qué el resultado es determinista.
Dos problemas distintos, dos tipos distintos
Compartir estado mutable entre hilos descompone en dos preguntas que la mayoría de lenguajes fusionan en un “objeto sincronizado” y que Rust mantiene separadas:
Arc: quién lo posee
Arc<T> (Atomically Reference Counted) da posesión compartida: cada clone incrementa un contador atómico y devuelve otro dueño del mismo dato en el heap. Cuando el último Arc muere, el dato se libera. Solo ofrece &T.
Mutex: quién lo muta ahora
Mutex<T> da mutación arbitrada: convierte un &T compartido en &mut T exclusivo mediante un turno. Es mutabilidad interior sincronizada. Pero por sí solo no puede repartirse entre hilos.
Ninguno de los dos basta por separado. Un Arc<T> reparte el dato pero solo da lectura: no puedes mutar a través de un &T compartido. Un Mutex<T> permite mutar pero tiene un único dueño: no puedes moverlo a varios hilos. Anidados, se completan: Arc<Mutex<T>> es “un puñado de manijas hacia un mismo dato, protegido por un turno”. Y como el orden del anidamiento cambia el significado, el tipo es una especificación precisa: Arc<Mutex<T>> comparte un dato guardado, mientras que Mutex<Arc<T>> guardaría una manija que puedes intercambiar. Casi siempre quieres el primero.
Rc incrementa su contador con una suma corriente, no atómica: si dos hilos clonaran a la vez, la actualización se corrompería y el dato se liberaría antes o después de tiempo —una carrera de datos sobre el propio recuento—. Por eso Rc no implementa Send, y el compilador rechaza moverlo a otro hilo. Arc usa operaciones atómicas para el contador, lo que sí es seguro entre hilos, a cambio de un coste ligeramente mayor por clone y por drop. La regla práctica: Rc dentro de un hilo, Arc cuando el dato cruza hilos. Rust te obliga a elegir bien porque el error es un fallo de compilación, no un crash de madrugada.
El contador compartido, palabra por palabra
Este es el “hola mundo” del estado compartido: diez hilos incrementando mil veces un mismo contador. Sin sincronización sería una carrera masiva; con Arc<Mutex<T>> el resultado es exactamente 10000, siempre.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let contador = Arc::new(Mutex::new(0u64));
let mut tareas = Vec::new();
for _ in 0..10 {
let contador = Arc::clone(&contador); // nueva manija, mismo dato
tareas.push(thread::spawn(move || { // move: la clausura toma su Arc
for _ in 0..1000 {
let mut n = contador.lock().unwrap();
*n += 1;
} // el guard se libera en cada vuelta
}));
}
for t in tareas {
t.join().unwrap(); // esperamos a que todos terminen
}
println!("total = {}", *contador.lock().unwrap()); // 10000, garantizado
}
Léelo por capas, que es como está construido:
Arc::clone(&contador)no copia elMutexni elu64: incrementa el contador atómico y devuelve otra manija hacia el mismo dato en el heap. Es barato y deliberadamente explícito —se escribeArc::clone(&x)y nox.clone()para que el lector vea que hay un recuento en juego, no una copia profunda—.moveobliga a la clausura del hilo a adueñarse de su clon delArc. Sinmove, la clausura intentaría tomar prestadocontador, y el borrow checker lo rechazaría: el hilo puede sobrevivir a la función que lo lanzó, así que no puede depender de un préstamo.contador.lock().unwrap()es la parte de la lección anterior: toma el turno y devuelve unMutexGuard.*n += 1muta a través deDerefMut. El guard se libera al final de cada vuelta del bucle, minimizando la sección crítica.joinbloquea hasta que cada hilo acaba; sin él,mainpodría leer el contador antes de tiempo.
El sistema de tipos hace todo el trabajo pesado: Arc<Mutex<u64>> implementa Send y Sync (porque u64: Send), y son justo esos dos traits los que permiten que la clausura cruce a otro hilo. Si intentaras esto con Rc<RefCell<u64>>, no compilaría —y ese “no compila” es Rust cerrándote la puerta a una carrera de datos antes de que exista—.
flowchart TD H[Arc Mutex del contador en el heap con recuento atomico] T1[Hilo 1 con su Arc clonado] --> H T2[Hilo 2 con su Arc clonado] --> H T3[Hilo N con su Arc clonado] --> H H --> L[Solo un hilo sostiene el MutexGuard a la vez] L --> R[Suma determinista total 10000] style H fill:#cba6f7,color:#11111b style L fill:#89b4fa,color:#11111b style R fill:#a6e3a1,color:#11111b
Cuando los hilos no sobreviven a la función que los lanza, thread::scope te deja prestarles datos de la pila sin Arc ni 'static: el scope garantiza que todos terminan antes de devolver, así que un &Mutex<T> normal basta. Reserva Arc para hilos desprendidos, tareas que se guardan en estructuras de vida larga, o cuando la posesión compartida es real y no un truco para saltarte un lifetime. Para el simple “reparto trabajo y recojo resultados”, scope suele ser más limpio.
Un dato rico compartido: el mapa entre hilos
El patrón no se limita a contadores. Cualquier estructura mutable que varios hilos deban compartir sigue la misma forma; solo cambia la T:
use std::sync::{Arc, Mutex};
use std::thread;
use std::collections::HashMap;
fn main() {
let cuentas: Arc<Mutex<HashMap<String, u64>>> =
Arc::new(Mutex::new(HashMap::new()));
let mut tareas = Vec::new();
for _ in 0..4 {
let cuentas = Arc::clone(&cuentas);
tareas.push(thread::spawn(move || {
let mut mapa = cuentas.lock().unwrap();
*mapa.entry(String::from("ana")).or_insert(0) += 1;
})); // el guard se libera aqui
}
for t in tareas { t.join().unwrap(); }
println!("{:?}", *cuentas.lock().unwrap()); // {"ana": 4}
}
La torre Arc<Mutex<HashMap<String, u64>>> se lee de fuera adentro como una frase: compartido entre hilos (Arc), con mutación arbitrada (Mutex), de un mapa de nombres a contadores (HashMap). Cada capa aporta exactamente una capacidad, y el tipo entero es la especificación completa de cómo se comparte ese estado. Añadir un RwLock en lugar del Mutex, o un atómico dentro, no cambiaría la gramática: cambiaría una palabra de la frase.
La primera vez que escribes Arc<Mutex<T>> parece un trabalenguas de tipos, un impuesto de ceremonia. Es lo contrario: es Rust negándose a fundir en un solo concepto dos preguntas que en realidad son ortogonales. Quién posee el dato y quién puede mutarlo ahora son ejes independientes —puedes compartir sin mutar, mutar sin compartir, ninguno, o ambos—, y un lenguaje honesto los factoriza en dos tipos que compones según lo que de verdad necesitas. El “objeto sincronizado” monolítico de Java o C# colapsa esos ejes en una decisión tomada por ti: todo objeto es potencialmente compartible y todo método puede ser synchronized, mezclando posesión, aliasing y bloqueo en una sopa donde nunca sabes qué garantiza qué. Rust te hace deletrear la garantía. Arc dice “este dato tiene varios dueños y morirá cuando el último se vaya”; Mutex dice “este dato solo se toca con exclusividad demostrada”; el anidamiento Arc<Mutex<T>> dice ambas cosas a la vez, y su orden —Arc por fuera, Mutex por dentro— codifica que lo compartido es el dato guardado, no una manija intercambiable. Que el compilador exija Send para cruzar la clausura al hilo y Sync para compartir la referencia no es burocracia: es el mismo teorema de ownership del nivel 8 proyectado sobre el tiempo paralelo, donde Arc aporta el Send que Rc no puede dar y Mutex aporta el Sync que una celda desnuda no tiene. Por eso el patrón no se memoriza, se deduce: ante cualquier estado compartido y mutable te preguntas qué capacidades necesita —posesión múltiple, mutación arbitrada, quizá lectura concurrente— y ensamblas los tipos que las dan. La torre de tipos no es ruido; es la lista exacta de lo que tu diseño promete, escrita en un lenguaje que el compilador verifica.
Compartir estado mutable entre hilos son dos problemas ortogonales: posesión compartida (Arc, con recuento atómico, Send donde Rc no lo es) y mutación arbitrada (Mutex). Anidados, Arc<Mutex<T>> es el patrón canónico: clona el Arc hacia cada hilo con Arc::clone, toma el turno con lock(), muta, y deja que el guard se libere. El compilador exige Send y Sync, y esa exigencia es lo que convierte una carrera de datos en un error de compilación. Si los hilos no sobreviven al ámbito, thread::scope te ahorra el Arc.
- Escribe el contador de diez hilos y comprueba que imprime 10000. Luego sustituye
ArcporRcyMutexporRefCell; lee el error de compilación y explica qué trait falta y por qué. - Cambia el
Mutex<u64>por unMutex<Vec<u64>>donde cada hilo empuja su identificador; ordena el vector al final y verifica que están los diez. - Reescribe el ejercicio con
thread::scopesinArc, prestando un&Mutex<u64>de la pila. Argumenta por qué ahí desaparece la necesidad de recuento de referencias. - Explica la diferencia de significado entre
Arc<Mutex<T>>yMutex<Arc<T>>, y da un caso donde querrías cada uno. - Justifica por qué se prefiere
Arc::clone(&x)ax.clone()en este patrón, aunque hagan lo mismo.