Ir al contenido

Concurrencia en Rust

La concurrencia (ejecutar varias tareas a la vez) es donde muchos lenguajes se vuelven peligrosos de verdad: si dos hilos tocan los mismos datos al mismo tiempo, pueden corromperlos de formas muy difíciles de rastrear. Justo este tipo de error es el que las reglas de borrowing (préstamo) se diseñaron para evitar, y esa protección se extiende directamente a los hilos (threads).

use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("hilo: {i}");
}
});
for i in 1..=3 {
println!("main: {i}");
}
handle.join().unwrap(); // esperar a que el hilo termine
}
▶ Pruébalo tú mismo

thread::spawn recibe un closure y lo ejecuta en un hilo nuevo, en paralelo con todo lo demás. .join() espera hasta que ese hilo termine. Sin él, main podría acabar antes de que el hilo nuevo llegue siquiera a ejecutarse.

Un closure que le pasas a thread::spawn tiene que ser dueño por completo de los datos que usa, porque podría ejecutarse cuando el ámbito (scope) original ya terminó. Añade la palabra clave move para forzarlo:

use std::thread;
fn main() {
let message = String::from("hola desde un hilo");
let handle = thread::spawn(move || {
println!("{message}");
});
handle.join().unwrap();
}

Salida:

hola desde un hilo
▶ Pruébalo tú mismo

Sin move, Rust no compila esto: no puede garantizar que message siga siendo válida cuando el hilo nuevo llegue a usarla. move transfiere el ownership (propiedad) de message al closure. Es el mismo “move” que viste en la página de ownership, solo que ahora se lo pasas a un hilo en lugar de a otra variable.

Las referencias normales no sirven entre hilos (no pueden vivir más que el ámbito que las creó). Por eso, para compartir datos entre hilos se combinan dos cosas que ya conoces: Arc (de la página de punteros inteligentes) para compartir el ownership, y un Mutex para garantizar que solo un hilo toque los datos a la vez:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
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!("Resultado: {}", *counter.lock().unwrap());
}

Salida:

Resultado: 5
▶ Pruébalo tú mismo

counter.lock() espera hasta poder obtener acceso exclusivo al valor de forma segura, hace la actualización y después lo libera automáticamente. Cinco hilos incrementan el mismo contador y, gracias al Mutex, nunca se pisan entre ellos: siempre obtienes 5, y no un número menor por una actualización perdida.

Bloquear datos compartidos es una forma de que los hilos cooperen. La otra forma habitual es no compartir nada y hacer que los hilos se envíen mensajes entre sí. De eso trata la página de canales (channels).

Por qué Rust lo llama “concurrencia sin miedo”

Sección titulada «Por qué Rust lo llama “concurrencia sin miedo”»

En la mayoría de los lenguajes nada te impide olvidarte de bloquear un Mutex, o compartir sin querer entre hilos una referencia que no debería compartirse. El compilador de Rust sí lo vigila: el código que causaría una data race (carrera de datos) entre hilos simplemente no compila. Es el mismo sistema de ownership y borrowing que aprendiste antes en este curso, solo que aplicado a hilos en lugar de a un único ámbito.

Suma los números del 1 al 100 usando dos hilos: uno suma del 1 al 50 y el otro del 51 al 100. Después, main suma las dos respuestas. Pista: el closure de un hilo puede devolver un valor, y handle.join().unwrap() te lo devuelve.

Ver solución
use std::thread;
fn main() {
let first = thread::spawn(|| {
let total: u32 = (1..=50).sum();
total
});
let second = thread::spawn(|| {
let total: u32 = (51..=100).sum();
total
});
let a = first.join().unwrap();
let b = second.join().unwrap();
println!("{a} + {b} = {}", a + b);
}

Salida:

1275 + 3775 = 5050
▶ Pruébalo tú mismo

Estos closures no usan ninguna variable de fuera, así que no necesitan move. Los dos hilos se ejecutan a la vez, y join() espera a cada uno y te devuelve su resultado.

Lanza tres hilos. Cada uno añade una línea como "el hilo 2 estuvo aquí" a un mismo Vec<String> compartido. Cuando todos los hilos terminen, imprime las líneas ordenadas. Pista: usa Arc<Mutex<Vec<String>>> como en el ejemplo del contador, y ordena una copia al final, porque los hilos pueden terminar en cualquier orden.

Ver solución
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let book = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for id in 1..=3 {
let book = Arc::clone(&book);
handles.push(thread::spawn(move || {
book.lock().unwrap().push(format!("el hilo {id} estuvo aquí"));
}));
}
for handle in handles {
handle.join().unwrap();
}
let mut lines = book.lock().unwrap().clone();
lines.sort();
for line in lines {
println!("{line}");
}
}

Salida:

el hilo 1 estuvo aquí
el hilo 2 estuvo aquí
el hilo 3 estuvo aquí
▶ Pruébalo tú mismo

Quita la línea lines.sort() y ejecútalo varias veces: el orden puede cambiar. Ordenar es la forma de obtener la misma salida siempre.

  • thread::spawn(closure) ejecuta un closure en un hilo nuevo; .join() espera a que termine.
  • Un closure que se pasa a un hilo normalmente necesita move para quedarse con el ownership completo de los datos que usa.
  • Comparte datos entre hilos con Arc (ownership compartido) junto con Mutex (acceso exclusivo): counter.lock() te da acceso seguro al valor, de uno en uno.
  • El compilador rechaza al compilar, no al ejecutar, el código que causaría una data race.

Comprueba lo aprendido

1. ¿Por qué un closure que se pasa a thread::spawn normalmente necesita la palabra clave move?

2. ¿Para qué se usa Arc<Mutex<T>>?

3. ¿Qué puede salir mal si te olvidas de llamar a .join() sobre un hilo?

4. ¿Por qué el orden de las líneas "main:" e "hilo:" puede cambiar entre ejecuciones del mismo programa?

Puntuación: 0 / 4