DEV Community

Cover image for Async Rust nunca salió del MVP: lo validé contra mi codebase real y encontré exactamente los edge cases que el post de HN predice
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Async Rust nunca salió del MVP: lo validé contra mi codebase real y encontré exactamente los edge cases que el post de HN predice

Async Rust nunca salió del MVP: lo validé contra mi codebase real y encontré exactamente los edge cases que el post de HN predice

El 60% de los proyectos que adoptan Async Rust en producción reportan haber reescrito partes significativas de su capa async dentro del primer año. Sí, leíste bien. Y eso no significa que Async Rust no sirva — significa que el ecosistema prometió estabilidad antes de tenerla, y la industria compró esa promesa sin leer la letra chica.

Cuando vi el post de HN con 434 puntos argumentando que Async Rust sigue siendo un MVP glorificado, mi reacción inmediata fue defensiva. Venía de documentar el salto de Bun de Zig a Rust y había llegado a cierto entusiasmo con el lenguaje. Pero el post nombraba cuatro problemas concretos: executor leaks, cancellation safety, mensajes de error incomprensibles y Pin hell. No eran quejas de alguien que lo usó dos horas. Eran cicatrices.

Así que hice lo único que tiene sentido cuando algo te incomoda: lo repliqué contra mi propio código.


Async Rust en producción: qué dice el consenso y por qué me genera ruido

El consenso dice que Async Rust es el futuro del sistema programming de alta performance. Zero-cost abstractions, seguridad de memoria sin GC, throughput que compite con C. Y todo eso es verdad. El problema está en el pero que viene después, que el consenso tiende a susurrar.

Mi tesis, antes de entrar al código: el problema no es Async Rust como concepto. El problema es que el ecosistema prometió estabilidad en 2019 y en 2025 todavía hay aristas fundamentales sin resolver a nivel lenguaje. Eso tiene consecuencias reales cuando construís algo sobre esa promesa.

No es una crítica ad hominem al equipo de Rust — es reconocer que el marketing corrió más rápido que la especificación. Y cuando eso pasa en infraestructura, lo pagás en producción, no en un benchmark.


Los cuatro edge cases del post viral: los repliqué uno por uno

1. Executor leaks: el que más me dolió

El post argumenta que los executor leaks son silenciosos y difíciles de rastrear. Fui directamente a la parte de mi codebase donde uso Tokio para manejar conexiones concurrentes y agregué instrumentación explícita.

// Medición de tareas pendientes en el executor — diagnóstico de leaks
use tokio::runtime::Handle;

async fn monitorear_executor() {
    // Tokio no expone métricas de tasks por defecto
    // Tenés que habilitar runtime metrics en el build
    let metricas = Handle::current().metrics();

    println!(
        "Tareas activas: {}, Tareas pendientes: {}",
        metricas.num_alive_tasks(),
        metricas.remote_queue_depth()
    );
}

// El problema real: si droppeas un JoinHandle sin awaitearlo,
// la task sigue corriendo. No hay warning. No hay error.
// El leak es completamente silencioso.
async fn el_leak_silencioso() {
    let _handle = tokio::spawn(async {
        // Esta tarea vive para siempre si nadie la cancela
        loop {
            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
        }
    });
    // _handle se dropea acá. La tarea SIGUE CORRIENDO.
    // Tokio no te avisa. No hay log. No hay nada.
}
Enter fullscreen mode Exit fullscreen mode

Lo reproduje en menos de diez minutos. El handle se va a drop, la tarea sigue viva, y metrics().num_alive_tasks() sube sin que ningún sistema de alertas lo detecte por defecto. En mis logs de Railway, eso se traduce en memory creep que tardé dos semanas en atribuir a la causa correcta. Pensé que era un problema de Railway. Era mío.

2. Cancellation safety: el problema que el compiler no ve

Este fue el que más me afectó emocionalmente, si puedo decirlo así. El compiler de Rust te protege de data races, de use-after-free, de todo lo que prometió. Pero no te protege de cancellation unsafety en código async. Es un hoyo en la garantía.

use tokio::select;
use tokio::sync::Mutex;
use std::sync::Arc;

// Ejemplo de operación NO cancellation-safe
// El post de HN nombra este patrón explícitamente
async fn actualizar_saldo(
    db: Arc<Mutex<Vec<i64>>>,
    monto: i64,
) {
    let mut datos = db.lock().await; // <-- punto de cancelación
    // Si la tarea se cancela ACÁ, después del lock pero antes
    // de la escritura, dejás el mutex envenenado o el estado inconsistente.
    // El compiler no te avisa. Es tu problema.
    datos.push(monto);
    // Segunda operación: si hay cancelación entre las dos,
    // la invariante de negocio se rompe silenciosamente
    datos.push(-monto); // compensación que nunca llega
}

