Guía completa de async/await en Rust para desarrolladores

rust Guía completa de async/await en Rust para desarrolladores

Guía completa de async/await en Rust para desarrolladores

Esta guía explica cómo pensar, diseñar y programar con async/await en Rust: qué hay debajo del azúcar sintáctica, por qué existen runtimes, patrones prácticos, plantilla de proyecto completa y trampas comunes. Si ya sabes usar async en otros lenguajes, aquí verás lo esencial para evitar errores típicos en Rust.

Índice rápido

  • ¿Qué es async en Rust y cómo funciona?
  • Runtimes: Tokio vs async-std (breve)
  • Futures, Pin y poll: el porqué
  • Patrones comunes y código práctico
  • Proyecto ejemplo: cliente concurrente con límite y procesamiento
  • Errores comunes y cómo evitarlos
  • Herramientas de observabilidad y optimización

¿Qué es async en Rust y cómo funciona?

El async/await en Rust es azúcar sintáctica que genera una estructura que implementa la trait Future. Un async fn devuelve una Future que, al ser "polleada" (poll), avanza hasta el próximo punto de espera (.await) o completa el resultado. Rust no tiene runtime builtin: necesitas un executor que llame a poll sobre las futures y gestione la ejecución.

Por qué esto importa

  • Control explícito del ejecutor → menores sorpresas en rendimiento.
  • Seguridad de memoria: no hay GC, así que el compilador forza propiedades de vida y ownership.
  • Necesidad de entender Send/Sync y Pin para evitar errores difíciles.

Runtimes: Tokio vs async-std

Tokio es el runtime más usado en producción (ecosistema amplio: hyper, reqwest, tracing). async-std es más cercano a la API de la std y útil para prototipos. Para la mayoría de proyectos de servidores/cliente a escala, recomienda: Tokio.

Futures, Pin y poll — el porqué técnico

Una Future es esencialmente un estado que sabe cómo continuar. El método core es poll(&mut self, cx: &mut Context). El objeto Future guarda estado interno (variables locales del async fn). A veces el objeto debe permanecer en una dirección de memoria fija porque contiene referencias internas; por eso aparece Pin. Si necesitas almacenar una future auto-referencial debes pinnearla (Box::pin o pin-utils/pin-project).

Patrones comunes y ejemplos prácticos

1) async fn simple

async fn fetch_url(url: &str) -> Result {
    let body = reqwest::get(url).await?.text().await?;
    Ok(body)
}

Por qué: reqwest::get devuelve una Future; el hilo no se bloquea, el executor continúa con otras tareas.

2) Concurrencia limitada (Semaphore)

use tokio::sync::Semaphore;
use std::sync::Arc;

async fn process_urls(urls: Vec<String>) {
    let sem = Arc::new(Semaphore::new(10)); // máximo 10 concurrentes

    let mut handles = Vec::new();
    for url in urls {
        let permit = sem.clone().acquire_owned().await.unwrap();
        let h = tokio::spawn(async move {
            // el permit se libera al caer
            let _p = permit;
            let _ = fetch_url(&url).await;
        });
        handles.push(h);
    }

    for h in handles { let _ = h.await; }
}

Por qué: evitar saturar DNS/sockets o memoria al lanzar infinitas tareas.

3) Cancelación y tokio::select!

let work = async {
    // trabajo largo
};
let timeout = tokio::time::sleep(Duration::from_secs(5));

tokio::select! {
    _ = work => println!("completado"),
    _ = timeout => println!("timeout"),
}

Por qué: manejar timeouts o señales de cancelación de forma elegante.

4) Trabajo CPU-bound: spawn_blocking

let result = tokio::task::spawn_blocking(|| heavy_cpu_calc()).await.unwrap();

Por qué: no bloquear el reactor del runtime (evita desastres de latencia).

5) Traits asíncronos: async_trait

use async_trait::async_trait;

#[async_trait]
trait Fetcher {
    async fn fetch(&self, url: &str) -> Result;
}

struct MyFetcher;

#[async_trait]
impl Fetcher for MyFetcher {
    async fn fetch(&self, url: &str) -> Result {
        reqwest::get(url).await?.text().await
    }
}

Por qué: Rust no soporta aún async fn en traits sin ayuda; async_trait genera el boilerplate.

