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).
Crear un hilo
Sección titulada «Crear un hilo»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}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.
Mover datos a un hilo
Sección titulada «Mover datos a un hilo»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 hiloSin 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.
Compartir datos entre hilos de forma segura
Sección titulada «Compartir datos entre hilos de forma segura»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: 5counter.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.
Práctica
Sección titulada «Práctica»1. Dos hilos, una suma
Sección titulada «1. Dos hilos, una suma»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 = 5050Estos 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.
2. Todos firman el libro de visitas
Sección titulada «2. Todos firman el libro de visitas»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í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.
Resumen
Sección titulada «Resumen»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
movepara quedarse con el ownership completo de los datos que usa. - Comparte datos entre hilos con
Arc(ownership compartido) junto conMutex(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 que aprendiste
Sección titulada «Comprueba lo que aprendiste»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
Para leer más
Sección titulada «Para leer más»- Usar hilos para ejecutar código a la vez — El libro de Rust (en inglés):
spawn,joiny closures conmove, con ejemplos de cómo se intercala la salida. - Concurrencia con estado compartido — El libro de Rust (en inglés):
MutexyArca fondo, incluido por quéRcno se puede enviar a otro hilo. std::thread— documentación de la biblioteca estándar (en inglés): todo sobre los hilos, incluido cómo ponerles nombre y los hilos con ámbito (scoped threads).Mutex— documentación de la biblioteca estándar (en inglés): cómo funciona el bloqueo y qué significa el “envenenamiento” (poisoning) cuando un hilo hace panic mientras tiene el bloqueo.- Threads — Rust by Example (en inglés): un ejemplo corto y ejecutable que lanza varios hilos y espera a que terminen.