Concurrencia segura en Rust con Mutex y channel
1. Descripción general
Rust llama a su capacidad “fearless concurrency” (concurrencia sin miedo). El compilador bloquea las carreras de datos en tiempo de compilación, antes de que el programa pueda ejecutarse. Este artículo presenta dos herramientas principales de la biblioteca estándar: Mutex (junto con Arc) para compartir estado, y mpsc channel para pasar mensajes entre threads. Después de leerlo, sabrás cómo usar cada uno, escribir pruebas para ellos y evitar trampas comunes como deadlock o mantener un lock demasiado tiempo.
Todos los ejemplos solo usan la biblioteca estándar, sin dependencias externas. Puedes crear un proyecto con cargo new orders-demo y pegar el código en src/main.rs.
2. Problema: compartir datos entre threads
Supongamos que tienes varios threads procesando órdenes y quieres contar el número total de órdenes. Una forma ingenua sería así:
use std::thread;
fn main() {
let mut total_orders = 0;
let handles: Vec<_> = (0..4)
.map(|_| thread::spawn(|| total_orders += 1))
.collect();
for h in handles {
h.join().unwrap();
}
}
Este código no compila:
error[E0373]: closure may outlive the current function, but it borrows `total_orders`, which is owned by the current function
El compilador da dos razones:
thread::spawnrequiere que el closure sea'static. El thread hijo puede vivir más quemain, así que no puede tomar prestada una variable local.- Incluso si agregamos
move, cada thread recibirá su propia copia detotal_orders. Nadie está realmente sumando a una variable compartida.
Para resolver esto, necesitas dos cosas: compartir la propiedad con Arc y sincronizar el acceso con Mutex.
3. Compartir estado con Arc<Mutex<T>>
3.1. Contar órdenes desde varios threads
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let total_orders = Arc::new(Mutex::new(0u32));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&total_orders);
handles.push(thread::spawn(move || {
for _ in 0..1_000 {
*counter.lock().unwrap() += 1;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("Total de órdenes: {}", *total_orders.lock().unwrap());
}
Total de órdenes: 4000
Explicación de cada parte:
Arc::new(Mutex::new(0u32)):Arc(Atomic Reference Counted - Contador de referencias atómico) permite que varios threads tengan la propiedad de un valor.Mutexasegura que en cada momento solo un thread accede al valor interior.Arc::clone(&total_orders)solo incrementa el contador de referencias, no copia datos. Cada thread recibe su propioArcmediantemove.counter.lock()bloquea el thread actual hasta que obtiene el lock. La función devuelveResult<MutexGuard<u32>, PoisonError<…>>.MutexGuardse desreferencia a&mut u32, así que*... += 1modifica el valor directamente. El guard se descarta al final de la sentencia, y el lock se libera automáticamente allí.
No puedes “olvidar desbloquear” en Rust. Tampoco puedes leer el valor pasando por alto el lock, porque los datos están dentro de Mutex.
Nota: No puedes reemplazar
ArcconRc.Rcno implementaSend, y el compilador reportará un error en cuanto intentes pasarlo athread::spawn. De igual forma, no puedes reemplazarMutexconRefCell, porqueRefCellno esSync.
3.2. Alcance del MutexGuard
El lock se mantiene hasta que el guard se descarta. Si asignas el guard a una variable, el lock vive hasta el final del alcance de esa variable:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn reserve(inventory: &Mutex<HashMap<String, u32>>, sku: &str) -> bool {
let reserved = {
let mut stock = inventory.lock().unwrap();
match stock.get_mut(sku) {
Some(qty) if *qty > 0 => {
*qty -= 1;
true
}
_ => false,
}
}; // el guard se descarta aquí, el lock se libera
if reserved {
// Simulamos llamar a PaymentService lentamente, SIN mantener el lock
thread::sleep(Duration::from_millis(50));
}
reserved
}
fn main() {
let inventory = Arc::new(Mutex::new(HashMap::from([("AO-THUN".to_string(), 2)])));
let handles: Vec<_> = (0..3)
.map(|i| {
let inv = Arc::clone(&inventory);
thread::spawn(move || println!("Cliente {i}: {}", reserve(&inv, "AO-THUN")))
})
.collect();
for h in handles {
h.join().unwrap();
}
}
Cliente 0: true
Cliente 1: true
Cliente 2: false
El orden de las líneas puede variar entre ejecuciones. El resultado siempre es exactamente dos true y un false.
El bloque { ... } limita el tiempo de retención del lock al mínimo. La parte lenta (llamada de pago) se ejecuta después de que el lock se haya liberado, así que otros threads no tienen que esperar. Si necesitas liberar más temprano, puedes llamar a drop(stock) explícitamente.
3.3. Mutex envenenado
Si un thread entra en pánico mientras mantiene un lock, Rust marca el Mutex como envenenado. La razón es que los datos podrían estar en un estado incompleto:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let balance = Arc::new(Mutex::new(100i64));
let b = Arc::clone(&balance);
let _ = thread::spawn(move || {
let mut guard = b.lock().unwrap();
*guard -= 30;
panic!("PaymentService falló a mitad de camino");
})
.join();
match balance.lock() {
Ok(v) => println!("Saldo: {}", *v),
Err(poisoned) => {
let v = poisoned.into_inner();
println!("Mutex envenenado, saldo actual: {}", *v);
}
}
}
thread '<unnamed>' panicked at src/main.rs:10:9:
PaymentService falló a mitad de camino
Mutex envenenado, saldo actual: 70
Por lo tanto, .unwrap() después de lock() significa efectivamente “si otro thread entró en pánico mientras mantenía el lock, entonces entra en pánico también”. Para la mayoría de las aplicaciones, esa es una opción razonable. Si sabes cómo recuperar los datos, into_inner() devuelve el guard para que puedas inspeccionar y reparar.
4. Pasar mensajes con canales
Mutex responde la pregunta “varios threads editando datos compartidos”. Los canales van en la dirección opuesta: cada dato solo tiene un propietario en cada momento, y la propiedad se transfiere a través del canal. En muchos problemas, este enfoque es más simple y menos propenso a errores.
4.1. Múltiples productores, un consumidor
std::sync::mpsc es la abreviatura de multiple producer, single consumer (múltiples productores, un único consumidor):
use std::sync::mpsc;
use std::thread;
#[derive(Debug)]
struct Order {
id: u32,
customer: String,
amount: u64,
}
fn main() {
let (tx, rx) = mpsc::channel::<Order>();
for shop in 1..=3 {
let tx = tx.clone();
thread::spawn(move || {
let order = Order {
id: shop * 100,
customer: format!("customer-{shop}"),
amount: 150_000 * shop as u64,
};
tx.send(order).unwrap();
});
}
drop(tx); // importante: libera el sender original
let mut revenue = 0;
for order in rx {
println!("Orden recibida #{} de {}", order.id, order.customer);
revenue += order.amount;
}
println!("Ingresos: {revenue}");
}
Orden recibida #100 de customer-1
Orden recibida #300 de customer-3
Orden recibida #200 de customer-2
Ingresos: 900000
Explicación:
mpsc::channel()devuelve un par(Sender, Receiver). Cada productor recibe su propiotx.clone().tx.send(order)mueveorderal canal. Después de esto, el thread emisor no puede usar másorder, y el compilador reportará un error si lo intentas usar. Gracias a esto, dos threads nunca editan la mismaOrder.for order in rxrepite hasta que todos losSenderse hayan descartado. Si faltadrop(tx), el sender original aún vive enmain, y el bucle esperará para siempre.
El orden de recepción depende del cronograma de los threads. El total de ingresos siempre es 900000.
4.2. Canales acotados y backpressure
mpsc::channel() tiene un buffer ilimitado. Si el productor es más rápido que el consumidor, la memoria se agrandará gradualmente. sync_channel(n) limita el buffer a n elementos, y send se bloqueará cuando el buffer esté lleno:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::sync_channel::<u32>(2);
let producer = thread::spawn(move || {
for id in 1..=5 {
tx.send(id).unwrap();
println!("Orden enviada {id}");
}
});
thread::sleep(Duration::from_millis(100));
for id in rx {
println!("Procesando orden {id}");
thread::sleep(Duration::from_millis(20));
}
producer.join().unwrap();
}
Orden enviada 1
Orden enviada 2
Procesando orden 1
Orden enviada 3
Procesando orden 2
...
El productor envía dos órdenes y luego debe esperar a que el consumidor saque algunas. Este es un mecanismo de backpressure natural, muy útil para pipelines de procesamiento de datos. Con sync_channel(0), cada send debe esperar hasta que alguien haga recv (rendezvous).
5. Cuándo no necesitas Arc: threads con alcance
Si los threads seguramente terminan antes de que la función devuelva, thread::scope te permite tomar prestados los datos directamente, sin Arc:
use std::sync::Mutex;
use std::thread;
fn main() {
let processed = Mutex::new(Vec::new());
let orders = vec![101, 102, 103, 104];
thread::scope(|s| {
for chunk in orders.chunks(2) {
s.spawn(|| processed.lock().unwrap().extend_from_slice(chunk));
}
}); // todos los threads en el alcance se han unido aquí
let mut result = processed.into_inner().unwrap();
result.sort();
println!("{result:?}");
}
[101, 102, 103, 104]
Gracias a scope, el compilador sabe que los threads no viven más que processed y orders. Por lo tanto, tomar prestado con & es válido. Después de scope, into_inner() extrae los datos del Mutex sin necesidad de lock.
6. Escribir pruebas para código concurrente
Debes separar la lógica en funciones puras para hacerla fácil de probar. Pon lo siguiente en src/lib.rs:
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
pub fn count_orders(workers: u32, per_worker: u32) -> u32 {
let total = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..workers)
.map(|_| {
let total = Arc::clone(&total);
thread::spawn(move || {
for _ in 0..per_worker {
*total.lock().unwrap() += 1;
}
})
})
.collect();
handles.into_iter().for_each(|h| h.join().unwrap());
let result = *total.lock().unwrap();
result
}
pub fn sum_payments(payments: Vec<u64>) -> u64 {
let (tx, rx) = mpsc::channel();
for amount in payments {
let tx = tx.clone();
thread::spawn(move || tx.send(amount).unwrap());
}
drop(tx);
rx.iter().sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_every_order_exactly_once() {
assert_eq!(count_orders(8, 10_000), 80_000);
}
#[test]
fn sums_all_payments_from_channel() {
assert_eq!(sum_payments(vec![50_000, 120_000, 30_000]), 200_000);
}
#[test]
fn empty_payments_returns_zero() {
assert_eq!(sum_payments(vec![]), 0);
}
}
cargo test
running 3 tests
test tests::empty_payments_returns_zero ... ok
test tests::sums_all_payments_from_channel ... ok
test tests::counts_every_order_exactly_once ... ok
test result: ok. 3 passed; 0 failed
La prueba empty_payments_returns_zero verifica un caso límite: si olvidas drop(tx), esta prueba se colgará indefinidamente en lugar de devolver 0. Esa es una señal clara para detectar el error.
Hay un pequeño punto en count_orders: el valor se asigna a la variable result antes de devolver. La razón es que el guard temporal en la expresión final de la función puede vivir más que total, y el compilador reportará un error de borrow.
7. Errores comunes
Deadlock por orden inverso de bloqueo. El thread A bloquea orders y luego customers, mientras que el thread B bloquea customers y luego orders. Ambos esperarán entre sí indefinidamente. Rust solo previene las carreras de datos, no previene el deadlock. Debes establecer un orden fijo de bloqueo, o combinar los dos datos en un Mutex<(A, B)> único.
El guard temporal vive más de lo que crees. Mira el siguiente código:
while let Some(order) = queue.lock().unwrap().pop() {
process(order); // ¡el lock aún se está manteniendo aquí!
}
El temporary en la expresión del while let (y match) vive hasta el final del cuerpo del bucle. Cualquier otro thread que intente agregar órdenes a queue se bloqueará durante todo el tiempo que process se ejecute. La solución es separar: let next = queue.lock().unwrap().pop(); y luego procesar.
Llamar a lock() de nuevo en el mismo thread. std::sync::Mutex no es reentrante. Llamar a lock() dos veces en el mismo thread resultará en deadlock o panic.
Mantener el lock mientras esperas I/O o recv(). Cualquier operación bloqueante mientras mantienes el guard convierte a Mutex en un cuello de botella. Debes copiar o clonar los datos necesarios afuera, liberar el lock, y luego hacer el trabajo lento.
Olvidar descartar Sender. Como se vio arriba, el consumidor esperará indefinidamente.
Nota: En código async (Tokio), no mantengas
std::sync::MutexGuarda través de.await. Usatokio::sync::Mutexo los canales de Tokio. Además, para un simple entero como un contador,AtomicU32es mucho más rápido queMutex<u32>.
¿Cuándo elegir cuál?
- Usa
Mutexcuando varios threads necesitan leer/modificar el mismo estado durante mucho tiempo, por ejemplo, caché o almacén. Si la lectura es mucho más frecuente que la escritura, consideraRwLock. - Usa canal cuando los datos fluyen de un lugar a otro, por ejemplo, colas de órdenes, pipelines o grupos de trabajadores. El canal ayuda a que cada valor tenga un solo propietario, así que la lógica es más fácil de razonar.
8. Conclusión
Rust convierte los errores de concurrencia comunes en errores de compilación. No puedes compartir datos sin Arc, no puedes modificar datos sin Mutex, y no puedes reutilizar un valor que ya se ha enviado a través de un canal. Arc<Mutex<T>> es adecuado para estado compartido, mientras que los canales mpsc son adecuados para transferir propiedad entre threads. sync_channel agrega backpressure, y thread::scope ayuda a prescindir de Arc cuando el ciclo de vida de los threads es claro. Lo que el compilador no puede proteger es el deadlock, guards que viven demasiado, y senders que no se descartan. Esos son los lugares donde necesitas revisar cuidadosamente y escribir pruebas con cuidado.