Estado compartido en Rust: RwLock, atómicos y scoped threads
En la página de concurrencia compartiste un contador entre hilos (threads) con Arc<Mutex<T>>: Arc permite que muchos hilos sean dueños del valor, y Mutex deja que solo uno de ellos lo toque a la vez. En la página de canales evitaste compartir por completo enviando mensajes. Las dos son herramientas estupendas, y con ellas puedes construir casi cualquier cosa.
Pero piensa en un Mutex como la única llave del baño de una oficina. Funciona, aunque es demasiado si lo único que quieres es leer el tablón de anuncios del pasillo. La biblioteca estándar de Rust tiene algunas herramientas más, y cada una encaja mejor en cierto trabajo. En esta página conocerás cuatro: los scoped threads (hilos con ámbito), RwLock, los tipos atómicos y OnceLock/LazyLock. También aprenderás a evitar el único bug que el compilador no puede detectar por ti: el deadlock (interbloqueo).
Scoped threads: tomar prestado en lugar de mover
Sección titulada «Scoped threads: tomar prestado en lugar de mover»Con thread::spawn, normalmente necesitas move y a menudo Arc. Esto es porque un hilo lanzado podría seguir ejecutándose después de que main haya seguido adelante, así que Rust no le deja tomar prestadas tus variables locales.
std::thread::scope lo resuelve con una promesa: todos los hilos que se lanzan dentro del ámbito terminan antes de que el ámbito acabe. Como los hilos no pueden vivir más que tus datos, se les permite simplemente tomarlos prestados. Sin Arc, sin move, sin clonar.
use std::thread;
fn main() { let shop = "Tienda de la Esquina"; let sales = vec![12, 7, 30, 5, 18, 22, 9, 14]; let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| { let am = s.spawn(|| morning.iter().sum::<i32>()); let pm = s.spawn(|| afternoon.iter().sum::<i32>()); (am.join().unwrap(), pm.join().unwrap()) });
println!("{shop}: mañana {am}, tarde {pm}, día completo {}", am + pm); println!("sigue siendo mío: {:?}", sales);}Salida:
Tienda de la Esquina: mañana 54, tarde 63, día completo 117sigue siendo mío: [12, 7, 30, 5, 18, 22, 9, 14]Esto es lo que pasa:
thread::scope(|s| { ... })abre el ámbito. Lanzas los hilos cons.spawn(...)en lugar dethread::spawn(...).- Los dos closures toman prestados
morningyafternoon, que solo son slices desales. No se copia nada. - Lo que devuelva el closure del ámbito (aquí, una tupla con dos sumas) se convierte en el valor de
thread::scope. - Después del ámbito,
salessigue perteneciendo amain, así que puedes seguir usándolo.
Aunque se te olvide llamar a join(), el ámbito espera a todos sus hilos en la } de cierre. Esa es justo la promesa que hace que tomar prestado sea seguro.
RwLock: muchos lectores o un escritor
Sección titulada «RwLock: muchos lectores o un escritor»Imagina la pizarra con el menú de una cafetería. Muchos clientes pueden leerla a la vez, y no pasa nada. Pero cuando la dueña cambia un precio, todos tienen que esperar un momento hasta que la pizarra nueva esté lista.
Eso es un RwLock (un “read-write lock”, cerrojo de lectura y escritura). Tiene dos entradas:
read()te da un guard de lectura compartido. Muchos hilos pueden tener uno a la vez.write()te da un guard de escritura exclusivo. Mientras un escritor lo tiene, nadie más puede leer ni escribir.
Un Mutex no hace esa distinción: hasta dos lectores tienen que esperar su turno. Aquí tienes una caché de configuración que leen varios manejadores de peticiones, y que un administrador actualiza una vez:
use std::sync::{Arc, RwLock};use std::thread;
struct Settings { theme: String, max_upload_mb: u32,}
fn main() { let settings = Arc::new(RwLock::new(Settings { theme: String::from("claro"), max_upload_mb: 10, }));
// Cuatro manejadores de peticiones leen la configuración a la vez. let mut handles = Vec::new(); for id in 1..=4 { let settings = Arc::clone(&settings); handles.push(thread::spawn(move || { let s = settings.read().unwrap(); // compartido: otros también pueden leer format!("petición {id}: tema={}, límite={}MB", s.theme, s.max_upload_mb) })); } for handle in handles { println!("{}", handle.join().unwrap()); }
// Un administrador cambia la configuración una vez. { let mut s = settings.write().unwrap(); // exclusivo: todos los demás esperan s.theme = String::from("oscuro"); s.max_upload_mb = 25; } // aquí se libera el guard de escritura, así que el cerrojo vuelve a estar libre
let s = settings.read().unwrap(); println!("tras actualizar: tema={}, límite={}MB", s.theme, s.max_upload_mb);}Salida:
petición 1: tema=claro, límite=10MBpetición 2: tema=claro, límite=10MBpetición 3: tema=claro, límite=10MBpetición 4: tema=claro, límite=10MBtras actualizar: tema=oscuro, límite=25MBLos hilos pueden terminar en cualquier orden, pero la salida siempre es la misma. Cada hilo devuelve su línea en lugar de imprimirla, y main las imprime en el orden en que hace join de cada hilo.
¿Cuándo gana RwLock a Mutex? Cuando las lecturas son frecuentes, las escrituras son raras y cada lectura tarda un poco (por ejemplo, buscar en una caché grande). Entonces los lectores de verdad trabajan a la vez.
¿Cuándo no? Si las lecturas son mínimas, o se escribe todo el tiempo, un RwLock es solo un Mutex más complicado. Hace trabajo extra de control, y los lectores y escritores acaban esperándose unos a otros de todos modos. Si no estás seguro, empieza con un Mutex. Es más sencillo, y puedes cambiar más adelante si al medir ves que el cerrojo es un cuello de botella.
Atómicos: contadores sin cerrojo
Sección titulada «Atómicos: contadores sin cerrojo»Para un solo número o un solo indicador de verdadero/falso, incluso un cerrojo es más de lo que necesitas. Los tipos atómicos como AtomicUsize, AtomicI64 y AtomicBool pueden ser modificados de forma segura por muchos hilos a la vez, sin ningún cerrojo. La CPU hace que cada operación ocurra en un único paso indivisible, así que no se pierde ninguna actualización.
Aquí, 8 hilos registran cada uno 1000 visitas a una página. También cuentan votos: un voto positivo, luego tres negativos, una y otra vez. El total de votos se vuelve negativo, y por eso es un AtomicI64 y no un tipo sin signo:
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};use std::thread;
fn main() { let page_views = AtomicUsize::new(0); let net_votes = AtomicI64::new(0);
thread::scope(|s| { for _ in 0..8 { s.spawn(|| { for n in 0..1000 { page_views.fetch_add(1, Ordering::Relaxed); if n % 4 == 0 { net_votes.fetch_add(1, Ordering::Relaxed); // voto positivo } else { net_votes.fetch_sub(1, Ordering::Relaxed); // voto negativo } } }); } }); // aquí ya han terminado los 8 hilos
println!("visitas: {}", page_views.load(Ordering::Relaxed)); println!("votos netos: {}", net_votes.load(Ordering::Relaxed));}Salida:
visitas: 8000votos netos: -4000Fíjate en que page_views no es mut. Los atómicos cambian a través de una referencia compartida, y justo por eso muchos hilos pueden usarlos a la vez. Los métodos que más usarás:
fetch_add(n, ordering)/fetch_sub(n, ordering)suman o restan, y te devuelven el valor anterior.load(ordering)lee el valor actual.store(value, ordering)lo sobrescribe.
¿Qué es Ordering?
Sección titulada «¿Qué es Ordering?»Cada operación atómica pide un Ordering (orden de memoria). Le dice a la CPU y al compilador con qué rigor debe mantenerse esta operación en orden respecto a las operaciones de memoria que la rodean. La historia completa es profunda, pero como principiante solo necesitas dos reglas:
- Si no estás seguro, usa
Ordering::SeqCst. Es la opción más estricta y segura. Ordering::Relaxedestá bien para un contador sencillo que solo lees al final, como el de arriba. El total sigue siendo exacto.
Si algún día quieres ir más allá, el libro gratuito Rust Atomics and Locks (enlazado al final) explica los órdenes de memoria con claridad.
Un indicador para parar
Sección titulada «Un indicador para parar»Un AtomicBool es una señal perfecta de “para, por favor”. Aquí, un trabajador en segundo plano sigue guardando automáticamente hasta que main cambia el indicador:
use std::sync::atomic::{AtomicBool, Ordering};use std::thread;use std::time::Duration;
fn main() { let stop = AtomicBool::new(false);
let report = thread::scope(|s| { let worker = s.spawn(|| { while !stop.load(Ordering::SeqCst) { // imagina que aquí se guarda el documento automáticamente thread::sleep(Duration::from_millis(5)); } "el trabajador de autoguardado se detuvo limpiamente" });
thread::sleep(Duration::from_millis(30)); // main hace otro trabajo stop.store(true, Ordering::SeqCst); // pide al trabajador que pare worker.join().unwrap() });
println!("{report}");}Salida:
el trabajador de autoguardado se detuvo limpiamenteEl trabajador comprueba el indicador en cada vuelta de su bucle. Termina el paso que está haciendo y luego sale por su cuenta, lo cual es mucho más amable que matar un hilo a mitad de guardar un archivo.
OnceLock y LazyLock: configúralo una sola vez
Sección titulada «OnceLock y LazyLock: configúralo una sola vez»A veces quieres un valor global que todos los hilos puedan leer: una tabla de consulta, el nombre de la app, una configuración que se carga al arrancar. Lo difícil es construirlo. ¿Qué pasa si dos hilos intentan construirlo en el mismo momento?
std::sync::LazyLock y std::sync::OnceLock resuelven esto. El valor se crea exactamente una vez, la primera vez que alguien lo necesita. Si otros hilos lo piden mientras se está construyendo, simplemente esperan, y luego todos comparten el mismo valor.
use std::collections::HashMap;use std::sync::{LazyLock, OnceLock};use std::thread;
// LazyLock: la receta está escrita aquí mismo y se ejecuta en el primer uso.static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| { println!("construyendo la tabla de monedas..."); HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])});
// OnceLock: empieza vacío, y tú decides cómo llenarlo allí donde lo usas.static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str { STORE_NAME.get_or_init(|| { std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Tienda Rusty")) })}
fn main() { let mut handles = Vec::new(); for country in ["US", "JP", "DE", "BR"] { handles.push(thread::spawn(move || { format!("{} en {country} cobra en {}", store_name(), CURRENCIES[country]) })); } for handle in handles { println!("{}", handle.join().unwrap()); }}Salida:
construyendo la tabla de monedas...Tienda Rusty en US cobra en USDTienda Rusty en JP cobra en JPYTienda Rusty en DE cobra en EURTienda Rusty en BR cobra en BRLLos cuatro hilos quieren la tabla casi al mismo tiempo, y aun así “construyendo la tabla de monedas…” se imprime una sola vez. Y como son valores static que viven durante todo el programa, los hilos no necesitan Arc para nada.
¿Cuál deberías elegir?
LazyLockcuando sabes cómo construir el valor en el mismo sitio donde lo declaras. Se siente como una variable global normal.OnceLockcuando el valor se decide más tarde, por ejemplo a partir de una opción de la línea de comandos. Lo llenas conget_or_init(...)oset(...)(ysetfalla con educación si ya estaba lleno).
Los dos son parte de la biblioteca estándar. LazyLock llegó en Rust 1.80, así que el código más antiguo suele usar los crates once_cell o lazy_static para lo mismo.
Cómo evitar los deadlocks
Sección titulada «Cómo evitar los deadlocks»El compilador impide las carreras de datos (data races), pero hay un bug de concurrencia que no puede detectar: el deadlock (interbloqueo). Ocurre cuando los hilos se esperan unos a otros para siempre y tu programa simplemente se congela. Sin error, sin que se cierre, nada.
La receta clásica son dos cerrojos que se toman en distinto orden. Imagina a dos personas que necesitan el bolígrafo y la libreta. Una agarra el bolígrafo, la otra agarra la libreta, y cada una espera con educación a que la otra suelte lo suyo. Para siempre.
// Hilo A // Hilo Blet from = alice.lock().unwrap(); let from = bob.lock().unwrap();let to = bob.lock().unwrap(); let to = alice.lock().unwrap();// A tiene alice, espera a bob. // B tiene bob, espera a alice. ¡Atascados!Tres hábitos te mantienen a salvo:
- Toma siempre los cerrojos en el mismo orden. Si todos los hilos bloquean
aliceantes quebob(por ejemplo, ordenando por ID de cuenta), el ciclo no puede ocurrir. - Mantén cortos los ámbitos de los cerrojos. Ten el cerrojo solo el tiempo justo para leer o cambiar los datos. Nunca lo mantengas durante un trabajo lento, como una llamada de red, una escritura en un archivo o un cálculo largo. Todos los demás hilos se quedan esperando todo ese tiempo.
- Libera los guards pronto. Un guard desbloquea cuando se libera (drop). Puedes terminarlo con un bloque interno
{ ... }(como el escritor delRwLockde arriba) o llamando tú mismo adrop(guard).
Aquí tienes el hábito 3 en acción. Cada trabajador toma una tarea de una cola compartida, desbloquea la cola y solo después hace la parte lenta:
use std::sync::Mutex;use std::thread;
fn main() { let queue = Mutex::new(vec!["redimensionar foto", "enviar correo", "crear informe"]); let finished = Mutex::new(Vec::new());
thread::scope(|s| { for _ in 0..3 { s.spawn(|| { let mut jobs = queue.lock().unwrap(); let job = jobs.pop(); drop(jobs); // desbloquea la cola ANTES de la parte lenta
if let Some(job) = job { let result = format!("hecho: {job}"); // imagina que esto tarda segundos finished.lock().unwrap().push(result); } }); } });
// El ámbito terminó, así que ningún hilo usa ya `finished`. let mut done = finished.into_inner().unwrap(); done.sort(); // los hilos terminan en cualquier orden for line in done { println!("{line}"); }}Salida:
hecho: crear informehecho: enviar correohecho: redimensionar fotoSin drop(jobs), el guard viviría hasta el final del closure. Entonces el trabajo “lento” ocurriría con la cola todavía bloqueada, y los tres trabajadores irían uno detrás de otro en lugar de a la vez.
Un caso traicionero que conviene conocer: en while let Some(job) = queue.lock().unwrap().pop() { ... }, el guard vive durante todo el cuerpo del bucle. Así que la cola sigue bloqueada mientras trabajas en la tarea. Saca primero la tarea en una sentencia let aparte, como hace el ejemplo.
¿Qué herramienta uso?
Sección titulada «¿Qué herramienta uso?»| Quieres… | Usa |
|---|---|
| Enviar trabajo o resultados de un hilo a otro | Un canal (mpsc) |
| Que muchos hilos lean y cambien el mismo valor | Arc<Mutex<T>> |
| Compartir datos que se leen mucho y se cambian poco | Arc<RwLock<T>> |
| Contar cosas, o compartir un indicador sencillo de encendido/apagado | Atómicos (AtomicUsize, AtomicBool, …) |
| Construir un valor global una vez y luego solo leerlo | LazyLock u OnceLock |
Que los hilos tomen prestados datos locales, sin Arc |
thread::scope |
Se pueden combinar libremente. Los ejemplos de esta página ya han combinado thread::scope con atómicos y con un Mutex. Elige la herramienta más sencilla que encaje, y no te preocupes por elegir “mal”: cambiar más tarde suele ser un cambio pequeño.
Errores comunes
Sección titulada «Errores comunes»Tomar prestada una variable local en thread::spawn. Esto parece inofensivo:
use std::thread;
fn main() { let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| { let total: i32 = sales.iter().sum(); total });
println!("total: {}", handle.join().unwrap());}error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function --> src/main.rs:6:32 |6 | let handle = thread::spawn(|| { | ^^ may outlive borrowed value `sales`7 | let total: i32 = sales.iter().sum(); | ----- `sales` is borrowed here |help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keywordEl compilador dice que el closure podría vivir más que la función actual, pero toma prestado sales, que pertenece a esa función. Tú sabes que llamas a join() enseguida, pero thread::spawn no puede saberlo. Para él, el hilo podría ejecutarse para siempre. Tienes dos soluciones: añadir move (y entonces main ya no puede usar sales después), o usar thread::scope, que sí puede tomar prestado:
let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());Olvidar Arc. Sin Arc, el closure move del primer hilo se queda con el Mutex, y no queda nada para el siguiente:
use std::sync::Mutex;use std::thread;
fn main() { let counter = Mutex::new(0); let mut handles = vec![];
for _ in 0..3 { handles.push(thread::spawn(move || { *counter.lock().unwrap() += 1; })); }
for handle in handles { handle.join().unwrap(); }}error[E0382]: use of moved value: `counter` --> src/main.rs:9:36 | 5 | let counter = Mutex::new(0); | ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait... 8 | for _ in 0..3 { | ------------- inside of this loop 9 | handles.push(thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loopEl mensaje dice que counter ya se movió al closure en una vuelta anterior del bucle. Envuélvelo en Arc::new(...) y clona el Arc al principio del bucle, como en la página de concurrencia. O usa thread::scope y quita el move. Si usas Rc en lugar de Arc, obtendrás error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely (no se puede enviar de forma segura entre hilos). Rc es solo para un hilo (consulta Smart pointers).
Práctica
Sección titulada «Práctica»1. Suma en cuatro trozos
Sección titulada «1. Suma en cuatro trozos»Crea un Vec con los números del 1 al 1000. Usa thread::scope para dividirlo en 4 trozos de 250 y suma cada trozo en su propio hilo. Imprime la suma de cada trozo y el total. Pista: numbers.chunks(250) te da los slices. Guarda los handles en un Vec y luego haz join de ellos en orden.
Ver solución
use std::thread;
fn main() { let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| { let mut handles = Vec::new(); for chunk in numbers.chunks(250) { handles.push(s.spawn(move || chunk.iter().sum::<u64>())); } handles.into_iter().map(|h| h.join().unwrap()).collect() });
println!("sumas por trozo: {:?}", sums); println!("total: {}", sums.iter().sum::<u64>());}Salida:
sumas por trozo: [31375, 93875, 156375, 218875]total: 500500Aquí el move solo mueve chunk a cada hilo, y chunk es un pequeño slice prestado. El vector en sí sigue prestado, no movido, así que podrías volver a usar numbers después del ámbito.
2. Números de pedido únicos
Sección titulada «2. Números de pedido únicos»Una tienda online le da un número a cada pedido, empezando en 1000. Lanza 4 hilos que tomen cada uno 3 números de pedido de un único AtomicU64 compartido. Junta los 12 números, ordénalos e imprímelos. Pista: fetch_add(1, Ordering::SeqCst) devuelve el valor antes de sumar, así que reparte cada número exactamente una vez.
Ver solución
use std::sync::atomic::{AtomicU64, Ordering};use std::thread;
fn main() { let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| { let mut handles = Vec::new(); for _ in 0..4 { handles.push(s.spawn(|| { (0..3) .map(|_| next_order.fetch_add(1, Ordering::SeqCst)) .collect::<Vec<u64>>() })); } handles.into_iter().flat_map(|h| h.join().unwrap()).collect() });
numbers.sort(); println!("{} pedidos: {:?}", numbers.len(), numbers);}Salida:
12 pedidos: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]Qué hilo recibe qué número cambia de una ejecución a otra, pero ningún número se reparte dos veces ni se salta. Al ordenarlos, la lista impresa es siempre la misma.
Resumen
Sección titulada «Resumen»thread::scopegarantiza que sus hilos terminan antes de que acabe, así que pueden tomar prestados datos locales sinArcnimove.RwLockpermite muchos lectores o un escritor. Ayuda con datos que se leen mucho; si no, unMutexes más sencillo.- Los atómicos (
AtomicUsize,AtomicI64,AtomicBool) se encargan de contadores e indicadores sin cerrojo. UsaSeqCstsi dudas yRelaxedpara contadores sencillos. LazyLockyOnceLockcrean un valor global exactamente una vez, de forma segura, aunque muchos hilos lo pidan a la vez.- Evita los deadlocks: toma los cerrojos siempre en el mismo orden, mantén cortos sus ámbitos y libera los guards pronto.
Comprueba lo que aprendiste
Sección titulada «Comprueba lo que aprendiste»Comprueba lo aprendido
1. ¿Por qué los hilos lanzados con s.spawn dentro de thread::scope pueden tomar prestadas variables locales?
2. ¿Qué afirmación sobre RwLock es cierta?
3. Necesitas un contador de visitas compartido por muchos hilos. ¿Cuál es la opción buena más sencilla?
4. El hilo A bloquea alice y luego bob. El hilo B bloquea bob y luego alice. ¿Qué puede pasar?
Puntuación: 0 / 4
Para leer más
Sección titulada «Para leer más»- Concurrencia con estado compartido — El libro de Rust (en inglés): el capítulo oficial sobre
MutexyArc, la base sobre la que se construye esta página. std::thread::scope— documentación de la biblioteca estándar (en inglés): cómo funcionan los scoped threads, con más ejemplos.std::sync::RwLock— documentación de la biblioteca estándar (en inglés): guards de lectura y escritura, envenenamiento (poisoning) y una nota sobre cómo la equidad depende del sistema operativo.std::sync::atomic— documentación de la biblioteca estándar (en inglés): todos los tipos atómicos y un breve resumen de los órdenes de memoria.- Rust Atomics and Locks, de Mara Bos (en inglés): un libro gratuito en línea que explica paso a paso los atómicos, el orden de memoria y cómo se construyen los cerrojos.