Arc de T: compartir entre hilos con conteo atómico
`Rc` es veloz pero está confinado a un hilo. `Arc<T>` (atomically reference counted) es la misma idea —propiedad compartida por conteo— pero con contadores atómicos, así que varios hilos pueden clonar y soltar sin carreras. Esa seguridad hace a `Arc` `Send + Sync`, y cuesta operaciones atómicas. Combinado con `Mutex`, es la columna vertebral del estado compartido entre hilos.
Rc es rápido porque asume que nadie más toca su contador; esa suposición se rompe en cuanto aparece un segundo hilo. Arc<T> —atomically reference counted— es exactamente la misma idea de propiedad compartida por conteo, con una sola diferencia decisiva: sus contadores son atómicos, de modo que varios hilos pueden clonar y soltar el mismo dato sin que las cuentas se pisen. Esa atomicidad es lo que convierte a Arc en Send + Sync y le abre las puertas de la concurrencia; y es también su coste, porque una operación atómica es más cara que un incremento normal. Arc es el cimiento del estado compartido entre hilos, casi siempre del brazo de un Mutex.
- Compartir un valor entre hilos con
Arc::newyArc::clone. - Entender por qué el conteo atómico hace a
ArcSendySync. - Medir el trade-off: la atomicidad frente al conteo no sincronizado de
Rc. - Combinar
ArcconMutexpara tener estado mutable compartido entre hilos.
Rc no cruza el hilo; Arc sí
El mismo código que compartía dentro de un hilo con Rc es rechazado en cuanto intentas mover la participación a otro hilo, porque Rc es !Send:
// NO compila: `Rc<Vec<i32>>` no puede enviarse entre hilos con seguridad
// use std::rc::Rc;
// use std::thread;
// let dato = Rc::new(vec![1, 2, 3]);
// let d = Rc::clone(&dato);
// thread::spawn(move || println!("{d:?}")); // error: Rc no es Send
Cambiar Rc por Arc es todo lo que hace falta: la API es idéntica, la garantía no.
use std::sync::Arc;
use std::thread;
fn main() {
let dato = Arc::new(vec![1, 2, 3]);
let mut handles = vec![];
for i in 0..3 {
let d = Arc::clone(&dato); // cada hilo recibe su participacion
handles.push(thread::spawn(move || {
println!("hilo {i} ve {d:?}");
}));
}
for h in handles {
h.join().unwrap();
}
} // el ultimo hilo en terminar lleva la cuenta a 0 y libera el Vec
Cada hilo se lleva su propio Arc —su participación sobre el mismo Vec— y el último en terminar, sea cual sea, ejecuta la liberación. Nadie necesita saber quién será el último: el contador lo decide.
Por qué atómico
Los contadores de Arc son AtomicUsize, y los clone/drop los mueven con operaciones atómicas de incremento y decremento bajo un orden de memoria adecuado. Una operación atómica es indivisible: aunque dos hilos la ejecuten a la vez, el hardware garantiza que ninguna cuenta se pierde. Eso elimina de raíz la doble liberación, la liberación prematura y la fuga que un contador normal sufriría bajo concurrencia. Y eso —precisamente eso— es lo que autoriza al compilador a marcar Arc<T> como Send y Sync cuando T también lo es, mientras Rc se queda fuera.
flowchart TB h1[Hilo 1] --> caja h2[Hilo 2] --> caja h3[Hilo 3] --> caja subgraph caja[ArcInner en el heap] sc[contador atomico fuerte] wc[contador atomico debil] val[valor T compartido] end style h1 fill:#89b4fa,color:#11111b style h2 fill:#89b4fa,color:#11111b style h3 fill:#89b4fa,color:#11111b style val fill:#a6e3a1,color:#11111b
El coste del trade-off
Nada de esto es gratis. Una operación atómica obliga a coordinar cachés entre núcleos y no puede reordenarse libremente, así que cuesta bastante más que sumar uno a un entero corriente. Clonar y soltar un mismo Arc en un bucle apretado desde varios hilos provoca ping-pong de la línea de caché que contiene el contador, y el rendimiento se hunde. De ahí la regla: usa Rc mientras estés dentro de un hilo y reserva Arc para cuando de verdad cruces la frontera. No cojas Arc “por si acaso”; pagas atomicidad por una seguridad que no necesitas.
Conviene separar dos cosas: leer el valor a través de un Arc es tan barato como con Rc —un Deref a &T, sin átomos—; lo atómico son solo las operaciones sobre el contador, es decir, clone y drop. Y, como Rc, Arc solo entrega &T: compartir entre hilos no te da mutación. Para mutar el dato compartido necesitas sincronización.
Arc con Mutex: el patrón del estado compartido
Arc resuelve quién posee; Mutex resuelve quién puede mutar ahora. Son ortogonales y se componen: Arc<Mutex<T>> es el patrón canónico del estado mutable compartido entre hilos.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let contador = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&contador);
handles.push(thread::spawn(move || {
let mut n = c.lock().unwrap(); // adquiere el candado
*n += 1; // mutacion exclusiva garantizada
})); // al salir n, el candado se libera
}
for h in handles {
h.join().unwrap();
}
println!("total = {}", *contador.lock().unwrap()); // 10
}
Arc permite que los diez hilos posean el mismo Mutex; el Mutex garantiza que solo uno mute a la vez. Sin el Arc no podrías repartir el Mutex entre los hilos; sin el Mutex no podrías mutar lo que el Arc comparte. Cada capa hace un trabajo, y su composición —que profundizarás en el nivel de estado compartido— es la respuesta idiomática de Rust a la mutación concurrente.
Arc<T> te deja compartir T entre hilos, pero no te deja mutarlo: sigue siendo acceso de solo lectura. Un error de principiante es esperar que Arc sincronice por sí mismo; no lo hace. Si necesitas escribir, envuelve el interior en Mutex o RwLock. Y si el dato es inmutable de por vida —una tabla de consulta, una configuración cargada una vez—, entonces Arc<T> a secas basta y es lo ideal: compartes sin candado porque nadie escribe.
Que existan Rc y Arc como tipos separados, casi idénticos salvo por un detalle interno, es una de las decisiones más reveladoras de Rust. En la mayoría de los lenguajes la seguridad frente a hilos es una propiedad difusa: una convención documentada, una nota en el manual, algo que el programador debe recordar y que el compilador no vigila; el mismo objeto compartido es a veces seguro y a veces no según cómo lo uses, y descubrir la diferencia suele ocurrir en producción, de madrugada, con un data race imposible de reproducir. Rust hace lo contrario: eleva “es seguro entre hilos” a una propiedad del tipo, encarnada en los marcadores Send y Sync, y te obliga a elegir el tipo correcto de antemano. Rc dice, en su firma, “yo vivo en un hilo” —es !Send, y ningún thread::spawn lo aceptará—; Arc dice “yo puedo viajar” —es Send + Sync, y paga contadores atómicos para merecerlo—. La distinción no está en tu disciplina, está en la firma, y por eso no puedes equivocarte por descuido: usar Rc donde hacía falta Arc no es un bug latente, es un error de compilación que ves antes de ejecutar nada. Aquí se entiende qué significa de verdad la “concurrencia sin miedo”: no es que Rust te dé herramientas mejores para depurar carreras, es que las hace inexpresables, porque la diferencia entre lo que cruza hilos y lo que no está codificada en el sistema de tipos y demostrada en cada compilación. Arc no es “el Rc de la concurrencia”; es la prueba de que la seguridad de hilos puede dejar de ser una virtud del programador para volverse un teorema del compilador, y su único coste —unos átomos en el contador— es el precio, explícito y mínimo, de esa demostración.
Arc<T> es Rc con contadores atómicos: la misma propiedad compartida por conteo, pero segura entre hilos, y por eso Send + Sync cuando T lo es. La atomicidad afecta solo a clone y drop; leer el valor es tan barato como con Rc. Cuesta más que Rc —coordina cachés entre núcleos—, así que resérvalo para cuando de verdad cruces hilos. Da únicamente &T; para mutar el dato compartido entre hilos se compone con Arc<Mutex<T>> o Arc<RwLock<T>>. Si el dato es inmutable, Arc<T> a secas basta.
- Toma el ejemplo de
Rccompartido en un hilo, cámbialo athread::spawny lee el error sobreSend. SustituyeRcporArcy verifica que compila. - Reparte un
Arc<Vec<i32>>entre tres hilos que lo lean, e imprimeArc::strong_countantes de lanzar y después de unir. Explica quién libera elVec. - Construye el contador con
Arc<Mutex<i32>>, increméntalo desde diez hilos y comprueba que el total es 10. Explica el papel de cada capa. - Intenta mutar el interior de un
Arc<i32>sinMutexy lee el error. Argumenta por quéArcsolo da&T. - Razona cuándo
Arc<T>a secas (sinMutex) es la elección correcta, y da un ejemplo de dato que se comparta entre hilos sin necesitar candado.