rust-tokio-spawn-join-select

Tokio en Rust: spawn, join! y select! en tareas async

  • 4 min

Una task de Tokio es una unidad ligera de trabajo asíncrono gestionada por el runtime.

En el artículo anterior aprendimos la sintaxis async y .await. Pero si escribimos tarea_1().await; tarea_2().await;, no estamos ejecutando las tareas a la vez. Primero termina una y luego empieza la otra.

Para ejecutar los futuros de forma concurrente necesitamos herramientas como tokio::spawn, tokio::join! y tokio::select!.

  • Hilo del sistema operativo: Reserva su propia pila y lo planifica el kernel.
  • Tarea de Tokio: Es mucho más ligera y la planifica el runtime sobre un conjunto de hilos.

Esto significa que puedes lanzar millones de tareas en Tokio sin saturar la memoria de tu servidor.

Lanzar tareas con tokio::spawn

Para ejecutar un futuro en “segundo plano” (sin bloquear tu flujo actual esperando a que termine), usamos tokio::spawn. Es el equivalente asíncrono de std::thread::spawn.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    // Lanzamos una tarea al fondo.
    // main NO se detiene aquí.
    let handle = tokio::spawn(async {
        sleep(Duration::from_secs(2)).await;
        println!("Tarea de fondo terminada");
        "Éxito"
    });

    println!("Haciendo otras cosas en el hilo principal...");
    sleep(Duration::from_secs(1)).await;
    println!("El main sigue trabajando...");

    // Opcional: Esperamos a que la tarea termine para obtener su valor
    let resultado = handle.await.unwrap();
    println!("Resultado: {}", resultado);
}
Copied!

El límite 'static La tarea enviada a spawn no puede contener referencias a variables locales que podrían desaparecer antes que ella. Un bloque async move permite capturar valores por propiedad, y Arc sirve cuando necesitamos compartir esa propiedad.

Concurrencia con tokio::join!

Imagina que tienes que hacer dos peticiones HTTP a dos servidores distintos. Si haces:

let res1 = pedir_servidor_a().await; // Tarda 2s
let res2 = pedir_servidor_b().await; // Tarda 2s
// Total: 4 segundos
Copied!

Esto es ineficiente. Queremos lanzarlas a la vez y esperar a que ambas terminen. Para eso usamos la macro tokio::join!.

use tokio::time::{sleep, Duration};

async fn tarea_a() -> u8 {
    sleep(Duration::from_secs(2)).await;
    println!("A terminada");
    10
}

async fn tarea_b() -> u8 {
    sleep(Duration::from_secs(1)).await; // Esta es más rápida
    println!("B terminada");
    20
}

#[tokio::main]
async fn main() {
    println!("Iniciando carrera...");

    // Lanzamos ambas a la vez y esperamos
    let (res_a, res_b) = tokio::join!(tarea_a(), tarea_b());

    // El tiempo total será el de la más lenta (2s), no la suma (3s).
    println!("Resultados: A={}, B={}", res_a, res_b);
}
Copied!

Nota: join! ejecuta los futuros concurrentemente dentro de la misma tarea y los sondea en el hilo que la esté ejecutando. No aporta paralelismo por sí solo; para eso tendríamos que lanzar tareas separadas.

Esperar al primero con tokio::select!

A veces no quieres esperar a que terminen todas las tareas. A veces solo te importa la primera que termine.

Ejemplos:

  • Intentar conectar a 3 servidores y quedarse con el que responda antes.
  • Esperar una respuesta con un Timeout (carrera entre la respuesta y el reloj).

Para esto usamos tokio::select!.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let carrera = tokio::select! {
        res_a = tarea_lenta() => {
            format!("Ganó la tarea: {}", res_a)
        }
        _ = sleep(Duration::from_millis(500)) => {
            "Ganó el reloj (Timeout)"
        }
    };

    println!("Resultado: {}", carrera);
}

async fn tarea_lenta() -> String {
    sleep(Duration::from_secs(2)).await;
    String::from("Completado")
}
Copied!

¿Qué pasa con el perdedor? Cuando select! termina porque una rama ha ganado, descarta los futuros de las otras ramas. Conviene comprobar que las operaciones sean seguras ante cancelación, especialmente al usar select! dentro de un bucle.

Descartar un JoinHandle no cancela la tarea creada con tokio::spawn; esa tarea continúa en segundo plano. Si necesitamos detenerla explícitamente, podemos usar abort() o diseñar un mecanismo de cancelación cooperativa.

Estado compartido en async

En el artículo de concurrencia vimos std::sync::Mutex. En el mundo asíncrono debemos evitar bloquear un hilo del runtime durante una espera larga.

Si usas std::sync::Mutex y llamas a .lock(), si el candado está cerrado, bloquearás al hilo del Runtime de Tokio. Si tienes pocos hilos en el runtime, podrías colgar todo el servidor.

Tokio ofrece su propio tokio::sync::Mutex. Su método .lock() es asíncrono (.lock().await).

use tokio::sync::Mutex; // ¡Ojo al import!
use std::sync::Arc;

#[tokio::main]
async fn main() {
    let contador = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let c = contador.clone();
        handles.push(tokio::spawn(async move {
            // El .await aquí suspende la tarea, no el hilo,
            // si el candado está ocupado.
            let mut num = c.lock().await;
            *num += 1;
        }));
    }

    for h in handles {
        h.await.unwrap();
    }

    println!("Contador: {}", *contador.lock().await);
}
Copied!

Criterio práctico del Mutex

  • Si la sección crítica es corta y no mantienes la guarda durante un .await, std::sync::Mutex suele ser apropiado y tiene menos coste.
  • Si la guarda debe sobrevivir a un .await, tokio::sync::Mutex está diseñado para ello. Aun así, mantener bloqueado un recurso durante una operación de red suele merecer una revisión del diseño.