Ir al contenido

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 117
sigue siendo mío: [12, 7, 30, 5, 18, 22, 9, 14]
▶ Pruébalo tú mismo

Esto es lo que pasa:

  • thread::scope(|s| { ... }) abre el ámbito. Lanzas los hilos con s.spawn(...) en lugar de thread::spawn(...).
  • Los dos closures toman prestados morning y afternoon, que solo son slices de sales. 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, sales sigue perteneciendo a main, 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.

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=10MB
petición 2: tema=claro, límite=10MB
petición 3: tema=claro, límite=10MB
petición 4: tema=claro, límite=10MB
tras actualizar: tema=oscuro, límite=25MB
▶ Pruébalo tú mismo

Los 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.

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: 8000
votos netos: -4000
▶ Pruébalo tú mismo

Fí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.

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::Relaxed está 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 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 limpiamente
▶ Pruébalo tú mismo

El 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 USD
Tienda Rusty en JP cobra en JPY
Tienda Rusty en DE cobra en EUR
Tienda Rusty en BR cobra en BRL
▶ Pruébalo tú mismo

Los 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?

  • LazyLock cuando sabes cómo construir el valor en el mismo sitio donde lo declaras. Se siente como una variable global normal.
  • OnceLock cuando el valor se decide más tarde, por ejemplo a partir de una opción de la línea de comandos. Lo llenas con get_or_init(...) o set(...) (y set falla 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.

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 B
let 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:

  1. Toma siempre los cerrojos en el mismo orden. Si todos los hilos bloquean alice antes que bob (por ejemplo, ordenando por ID de cuenta), el ciclo no puede ocurrir.
  2. 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.
  3. Libera los guards pronto. Un guard desbloquea cuando se libera (drop). Puedes terminarlo con un bloque interno { ... } (como el escritor del RwLock de arriba) o llamando tú mismo a drop(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 informe
hecho: enviar correo
hecho: redimensionar foto
▶ Pruébalo tú mismo

Sin 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.

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.

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` keyword

El 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 loop

El 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).

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: 500500
▶ Pruébalo tú mismo

Aquí 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.

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]
▶ Pruébalo tú mismo

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.

  • thread::scope garantiza que sus hilos terminan antes de que acabe, así que pueden tomar prestados datos locales sin Arc ni move.
  • RwLock permite muchos lectores o un escritor. Ayuda con datos que se leen mucho; si no, un Mutex es más sencillo.
  • Los atómicos (AtomicUsize, AtomicI64, AtomicBool) se encargan de contadores e indicadores sin cerrojo. Usa SeqCst si dudas y Relaxed para contadores sencillos.
  • LazyLock y OnceLock crean 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 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