Lección 24 de 26 Concurrencia y ecosistema
Concurrencia sin miedo
- thread::spawn lanza hilos que trabajan a la vez que main. El orden cambia en cada ejecución.
- join hace que main espere a que cada hilo termine. Si main acaba antes, se cierran todos los hilos.
- Los hilos se comunican con un canal: varios envían con tx.send() y uno recibe por rx, en orden de llegada.
- Para compartir un dato: Arc permite varios dueños y Mutex es un candado. Solo un hilo a la vez lo toca.
Los ordenadores actuales tienen varios núcleos, y para aprovecharlos un programa tiene que hacer varias cosas a la vez usando hilos (threads). El problema es que la programación concurrente es famosa por sus errores difíciles de reproducir: dos hilos que modifican el mismo dato a la vez, o que se esperan mutuamente para siempre.
Rust lo llama concurrencia sin miedo porque las mismas reglas de ownership y préstamos que ya conoces impiden, al compilar, la mayoría de esos errores. Si tu código concurrente compila, no tiene condiciones de carrera sobre los datos.
Crear hilos con thread::spawn
thread::spawn recibe una closure y la ejecuta en un hilo nuevo, en paralelo con el hilo principal:
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("hilo secundario: {i}");
}
});
for i in 1..=3 {
println!("hilo principal: {i}");
}
handle.join().unwrap();El orden de las líneas depende de cómo el sistema operativo reparta el tiempo entre los hilos, así que cambia de una ejecución a otra. Una salida posible:
hilo principal: 1
hilo secundario: 1
hilo secundario: 2
hilo principal: 2
hilo principal: 3
hilo secundario: 3Esperar a un hilo con join
thread::spawn devuelve un JoinHandle. Al llamar a su método join, el hilo actual espera a que el otro termine. Es importante: cuando main acaba, el programa termina y se cierran todos los hilos, hayan acabado o no.
join devuelve además lo que devuelva la closure, envuelto en un Result (es Err si el hilo hizo panic):
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let suma: u64 = (1..=1_000).sum();
suma
});
let resultado = handle.join().unwrap();
println!("La suma es {resultado}");
}Closures move en hilos
Un hilo puede seguir ejecutándose cuando la función que lo creó ya ha terminado. Por eso Rust no le deja tomar prestadas variables locales:
use std::thread;
fn main() {
let nombres = vec!["Ana", "Luis"];
let handle = thread::spawn(|| {
println!("{nombres:?}");
});
handle.join().unwrap();
}El error (closure may outlive the current function) sugiere la solución: con move, el hilo se queda con la propiedad de lo que usa.
use std::thread;
fn main() {
let nombres = vec!["Ana", "Luis"];
let handle = thread::spawn(move || {
println!("{nombres:?}");
});
handle.join().unwrap();
}Es habitual lanzar varios hilos, guardar sus JoinHandle en un vector y recoger los resultados después. Al hacer join en orden, los resultados salen siempre en el mismo orden:
use std::thread;
fn main() {
let mut handles = Vec::new();
for id in 1..=4 {
handles.push(thread::spawn(move || id * id));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}Canales: enviar mensajes entre hilos
Una forma segura de comunicar hilos es pasarse mensajes en lugar de compartir memoria. mpsc::channel crea un canal con dos extremos: el transmisor tx y el receptor rx. Su nombre viene de multiple producer, single consumer: muchos pueden enviar, uno recibe.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for palabra in ["hola", "desde", "otro", "hilo"] {
tx.send(palabra).unwrap();
}
});
for recibido in rx {
println!("Recibido: {recibido}");
}
}sendmueve el valor al canal: después ya no puedes usarlo en el hilo que lo envió.recv()espera hasta que llegue un mensaje. DevuelveErrcuando todos los transmisores se han cerrado.- Recorrer
rxconforrecibe mensajes hasta que se cierran todos los transmisores.
Para tener varios productores, clona el transmisor con tx.clone(), uno por hilo:
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(format!("trabajador {id} listo")).unwrap();
});
}
drop(tx); // el original no envía nada: lo cerramos para que el bucle termine
let mut mensajes: Vec<String> = rx.iter().collect();
mensajes.sort();
for mensaje in &mensajes {
println!("{mensaje}");
}
}Los mensajes llegan en el orden en que terminen los hilos, que no es fijo. Por eso aquí se ordenan antes de imprimirlos.
Cuidado: Si olvidas el
drop(tx), el transmisor original sigue abierto y el bucle sobrerxespera para siempre un mensaje que nunca llegará.
Estado compartido: Arc y Mutex
La otra opción es que varios hilos compartan un dato. Para modificarlo sin que se pisen, se protege con un Mutex (mutual exclusion): solo un hilo a la vez puede acceder a su interior.
use std::sync::Mutex;
fn main() {
let contador = Mutex::new(0);
{
let mut valor = contador.lock().unwrap();
*valor += 1;
} // aquí se libera el bloqueo
println!("contador = {}", contador.lock().unwrap());
}lock() espera hasta conseguir el acceso y devuelve un guardián que se comporta como &mut T. El bloqueo se libera solo cuando el guardián sale de su ámbito, gracias a Drop. Devuelve un Result porque falla si otro hilo hizo panic mientras tenía el bloqueo; de ahí el unwrap().
Para que varios hilos sean dueños del mismo Mutex hace falta Arc<T>, la versión de Rc segura entre hilos (atomic reference counted):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let contador = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..10 {
let contador = Arc::clone(&contador);
handles.push(thread::spawn(move || {
let mut valor = contador.lock().unwrap();
*valor += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Resultado: {}", contador.lock().unwrap());
}Arc<Mutex<T>> es el equivalente entre hilos de Rc<RefCell<T>>.
¿Por qué no Rc? Send y Sync
Si intentas enviar un Rc a otro hilo, el código no compila:
use std::rc::Rc;
use std::thread;
fn main() {
let datos = Rc::new(5);
let copia = Rc::clone(&datos);
let handle = thread::spawn(move || {
println!("{copia}");
});
handle.join().unwrap();
}El contador de Rc no está protegido: si dos hilos lo actualizaran a la vez, podría corromperse. Rust lo sabe gracias a dos traits que se implementan automáticamente:
Send: el valor se puede mover a otro hilo. Casi todos los tipos lo son;Rcno.Sync: el valor se puede compartir por referencia entre hilos.Mutexlo es;RefCellno.
| En un hilo | Entre hilos |
|---|---|
Rc<T> | Arc<T> |
RefCell<T> | Mutex<T> |
Hilos con ámbito: thread::scope
thread::scope crea hilos que tienen garantizado terminar antes de que acabe el bloque. Por eso sí pueden tomar prestadas variables locales, sin move ni Arc:
use std::thread;
fn main() {
let numeros = vec![1, 2, 3, 4, 5, 6];
let (izquierda, derecha) = numeros.split_at(3);
let (a, b) = thread::scope(|s| {
let h1 = s.spawn(|| izquierda.iter().sum::<i32>());
let h2 = s.spawn(|| derecha.iter().sum::<i32>());
(h1.join().unwrap(), h2.join().unwrap())
});
println!("{a} + {b} = {}", a + b);
}Hora de practicar
Ejercicios
0/3 resueltos
Reto 1 de 3 Resuelto
nombre, que podría dejar de existir antes de que el hilo termine. Arréglalo para que el hilo se quede con ella. El hilo devuelve el saludo y main lo imprime tras hacer join.Hola, Ferris, desde otro hilo
Al ejecutar, unos tests ocultos comprueban tus funciones.
Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.