Concurrencia sin miedo: hilos, canales y Arc<Mutex<T>>
Rust te permite escribir código multihilo sin el miedo habitual, porque las mismas reglas de ownership que evitan use-after-free también evitan las carreras de datos, en tiempo de compilación.
La concurrencia tiene una reputación bien ganada como una de las partes más difíciles de la programación, porque la mayoría de los lenguajes permiten que dos hilos toquen la misma memoria al mismo tiempo y simplemente confían en que el programador no lo arruine. Rust no confía en ti — o mejor dicho, no lo necesita, porque las reglas de ownership y borrowing que has usado desde el principio de este curso (un dueño, o muchos lectores O un escritor) resultan ser exactamente las reglas que también previenen las carreras de datos (data races). Cada primitiva de concurrencia en la biblioteca estándar de Rust está diseñada para que una carrera de datos, si intentaras escribir una, falle en la compilación en lugar de fallar a las 3 a.m. en producción. Eso es lo que significa 'concurrencia sin miedo' en la práctica: no que el código concurrente sea fácil de razonar, sino que el compilador razona la parte peligrosa por ti.
La herramienta más básica es `std::thread::spawn`, que recibe una closure y la ejecuta en un nuevo hilo del sistema operativo, devolviendo un `JoinHandle`. El hilo padre no espera automáticamente al hilo generado — si `main` termina primero, todo el proceso se cierra y cualquier hilo generado sin terminar simplemente se corta. Llamar a `.join()` sobre el handle bloquea el hilo actual hasta que el hilo generado termine, y devuelve un `Result` porque el hilo generado pudo haber entrado en panic; `.join().unwrap()` es el atajo habitual cuando solo quieres propagar ese panic.
Fíjate en la palabra clave `move` que verás delante de casi todas las closures de hilos. Por defecto, una closure toma prestadas las variables que usa de su entorno, pero una referencia prestada no tiene garantía de sobrevivir más que el hilo que la usa — el hilo generado podría seguir ejecutándose mucho después de que la función que lo creó haya retornado. `move` obliga a la closure a tomar ownership de todo lo que captura en lugar de tomarlo prestado, de modo que el dato tiene garantizado vivir exactamente lo que el hilo necesite. Esto es el ownership haciendo el mismo trabajo de siempre — decidir quién es responsable del tiempo de vida de un valor — solo que aplicado a través de un límite de hilos en lugar de un límite de funciones.
Los hilos que nunca se comunican entre sí no son muy útiles, así que la biblioteca estándar incluye `std::sync::mpsc` — canales de múltiples productores y un solo consumidor — para paso de mensajes. Creas un canal con `mpsc::channel()`, que devuelve un `Sender` y un `Receiver`; clonas el `Sender` para darle a varios hilos una forma de enviar valores al mismo canal, pero siempre hay un único `Receiver` que los recibe. Enviar un valor por el canal lo *mueve* — el hilo que envía ya no puede usarlo — que es, otra vez, las reglas de ownership de Rust previniendo silenciosamente que dos hilos toquen el mismo dato a la vez, esta vez asegurando que solo el lado receptor termine poseyéndolo.
El paso de mensajes funciona bien cuando el dato fluye en una sola dirección, pero a veces varios hilos realmente necesitan leer y escribir el *mismo* estado — un contador compartido, un caché, un pool de conexiones. Para eso usas `Arc<T>` combinado con `Mutex<T>`. `Arc` es el hermano seguro para hilos de `Rc`: la misma idea de conteo de referencias, el mismo `.clone()` barato, pero sus actualizaciones del contador usan operaciones atómicas para que varios hilos puedan clonarlo y soltarlo simultáneamente sin corromper el conteo. `Mutex<T>` aporta la mutabilidad interior — llamas a `.lock()` para obtener acceso exclusivo, lo cual bloquea el hilo actual si otro hilo ya tiene el lock, y devuelve un guard que libera el lock automáticamente cuando sale de scope. `Arc<Mutex<T>>` juntos son el equivalente multihilo del patrón `Rc<RefCell<T>>` de la lección anterior: ownership compartido más mutabilidad interior segura, solo que construido con primitivas atómicas y seguras para hilos.
Todo esto lo hacen cumplir dos traits que rara vez escribirás tú mismo pero que debes conocer por nombre: `Send`, que significa que un tipo es seguro de transferir a otro hilo, y `Sync`, que significa que un tipo es seguro de acceder desde múltiples hilos a la vez mediante una referencia compartida. `Rc<T>` deliberadamente no implementa `Send` ni `Sync` — el compilador se negará directamente a dejarte mover uno dentro de una closure de `thread::spawn` — precisamente porque su contador de referencias no es atómico y se corrompería bajo acceso concurrente. Esa negación es la idea completa: en lugar de que un code review detecte un bug sutil de hilos, o de que un crash raro en producción lo detecte por ti, el compilador lo detecta antes de que el programa exista. El lenguaje no 'sabe' que la concurrencia es difícil; simplemente ocurre que las reglas de ownership creadas para la seguridad de un solo hilo generalizan perfectamente a la seguridad multihilo también.
use std::thread;fn main() {let data = vec![1, 2, 3, 4, 5];let handle = thread::spawn(move || {let sum: i32 = data.iter().sum();println!("sum computed on spawned thread: {sum}");sum});let result = handle.join().unwrap();println!("main thread received: {result}");}
`move` transfers ownership of data into the spawned thread's closure, so the closure — not main — is responsible for it; join() blocks until the thread finishes and hands back its return value wrapped in a Result.
use std::sync::mpsc;use std::thread;fn main() {let (tx, rx) = mpsc::channel();for id in 0..3 {let tx_clone = tx.clone();thread::spawn(move || {let message = format!("hello from producer {id}");tx_clone.send(message).unwrap();});}drop(tx);for received in rx {println!("main received: {received}");}}
Each producer thread gets its own cloned Sender, but there's only one Receiver; dropping the original tx lets the for loop over rx know when every producer has finished and the channel is closed.
use std::sync::{Arc, Mutex};use std::thread;fn main() {let counter = Arc::new(Mutex::new(0));let mut handles = vec![];for _ in 0..10 {let counter = Arc::clone(&counter);let handle = thread::spawn(move || {let mut num = counter.lock().unwrap();*num += 1;});handles.push(handle);}for handle in handles {handle.join().unwrap();}println!("final count: {}", *counter.lock().unwrap());}
Each thread clones the Arc (cheap, atomic) and calls lock() to get exclusive, blocking access to the shared i32 inside the Mutex; ten threads increment the same counter with zero data races and no unsafe code.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Why won't the following code compile: spawning a thread that captures an `Rc<RefCell<i32>>` by moving it into the closure, incrementing it, and joining the thread?