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::timeoutpara 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
Sendcuando 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación