Guía completa de async/await en Rust para desarrollo backend

rust

Guía completa de async/await en Rust para desarrollo backend

Esta guía explica cómo usar async/await en Rust para construir backends robustos y performantes. Cubre runtimes (Tokio vs async-std), fundamentos de Future/Pin/Send, patrones prácticos (spawning, timeouts, streaming), integración con bases de datos y un ejemplo completo con Axum + sqlx. Incluye estructura de proyecto, código completo y el porqué de cada decisión.

1. ¿Por qué async en Rust?

El modelo async te permite manejar muchas conexiones concurrentes sin crear un hilo por conexión. Rust añade seguridad de memoria y tipos (Send/Sync) para evitar condiciones comunes en concurrencia. El coste es que necesitas entender Future, Send, Pin y el runtime.

2. Runtimes: Tokio vs async-std (breve)

  • Tokio: más maduro para servidores, ecosistema amplio (sqlx, hyper, tower, tonic), control fino (multi-thread, tiempo, sincronización). Recomendado para la mayoría de backends.
  • async-std: API similar a la estándar, buena para prototipos o proyectos que prefieren esa ergonomía. Ecosistema más pequeño.

En los ejemplos usaremos Tokio.

3. Fundamentos importantes

  • Future: una computación perezosa que produce un valor en el futuro. async fn devuelve impl Future.
  • Pin: asegura que un Future no se mueva en memoria si tiene referencias internas que dependen de su dirección.
  • Send/Sync: los objetos que envías entre threads deben ser Send; muchos runtimes exigen que tareas (closures, futures) sean Send.
  • Async drop: actualmente no existe async destructor; usa patrones explícitos para cleanup asíncrono.

4. Patrones comunes

  • spawn vs await: tokio::spawn(future) crea una tarea en el runtime; await bloquea la ejecución actual hasta completar el future.
  • spawn_blocking: para trabajo CPU-intensivo (no bloquear el runtime).
  • Timeouts: tokio::time::timeout para limitar duración de operaciones I/O o terceros.
  • Cancellation: drop de la Future cancela; usar tokio::select! para cancelación explícita.
  • Streaming: emitir datos poco a poco con streams y cuerpos de respuesta por partes.

5. Manejo de errores

Usa tipos de error bien definidos (thiserror) y, para capas superiores, anyhow o respuestas HTTP transformadas. Evita propagar cadenas genéricas.

6. Proyecto de ejemplo: API simple con Axum y sqlx (Postgres)

Objetivo: una API REST que liste y cree usuarios con conexión asíncrona a Postgres, manejo de timeouts y graceful shutdown.

Estructura de carpetas

rust-async-backend/
├─ Cargo.toml
└─ src/
   ├─ main.rs
   ├─ app.rs        // inicializa router y middlewares
   ├─ db.rs         // pool y funciones DB
   ├─ routes.rs     // handlers
   ├─ models.rs     // structs y conversiones
   └─ errors.rs     // tipos de error

Cargo.toml (dependencias principales)

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

[dependencies]
tokio = { version = "1.34", features = ["rt-multi-thread", "macros", "time"] }
axum = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.6", features = ["postgres", "runtime-tokio-rustls", "macros", "chrono", "uuid"] }
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1.0"

src/models.rs

use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Serialize, FromRow)]
pub struct User {
    pub id: Uuid,
    pub name: String,
    pub created_at: chrono::NaiveDateTime,
}

#[derive(Deserialize)]
pub struct CreateUser {
    pub name: String,
}

src/errors.rs

use axum::http::StatusCode;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("DB error: {0}")]
    Db(#[from] sqlx::Error),

    #[error("Not found")]
    NotFound,

    #[error("Bad request: {0}")]
    BadRequest(String),
}

impl ApiError {
    pub fn status_code(&self) -> StatusCode {
        match self {
            ApiError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
            ApiError::NotFound => StatusCode::NOT_FOUND,
            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
        }
    }
}

src/db.rs

use sqlx::PgPool;
use std::time::Duration;

pub async fn init_db(url: &str) -> anyhow::Result {
    // Pool optimizado: max_connections depende de tu carga y DB
    let pool = PgPool::connect_timeout(url, Duration::from_secs(5)).await?;
    Ok(pool)
}

pub async fn list_users(pool: &PgPool) -> Result, sqlx::Error> {
    let rows = sqlx::query_as::<_, crate::models::User>(
        "SELECT id, name, created_at FROM users ORDER BY created_at DESC LIMIT 100",
    )
    .fetch_all(pool)
    .await?;

    Ok(rows)
}

