Concurrencia sin miedo en Rust

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:

Sin protección frente a Arc y Mutex SIN PROTECCIÓN hilo A hilo B contador no compila CON Arc<Mutex<T>> hilo A hilo B candado uno a uno dato compila, y da siempre lo mismo La carrera de datos deja de ser un fallo que se persigue con un depurador: pasa a ser un programa que no existe.
No es que Rust detecte la carrera y avise. Es que el tipo que permitiría escribirla no se puede construir.

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.

Un canal mpsc hilo 1 tx.send(…) hilo 2 tx.send(…) hilo 3 tx.send(…) el canal (mpsc) varios emisores, un receptor for x in rx recibe según van llegando El nombre lo dice todo: mpsc es «multiple producer, single consumer», varios que producen y uno que consume.
El valor enviado deja de pertenecer al hilo que lo envió, así que no hay nada compartido que proteger.

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.

Hilos con ámbito, para no necesitar 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.

Lee el error, que te está diciendo el diseño. Un `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

Miniatura del vídeo «Concurrencia en Rust | Curso Rust | Stan Tech», del canal Stan Tech

«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.