Proyecto ejemplo: cliente concurrente con límite y pipeline

Objetivo: descargar varias URLs concurrentemente, procesar el HTML (simulado) y almacenar resultados en un vector compartido. Usamos Tokio, reqwest, tracing y thiserror.

Estructura de carpetas

rust-async-client/
├── Cargo.toml
└── src
    ├── main.rs
    ├── fetcher.rs
    └── processor.rs

Cargo.toml

[package]
name = "rust-async-client"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
reqwest = { version = "0.11", features = ["json", "gzip"] }

tracing-subscriber = "0.3"
thiserror = "1"
async-trait = "0.1"

src/fetcher.rs

use reqwest::Error;

pub async fn fetch_url(url: &str) -> Result {
    let resp = reqwest::get(url).await?;
    let body = resp.text().await?;
    Ok(body)
}

src/processor.rs

pub fn parse_title(html: &str) -> Option<String> {
    // Simulamos un parseo simple
    html.split("<title>")
        .nth(1)
        .and_then(|s| s.split("</title>").next())
        .map(|s| s.to_string())
}

src/main.rs

mod fetcher;
mod processor;

use tokio::sync::Semaphore;
use std::sync::Arc;
use tracing::{info, error};

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let urls = vec![
        "https://example.com".to_string(),
        // más urls...
    ];

    let sem = Arc::new(Semaphore::new(8));
    let mut handles = vec![];
    let results = Arc::new(tokio::sync::Mutex::new(Vec::new()));

    for url in urls {
        let permit = sem.clone().acquire_owned().await.unwrap();
        let results = results.clone();
        let h = tokio::spawn(async move {
            let _permit = permit; // se libera al caer
            match fetcher::fetch_url(&url).await {
                Ok(body) => {
                    if let Some(title) = processor::parse_title(&body) {
                        info!(url = %url, title = %title, "fetched");
                        results.lock().await.push((url, title));
                    } else {
                        info!(url = %url, "no title");
                    }
                }
                Err(e) => error!(url = %url, error = %e, "fetch error"),
            }
        });
        handles.push(h);
    }

    for h in handles { let _ = h.await; }

    let res = results.lock().await;
    info!(count = res.len(), "done");
}

Por qué está así: usamos Semaphore para limitar concurrencia, Mutex async para compartir resultados, y tokio::spawn para paralelizar IO. Tracing permite observabilidad sin bloquear.

Errores comunes y cómo evitarlos

  • Bloquear el runtime con operaciones CPU-bound: usa spawn_blocking.
  • Confundir Send: muchas APIs requieren futures Send para spawn; usa Arc en vez de Rc y evita referencias no-Send.
  • Crear un deadlock con Mutex sin .await fuera del scope del lock: no awaits mientras mantienes locks globales.
  • Olvidar pin al almacenar futures auto-referenciales: usa Box::pin o crates como pin-project.

Observabilidad y optimización

  • tracing + tracing-subscriber: logging estructurado y spans.
  • tokio-console: inspección de tasks en runtime (útil para encontrar blocked tasks).
  • Perf y flamegraphs: para trabajo CPU-bound en spawn_blocking.
  • Evitar allocations innecesarias: reutiliza buffers, considera Bytes y streaming (reqwest supports streaming bodies).

Checklist rápido antes de deployar

  • ¿Estás usando un runtime multi-thread para server? (tokio::main con rt-multi-thread)
  • ¿Bloqueos y operaciones pesadas en spawn_blocking?
  • ¿Concurrencia limitada donde necesario?
  • ¿Métricas y logging integrados?

Recursos recomendados

  • Documentación de Tokio: https://tokio.rs
  • Libros: "Asynchronous Programming in Rust"
  • Crates: reqwest, hyper, tower, tracing, tokio-console

Consejo avanzado: si necesitas APIs async en traits con cero overhead y control fino del estado, estudia las técnicas de "manual state machines" y pin-projection (pin-project), y considera evitar async_trait en hot paths para reducir allocations. Advertencia: manipular Pin/unsafe mal puede causar UB — antes de tocar unsafe, perfila y asegúrate de que realmente lo necesitas. Siguiente paso: construye un servicio small-scale con hyper/tokio y agrega tokio-console y tracing para observar la telemetría en producción.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión