rust-threads-concurrencia-send

Threads en Rust: concurrencia segura con spawn y Send

  • 4 min

Un thread es un hilo de ejecución concurrente que puede avanzar en paralelo con otros hilos.

En el mundo moderno, las CPUs no se hacen mucho más rápidas, pero sí tienen más núcleos. Para aprovechar el hardware, necesitamos ejecutar tareas en paralelo.

Rust utiliza una relación 1:1 para los hilos. Esto significa que cuando creas un thread en Rust, se mapea directamente a un hilo nativo del Sistema Operativo.

Desventaja: Crear miles de hilos puede consumir mucha memoria; para cargas con muchísimas tareas concurrentes existen modelos asíncronos como async/await.

Crear un hilo con spawn

La función básica es std::thread::spawn. Recibe un Closure con el código a ejecutar.

use std::thread;
use std::time::Duration;

fn main() {
    // Creamos un hilo secundario
    thread::spawn(|| {
        for i in 1..10 {
            println!("Hilo secundario: número {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    // Código del hilo principal (main)
    for i in 1..5 {
        println!("Hilo PRINCIPAL: número {}", i);
        thread::sleep(Duration::from_millis(1));
    }
}
Copied!

Si ejecutas esto, notarás algo curioso: El hilo secundario probablemente se corte a la mitad. Cuando el hilo principal (main) termina, el programa entero se cierra, matando instantáneamente a todos los hilos secundarios, hayan terminado o no.

Esperar con join

Para evitar que el programa se cierre antes de tiempo, necesitamos guardar el “manejador” (JoinHandle) que devuelve spawn y decirle al hilo principal que espere.

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        println!("Trabajando en el hilo...");
    });

    // ... hacemos otras cosas en main ...

    // Bloqueamos main hasta que el hilo termine
    handle.join().unwrap();
}
Copied!

El método .join() bloquea el hilo actual hasta que el hilo del handle finalice. Devuelve un Result, por lo que usamos unwrap (si el hilo hubiera entrado en pánico, join devolvería un error).

Usar datos en hilos con move

En este caso el Borrow Checker demuestra su valía. Imagina que quieres usar un vector creado en main dentro de un hilo.

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        // ❌ Error: closure may outlive the current function,
        // but it borrows `v`, which is owned by the current function
        println!("Aquí está el vector: {:?}", v);
    });

    handle.join().unwrap();
}
Copied!

¿Por qué falla?

Rust razona así: “Estás lanzando un hilo. Ese hilo podría ejecutarse durante una hora. Pero la función main (o cualquier otra función donde estés) podría terminar en 1 milisegundo, liberando la memoria de v. Si permito esto, el hilo intentaría leer memoria liberada (Use After Free).”

Rust no sabe cuánto vivirá el hilo, así que asume que puede vivir más que la referencia.

La solución: move

Para arreglarlo, debemos forzar al Closure a tomar posesión (Ownership) de los valores que usa. Usamos la palabra clave move antes de las barras verticales.

    let handle = thread::spawn(move || {
        // Ahora el vector 'v' pertenece a este hilo.
        // Nadie más puede usarlo en 'main' después de esta línea.
        println!("Aquí está el vector: {:?}", v);
    });
Copied!

Al mover el dato, garantizamos que el dato vivirá exactamente tanto como el hilo, porque el hilo ahora es su dueño.

El trait Send

¿Te has preguntado por qué podemos enviar un Vec<i32> a otro hilo pero no otras cosas?

La firma de thread::spawn tiene un límite (Bound) muy importante:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
Copied!

Fíjate en Send.

Send es un Trait Marcador (Marker Trait). No tiene métodos. Solo sirve para marcar tipos que son seguros de transferir (mover) de un hilo a otro.

  • Casi todos los tipos de Rust son Send (i32, String, Vec, Box).
  • Si un struct está compuesto enteramente por tipos Send, es automáticamente Send.

¿Qué NO es Send?

El ejemplo clásico es Rc<T>. Como vimos en la categoría anterior, Rc usa un contador simple para saber cuándo liberar memoria. Ese contador NO es atómico.

Si enviáramos un Rc a otro hilo:

Hilo A clona el Rc (intenta sumar 1).

Hilo B clona el Rc (intenta sumar 1).

Sin sincronización atómica, podrían sobrescribirse mutuamente.

Resultado: El contador queda mal, y la memoria se liberará antes de tiempo (caos total).

Por eso, Rc no implementa Send. Si intentas hacer move de un Rc dentro de un thread::spawn, el compilador te dará un error.

// ❌ Error: `Rc<i32>` cannot be sent between threads safely
thread::spawn(move || {
    println!("{:?}", mi_rc);
});
Copied!

Para compartir propiedad entre hilos usamos normalmente Arc<T> (Atomic Reference Counting). Arc<T> puede implementar Send y Sync cuando el tipo contenido también cumple los límites necesarios.