Aprende Rust → Lección 10
Esta es la lección donde se cobra todo lo anterior. Las reglas de propiedad y préstamo que llevan incomodando desde el principio —un solo dueño, o muchos lectores o un escritor— resultan ser, exactamente, las reglas que hacen imposible una carrera de datos. En Rust no se depura una condición de carrera: no llega a compilar. A eso se le llama concurrencia sin miedo, y no es un eslogan, es una consecuencia.
Lanzar un hilo
Un hilo se crea con thread::spawn, al que se le pasa el código que debe ejecutar. Devuelve un manejador que sirve para esperarlo:
use std::thread;
fn main() {
let manejador = thread::spawn(|| {
let mut suma = 0;
for i in 1..=100 {
suma += i;
}
suma
});
println!("el hilo principal sigue trabajando");
let resultado = manejador.join().unwrap();
println!("el hilo calculó {resultado}");
}
Dos cosas que conviene fijar desde ya. La primera es que join() bloquea hasta que el hilo termina y devuelve lo que el hilo produjo; sin esa llamada, el programa principal puede acabar antes y llevarse los hilos por delante sin que lleguen a terminar. La segunda es que devuelve un Result, el de la lección 7, porque un hilo puede haber entrado en pánico y eso hay que poder saberlo.
move: el hilo se lleva lo que usa
Si el código del hilo usa algo de fuera, hay un problema de tiempos: nadie garantiza que el hilo termine antes que la función donde nació. Rust lo resuelve obligándote a ceder la propiedad con move:
use std::thread;
fn main() {
let datos = vec![1, 2, 3];
let manejador = thread::spawn(move || {
println!("el hilo recibió {datos:?}");
datos.len()
});
println!("elementos procesados: {}", manejador.join().unwrap());
}
Sin ese move el programa no compila, y el mensaje del compilador es de los más didácticos que tiene: dice que la clausura puede sobrevivir a la función actual pero toma prestado datos, que es de la función actual, y sugiere literalmente añadir la palabra move. Después de cederlo, datos ya no se puede usar en main: pertenece al hilo.
Lo que el compilador no te deja escribir
Una carrera de datos ocurre cuando dos hilos tocan el mismo dato a la vez y al menos uno escribe. En la mayoría de los lenguajes eso compila perfectamente y falla una vez de cada mil, en producción, de madrugada. Aquí no llega a existir:
Hay dos formas de hacerlo bien, y la comunidad prefiere la primera: comunicar en vez de compartir.
Canales: comunicar enviando
Un canal es una tubería de un solo sentido. Los hilos envían valores por un extremo y alguien los recibe por el otro, y como el valor se mueve al enviarlo, nunca hay dos hilos mirando el mismo dato.
El emisor se puede clonar para repartirlo entre varios hilos; el receptor, no:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 1..=3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(id * 10).unwrap();
});
}
drop(tx);
let mut recibidos: Vec<i32> = rx.iter().collect();
recibidos.sort();
println!("recibidos: {recibidos:?}");
println!("suma: {}", recibidos.iter().sum::<i32>());
}
Fíjate en el drop(tx), que es el detalle que atasca a casi todo el mundo la primera vez. El bucle for sobre el receptor termina cuando se cierran todos los emisores; si te quedas con el original sin usar, nunca se cierran todos y el programa se queda esperando para siempre. Soltarlo explícitamente es lo que deja que el canal se dé por terminado.
Y fíjate también en que ordeno antes de imprimir. Los tres hilos envían en el orden en que el sistema operativo los despacha, que no es predecible; lo que sí es determinista es el conjunto de lo recibido. Esa distinción vale para toda la lección.
Compartir estado: Arc y Mutex
A veces los hilos tienen que tocar el mismo dato de verdad. Entonces hacen falta dos piezas, y conviene no confundir para qué sirve cada una:
| Pieza | Qué resuelve |
|---|---|
Arc<T> |
Quién es el dueño. Permite que varios hilos sean copropietarios del mismo valor; se libera cuando el último suelta |
Mutex<T> |
Quién puede tocarlo ahora. Da acceso exclusivo a uno cada vez; los demás esperan |
Arc<Mutex<T>> |
Las dos cosas, que es lo que casi siempre se necesita |
Rc<T> |
Lo mismo que Arc pero sin sincronizar: más rápido y solo válido en un hilo |
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let contador = Arc::new(Mutex::new(0));
let mut manejadores = vec![];
for _ in 0..10 {
let contador = Arc::clone(&contador);
manejadores.push(thread::spawn(move || {
let mut n = contador.lock().unwrap();
*n += 1;
}));
}
for m in manejadores {
m.join().unwrap();
}
println!("resultado: {}", *contador.lock().unwrap());
}
Ese programa imprime 10. Siempre, en cualquier máquina y en cualquier ejecución. El mismo bucle escrito sin candado en un lenguaje que lo permita imprime 10 casi siempre y de vez en cuando otra cosa, y esa es precisamente la clase de fallo que no aparece en las pruebas.
El lock() devuelve un Result porque el candado puede quedar «envenenado» si un hilo entra en pánico mientras lo tiene cogido; el valor podría haber quedado a medias y Rust prefiere avisarte. Y el candado se suelta solo, cuando la variable que lo guarda sale de ámbito: no hay un unlock que olvidar.
Arc. Desde Rust 1.63 existe thread::scope, que garantiza que los hilos terminan antes de salir del bloque. Como el compilador ya sabe que no sobrevivirán, dentro se pueden tomar prestados datos de fuera con un simple &, sin envolver nada. Para trabajo paralelo de corta duración sobre datos locales es bastante más cómodo que Arc.Send y Sync: por qué todo esto funciona
Nada de lo anterior está codificado en el compilador como un caso especial de los hilos. Todo sale de dos traits de los de la lección 8, que el compilador implementa solo para los tipos que lo merecen:
| Trait | Qué significa |
|---|---|
Send |
El valor se puede mover a otro hilo sin peligro |
Sync |
Se puede compartir una referencia a él entre hilos sin peligro |
Casi todos los tipos los cumplen, y como se deducen automáticamente nunca hay que escribirlos. Las excepciones son las interesantes: Rc no es Send, porque lleva una cuenta de referencias sin sincronizar que dos hilos podrían corromper. Si intentas usar un Rc en un hilo, el programa no compila y el error nombra el trait que falta.
`Rc<…>` cannot be sent between threads safely no es un obstáculo burocrático: significa que el tipo que elegiste no está preparado para cruzar hilos y que la alternativa se llama Arc. Lo mismo con RefCell, que no es Sync y cuya versión para hilos es Mutex. El compilador no te está frenando, te está indicando el tipo correcto.En resumen
| Lo que quieres hacer | Qué usar |
|---|---|
| Lanzar trabajo en paralelo | thread::spawn |
| Esperarlo y recoger su resultado | .join().unwrap() |
| Que el hilo use datos de fuera | una clausura move |
| Prestar datos sin ceder la propiedad | thread::scope |
| Pasar resultados entre hilos | un canal mpsc |
| Que varios hilos envíen al mismo canal | clonar el emisor con tx.clone() |
| Que el receptor deje de esperar | soltar todos los emisores |
| Compartir de verdad un dato mutable | Arc<Mutex<T>> |
| Contar referencias en un solo hilo | Rc<T>, que es más barato |
| Saber si un tipo puede cruzar hilos | mirar si es Send y Sync |
Y aquí se cierra el camino. La idea que conviene llevarse, y que resume la guía entera, es que la incomodidad de las primeras semanas con Rust era una inversión: las mismas reglas que obligaban a pensar quién es el dueño de cada valor son las que, llegados a este punto, convierten la clase de error más cara de depurar que existe en un mensaje de compilación. No es que Rust sea bueno con los hilos a pesar de ser estricto. Es bueno con los hilos porque es estricto.
Para verlo explicado

«Concurrencia en Rust | Curso Rust | Stan Tech», del canal Stan Tech (18:53). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.