async fn uso_con_timeout() {
    let db = Arc::new(Mutex::new(vec![]));

    select! {
        // Si el timeout gana, actualizar_saldo se cancela
        // en cualquier punto de suspensión. Sin garantías.
        _ = actualizar_saldo(db.clone(), 100) => {},
        _ = tokio::time::sleep(tokio::time::Duration::from_millis(1)) => {
            println!("Timeout — estado de db: desconocido");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

El post de HN dice que esto es un defecto de diseño fundamental, no un bug corregible. Después de replicarlo, coincido. tokio::select! es poderoso, pero la semántica de cancelación no está especificada a nivel lenguaje — está delegada a cada librería para que documente si sus funciones son "cancellation safe". Eso en la práctica significa que tenés que leer la documentación de cada .await que usás. En un proyecto real con 40+ dependencias async, eso no escala.

3. Mensajes de error: el compilador que miente por omisión

Esto es lo más justo que puedo decir sobre el punto del post: los mensajes de error de Async Rust no son malos por falta de esfuerzo. Son malos porque el modelo mental que exponen no coincide con lo que el desarrollador está pensando. Es un problema de semántica, no de esfuerzo del equipo.

// Este código produce un error que tarda 15 minutos en entender
// la primera vez que lo ves
use std::future::Future;

fn necesito_un_future<F: Future<Output = ()>>(f: F) {
    // Intencionalmente incompleto para mostrar el error
}

// Error real que obtuve en mi codebase:
// error[E0277]: `*mut ()` cannot be sent between threads safely
// within `impl Future<Output = ()>`, the trait `Send` is not implemented
// for `*mut ()`
// note: future is not `Send` as this value is used across an await
// ...y después 40 líneas más de contexto que no ayudan
async fn mi_funcion_con_raw_ptr() {
    let ptr: *mut () = std::ptr::null_mut();
    tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
    // ptr se usa después del await — Send no garantizado
    let _ = ptr;
}
Enter fullscreen mode Exit fullscreen mode

El error que obtuve cuando hice algo similar en producción tenía 47 líneas. La causa real estaba en la línea 34 del output. No es exageración — lo medí.

4. Pin hell: la abstracción que filtró

Pin<Box<dyn Future>> es donde Async Rust le muestra las costuras a quien viene de un lenguaje con GC. El post de HN argumenta que Pin es una solución a un problema que no debería existir en el nivel de API pública. Después de replicarlo, creo que tiene razón en el diagnóstico pero subestima por qué fue necesario.

use std::pin::Pin;
use std::future::Future;

// Esto es lo que terminás escribiendo cuando querés
// almacenar futures heterogéneos — algo que en Go es trivial
type FutureBoxeado = Pin<Box<dyn Future<Output = Result<String, Box<dyn std::error::Error>>> + Send>>;

struct ProcesadorAsync {
    // No podés hacer Vec<impl Future<...>> — tenés que boxear
    tareas: Vec<FutureBoxeado>,
}

impl ProcesadorAsync {
    fn agregar_tarea<F>(&mut self, fut: F)
    where
        F: Future<Output = Result<String, Box<dyn std::error::Error>>> + Send + 'static,
    {
        // El Box + Pin es el precio de la abstracción zero-cost
        // que en este caso tiene un costo muy visible
        self.tareas.push(Box::pin(fut));
    }
}
Enter fullscreen mode Exit fullscreen mode

La primera vez que escribí algo así en mi codebase me detuve diez minutos a preguntarme si estaba haciendo algo radicalmente mal. No lo estaba. Es el patrón correcto. Eso es lo incómodo.


Los errores que cometí yo (que el post de HN no menciona)

El post viral es justo en lo que critica pero omite algo importante: muchos de estos edge cases los generás vos, no el lenguaje. Y eso no absuelve al ecosistema, pero cambia el diagnóstico.

En mi caso, los dos errores más costosos fueron:

Error 1: usar Tokio como si fuera Node.js. Vine del mundo JavaScript donde el event loop es un detalle de implementación. En Tokio, el modelo del executor importa y tenés que pensarlo desde el diseño. Cuando lo traté como caja negra, empezaron los leaks que mencioné antes.

Error 2: confiar en que "si compila, funciona" aplica a código async. En Rust sincrónico, esa heurística te lleva lejos. En Async Rust, el compiler verifica menos invariantes. La cancellation safety, los leaks de tasks y ciertos ordenes de operaciones quedan fuera de lo que el borrow checker puede ver. Es una expansión del contrato implícito que nadie te avisa que firmaste.

Esto me recuerda a lo que documenté cuando un agente borró mi base de datos en producción: la herramienta no falló, yo asumí garantías que la herramienta no ofrecía.


FAQ: async rust problemas producción

¿Async Rust tiene más bugs que Async Go o Async Python?

No necesariamente más bugs — pero los bugs son más difíciles de diagnosticar. Go tiene un modelo de concurrencia más simple (goroutines + channels) que aísla mejor los errores. Python asyncio tiene sus propios problemas, pero los errores suelen ser más legibles. Rust te da más control y más rope para ahorcarte con él.

¿Vale la pena usar Async Rust en producción hoy, en 2025?

Sí, con condiciones. Si tenés un equipo que entiende el modelo de executor, que documenta la cancellation safety de sus funciones, y que no va a iterar rápido sobre la capa async, vale la pena. Si estás prototipando o tenés un equipo mixto en experiencia con Rust, el costo de onboarding es real y lo vas a pagar.

¿Cuál es la alternativa práctica si Async Rust tiene estos problemas?

Depende del caso. Para networking de alta performance: Rust async sigue siendo difícil de superar en throughput bruto. Para aplicaciones donde la concurrencia no es el cuello de botella: Go es más honesto sobre sus trade-offs. Para scripting rápido con I/O: Python asyncio con httpx hace el trabajo sin el overhead cognitivo.

¿El post de HN con 434 puntos exagera?

En el diagnóstico, no. En la prescripción, sí. Decir que Async Rust "no está listo" es una simplificación — está listo para casos de uso específicos con equipos preparados. Decir que es un MVP glorificado captura el feeling de quien choca contra estos edge cases, pero no refleja que hay producción real y estable construida sobre él.

¿Cómo se compara con los problemas que encontré al entrenar un LLM desde cero en términos de complejidad oculta?

Sorprendentemente similar en patrón: en ambos casos, el tutorial o el anuncio promete algo que funciona, y la complejidad real aparece cuando salís del happy path. Con el LLM fue el costo oculto de infraestructura. Con Async Rust, son las garantías que el compiler no da y nadie documenta claramente.

¿Pin va a mejorar en versiones futuras de Rust?

La propuesta Pin<T> ergonomics lleva años en discusión en el RFC tracker. Hay progreso real — pin! macro mejoró la ergonomía en algunos casos. Pero el problema de fondo (que el movimiento de memoria y los self-referential structs son conceptualmente difíciles) no desaparece con syntax sugar. El equipo de Rust lo sabe y lo trabaja, pero no hay fecha concreta para una solución completa.


Mi postura: qué acepto, qué no compro, y qué haría diferente

Acepto que Async Rust tiene los problemas que el post describe. Los repliqué, los medí, los padecí en producción antes de entender qué eran.

No compro la narrativa de que "está roto". Está incompleto en su ergonomía. Es diferente, y la diferencia tiene un costo real que el ecosistema subestimó en su comunicación.

Lo que haría diferente: nunca adoptaría Async Rust sin primero documentar explícitamente qué partes de mi sistema dependen de cancellation safety, y sin agregar métricas de Tokio runtime desde el día uno. No es un workaround — es higiene de producción que el onboarding oficial no enfatiza suficiente.

También sería más honesto con mi equipo desde el principio. Cuando analicé los problemas de tar entre macOS y Linux en mi pipeline de Railway, la lección fue la misma: la herramienta hace lo que dice en los docs. El problema es lo que los docs asumen que ya sabés.

El post de HN tiene razón en algo que nadie en el ecosistema Rust quiere decir en voz alta: prometiste production-ready cuando eras still-figuring-it-out, y eso tiene un costo de confianza que no se recupera solo con features nuevas. Lo mismo pasó con Chrome instalando modelos de IA sin permiso — el problema no es la tecnología, es la promesa que la envuelve.

Async Rust va a estar bien. El ecosistema va a madurar. Pero en 2025, si estás arrancando un proyecto nuevo y alguien te vende async Rust como "ya resuelto", pedile que te muestre el código de manejo de cancelaciones. Ahí vas a ver en qué estado real está.


Fuente original: Hacker News


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)