pub async fn create_user(pool: &PgPool, name: &str) -> Result<:models::user sqlx::error> {
    let row = sqlx::query_as::<_, crate::models::User>(
        "INSERT INTO users (name) VALUES ($1) RETURNING id, name, created_at",
    )
    .bind(name)
    .fetch_one(pool)
    .await?;

    Ok(row)
}

src/routes.rs

use axum::{
    extract::{Extension, Json},
    response::IntoResponse,
    routing::{get, post},
    Router,
};
use sqlx::PgPool;
use crate::{models::{CreateUser, User}, errors::ApiError};
use std::sync::Arc;
use tokio::time::timeout;
use std::time::Duration;

pub fn router(pool: Arc) -> Router {
    Router::new()
        .route("/users", get(list_users).post(create_user))
        .layer(Extension(pool))
}

async fn list_users(Extension(pool): Extension>) -> Result {
    // Timeout de 2s para la consulta
    let res = timeout(Duration::from_secs(2), crate::db::list_users(&pool))
        .await
        .map_err(|_| ApiError::BadRequest("DB timeout".into()))?
        .map_err(ApiError::Db)?;

    Ok((axum::http::StatusCode::OK, axum::Json(res)))
}

async fn create_user(
    Extension(pool): Extension>,
    Json(payload): Json,
) -> Result {
    if payload.name.trim().is_empty() {
        return Err(ApiError::BadRequest("name empty".into()));
    }

    // Ejemplo: spawn blocking si hicieras procesamiento costoso antes de DB
    let name = payload.name.clone();
    let pool_clone = pool.clone();

    // Aquí no es necesario spawn_blocking, pero se muestra cómo hacerlo
    let user = tokio::task::spawn_blocking(move || {
        // trabajo síncrono simulado
        std::thread::sleep(std::time::Duration::from_millis(10));
        // retornamos name para la siguiente async
        name
    })
    .await
    .map_err(|_| ApiError::BadRequest("task failed".into()))?;

    let created = crate::db::create_user(&pool_clone, &user).await.map_err(ApiError::Db)?;

    Ok((axum::http::StatusCode::CREATED, axum::Json(created)))
}

src/app.rs

use axum::Router;
use std::sync::Arc;
use sqlx::PgPool;

pub fn build_app(pool: Arc) -> Router {
    crate::routes::router(pool)
}

src/main.rs

use tokio::signal;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use std::net::SocketAddr;
use std::sync::Arc;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::from_default_env())
        .with(tracing_subscriber::fmt::layer())
        .init();

    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required");
    let pool = crate::db::init_db(&database_url).await?;
    let pool = Arc::new(pool);

    let app = crate::app::build_app(pool.clone());

    let addr: SocketAddr = "0.0.0.0:3000".parse().unwrap();
    let server = axum::Server::bind(&addr)
        .serve(app.into_make_service());

    tracing::info!(%addr, "listening");

    let graceful = server.with_graceful_shutdown(shutdown_signal());

    graceful.await?;

    Ok(())
}

async fn shutdown_signal() {
    // CTRL+C
    let _ = signal::ctrl_c().await;
    tracing::info!("signal received, starting graceful shutdown");
}

7. Explicación de decisiones

  • Usamos Tokio por ecosistema y control de hilos.
  • sqlx con PgPool para pooling eficiente y consultas async. No uses consultas que bloqueen dentro del runtime; si necesitas código síncrono pesado, usa spawn_blocking.
  • Timeouts protegen al servidor de operaciones colgadas y permiten devolver errores claros al cliente.
  • Separación en módulos mejora testabilidad y mantenimiento.

8. Errores comunes y cómo evitarlos

  • No marcar closures como Send cuando las tareas las necesitan: evita capturar referencias no-Send; clona los datos necesarios o usa Arc.
  • Bloquear el runtime con operaciones CPU pesadas: usa spawn_blocking.
  • Usar timeouts demasiado largos o inexistentes: siempre pon límites razonables en operaciones externas.
  • Ignorar backpressure: para streams grandes, usa paginación o streaming controlado.

9. Observabilidad y tuning

Usa tracing y tokio-console para diagnosticar tareas estancadas. Ajusta el pool de DB según el número de hilos de Tokio y la capacidad de tu DB. Mide con benchmarks reales de carga.

10. Pruebas y CI

Mockea la capa de DB con trait abstractions para poder testear handlers sin una DB real. En CI, usa containers (Postgres) o sqlx's offline mode para compilar las consultas.

Consejo avanzado: instrumenta tus futures con spans de tracing (tracing::instrument) para correlacionar latencias en handlers y consultas. Siguiente paso: integrar métricas (Prometheus) y probar con una carga real para ajustar pool/máquinas y evitar latencias de cola.

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