Por qué depurar async es difícil: la pila miente y la tarea no está ahí
Las técnicas de depuración clásicas asumen que la pila del hilo cuenta la historia del programa. En async esa premisa se rompe: el stack trace muestra la maquinaria del executor y no tu cadena de await, las tareas saltan de un hilo a otro entre suspensiones, y un breakpoint congela un hilo pero jamás captura una tarea dormida que vive como dato en el heap. Entender por qué falla cada herramienta es el primer paso para cambiar de método.
Todo lo que aprendiste sobre depurar descansa en una premisa silenciosa: que la pila de llamadas es la historia del programa. El depurador para el hilo, lees la pila hacia arriba y ves quién llamó a quién; pones un breakpoint y atrapas el flujo en el acto. Async rompe esa premisa de raíz. Cuando escribes .await, el compilador no genera una llamada que se apila: genera una máquina de estados que retorna al executor cada vez que tendría que esperar. La consecuencia es que la pila del hilo ya no refleja tu lógica —refleja el bucle del runtime—, tus tareas saltan de un hilo a otro entre una suspensión y la siguiente, y una tarea dormida no está en ninguna pila: es un valor aparcado en el heap. Las tres herramientas que dabas por descontadas —el backtrace, el razonamiento por hilo, el breakpoint— fallan a la vez, y no por un defecto del tooling, sino porque async reificó el flujo de control en datos. Depurar async empieza por entender por qué las viejas herramientas mienten.
- Entender por qué un stack trace en async muestra la maquinaria del executor y no tu cadena lógica de
.await. - Ver cómo el planificador con robo de trabajo hace que una tarea migre de hilo, rompiendo la intuición “un flujo, un hilo”.
- Comprender por qué un breakpoint congela un hilo pero no captura una tarea suspendida, que vive como dato en el heap.
- Adoptar el cambio de método que exigen las lecciones siguientes: observar el runtime y las tareas, no inspeccionar la pila.
La pila muestra al executor, no a tu lógica
En código síncrono, capturar un backtrace es capturar la verdad: cada marco es una llamada viva, y leerlos de abajo arriba reconstruye “main llamó a atender, que llamó a consultar”. Esa cadena existe porque una llamada síncrona se apila y no se desapila hasta retornar. En async no ocurre nada de eso. Recuerda la máquina de estados del nivel 32: un async fn se compila a un Future que, al llegar a un .await cuyo dato aún no está listo, devuelve Poll::Pending hacia arriba hasta el executor. La pila del sistema operativo se desapila por completo en cada suspensión y vuelve al bucle de sondeo del runtime.
use tracing::info;
async fn atender(id: u64) {
let datos = consultar(id).await; // aqui la pila del SO vuelve al executor
info!(bytes = datos.len(), "consulta lista");
}
Si capturas un backtrace dentro de consultar, hacia arriba no verás atender seguido de quien lanzó la tarea. Verás algo así:
3: consultar::{{closure}}
4: <consultar::Future as Future>::poll
5: atender::{{closure}}
6: <tokio::runtime::task::...>::poll
7: tokio::runtime::scheduler::multi_thread::worker::run
8: std::sys::backtrace::__rust_begin_short_backtrace
Tu marco está envuelto en poll, y por encima solo hay executor y hilo trabajador. Quién hizo spawn de esta tarea y quién la está esperando no aparecen por ningún lado: esa relación lógica —la que en síncrono era la pila— ahora vive dispersa en las estructuras del runtime, no en el hilo. El backtrace no miente sobre el instante presente; miente sobre la historia, porque la historia de una tarea async no cabe en una pila que se desapila mil veces por segundo.
flowchart TD A[Backtrace capturado dentro del await] --> B[poll de tu future] B --> C[poll de la maquina de estados generada] C --> D[bucle del executor] D --> E[hilo trabajador del runtime] F[Quien hizo spawn de la tarea] -.no aparece.-> A G[Quien te espera con await] -.no aparece.-> A style A fill:#f38ba8,color:#11111b style D fill:#89b4fa,color:#11111b style F fill:#45475a,color:#cdd6f4 style G fill:#45475a,color:#cdd6f4
La tarea salta de hilo entre suspensiones
El segundo pilar que se derrumba es “un flujo de ejecución vive en un hilo”. El runtime multihilo de Tokio usa un planificador con robo de trabajo: un hilo trabajador ocioso roba tareas de la cola de otro para no quedarse quieto. La consecuencia es que una tarea sondeada en el trabajador 2 puede, tras un .await, reanudarse en el trabajador 5. No hay ninguna afinidad entre una tarea y un hilo.
async fn procesar() {
// Puede estar corriendo en el worker 2...
let x = fase_uno().await;
// ...y despues de este await, reanudarse en el worker 5.
fase_dos(x).await;
}
Esto pulveriza cualquier razonamiento basado en el identificador de hilo. Un log que imprime el thread id para “seguir” una operación es inútil: la misma tarea aparecerá bajo tres identificadores distintos a lo largo de su vida. Un thread_local! observado antes y después de un .await puede haber cambiado de valor sin que tú tocaras nada, porque ya no es tu hilo. Y es precisamente por esta migración por lo que el compilador exige que todo lo que cruce un .await sea Send: la tarea podría mudarse de hilo en ese punto, y un dato !Send viajando entre hilos sería justo la carrera de datos que Rust prohíbe. La regla que el nivel 30 te presentó como una restricción de tipos es, vista desde aquí, la sombra de un hecho de ejecución: tus tareas son nómadas.
El breakpoint congela un hilo, no una tarea
El tercer golpe es el más desconcertante. Un breakpoint detiene un hilo; pero la mayoría de tus tareas, en cualquier instante, no están corriendo en ningún hilo: están dormidas, aparcadas como valores a la espera de que un waker las despierte. Una tarea suspendida es la máquina de estados en reposo, guardada en una estructura del runtime, sin marco de pila, sin registros, sin nada que un depurador tradicional sepa inspeccionar.
Diez mil tareas invisibles
Un servidor con diez mil conexiones ociosas tiene diez mil tareas dormidas. Ninguna está en una pila. El depurador no puede listarlas, contarlas ni ver en qué .await se quedó cada una.
Atrapas la tarea equivocada
Al parar un hilo trabajador cazas la tarea que justo ahora se está sondeando, con la máquina de estados a medio desempaquetar en locales. Casi nunca es la que te interesa.
El step no cruza el await
No puedes “entrar” en un .await hasta la reanudación: esta ocurre más tarde, quizá en otro hilo, disparada por un waker. El paso a paso pierde el hilo lógico en cada suspensión.
La causa está en otro sitio
Que una tarea no progrese casi nunca se ve en la que está corriendo, sino en cuál no despierta y por qué. Eso no se lee en una pila; se observa en el runtime.
Poner un breakpoint en consultar y esperar a “ver el flujo” caza un fotograma arbitrario de una de las mil tareas que pasaban por ahí, arrancada de su contexto. Y lo que de verdad quieres saber cuando algo va mal —¿qué tarea está atascada?, ¿en qué .await se durmió?, ¿por qué su waker no la despierta?— es exactamente lo que un depurador de pila no puede contarte, porque la respuesta no está en ninguna pila. Está en la población de tareas dormidas que el runtime administra y que ninguna herramienta clásica sabe mirar.
Supón que aciertas y el breakpoint cae justo en la tarea que investigas. Ni así recuperas la comodidad síncrona: los locales de una async fn que sobreviven a un .await no viven en la pila, sino empaquetados como campos de la enum que el compilador generó para la máquina de estados. El depurador te los muestra —si acaso— como campos anónimos de un tipo de nombre ilegible como atender::{{closure}}, no como tus variables con su nombre. La representación por la que async es tan barata en memoria —guardar solo lo que cruza cada suspensión— es la misma que vuelve opaca la inspección. El dato está ahí, pero desestructurado en la forma que le conviene al runtime, no a ti.
La reacción instintiva —“si el depurador falla, meto println!”— también se rompe aquí, aunque de forma menos obvia. La salida de mil tareas multiplexadas sobre ocho hilos se entrelaza: líneas de operaciones distintas se intercalan sin orden, y no hay forma de saber qué println! pertenece a qué tarea, porque el identificador de hilo ya no las distingue. Necesitas que cada mensaje arrastre la identidad de su tarea y el árbol de operaciones que lo contiene. Eso es exactamente lo que aporta tracing, el tema de la próxima lección.
Entonces, cómo se depura async
Si las tres herramientas clásicas fallan a la vez, el resto del nivel las reemplaza una por una, y siempre en la misma dirección: dejar de leer la pila y empezar a observar la tarea.
tracing(lección 2) reconstruye la historia que el backtrace perdió: spans y events que siguen a la tarea aunque salte de hilo.tokio-console(lección 3) da la vista de población que ninguna pila contiene: cada tarea, su tiempo y sus wakeups, en vivo.- Deadlocks y stalls (lección 4) enseña a leer el silencio de un
.awaitque no vuelve, contimeouty los volcados de tareas. - Profiling (lección 5) mide lo que de verdad importa en async: el poll time, la latencia que una tarea reparte a las demás, no el porcentaje de CPU.
Ninguna es un parche sobre el depurador roto; todas operan en la capa a la que async trasladó la verdad. Este primer capítulo era el diagnóstico. Los cuatro siguientes son el tratamiento.
Detrás de los tres fallos hay una sola causa, y entenderla convierte la frustración en método. En un lenguaje síncrono, el flujo de control es implícito: vive en el hardware, en el puntero de pila y de instrucción, en el acto físico de apilar y desapilar marcos. Esa implicitud es lo que hace que la pila sea la historia y que el depurador, que sabe leer pilas, sea omnisciente. Async da un paso conceptual enorme: toma ese flujo de control implícito y lo reifica, lo convierte en un valor de primera clase —la máquina de estados— que puedes guardar, mover entre hilos, tener diez mil a la vez y aparcar en el heap. Ganas con ello todo lo que el nivel 32 celebraba: concurrencia baratísima, tareas por millones, espera desacoplada del hilo. Pero cada ganancia tiene su sombra en la depuración, y las tres sombras son la misma. El backtrace miente porque el flujo lógico ya no está en la pila, sino en el objeto-future y en las relaciones de spawn y await que el runtime guarda aparte. El razonamiento por hilo se rompe porque la unidad de flujo dejó de estar atada a un hilo: ahora es un dato que migra. Y el breakpoint no captura la tarea dormida porque no hay marco que congelar: el estado suspendido es un valor en reposo, no una ejecución detenible. La moraleja no es que async sea indepurable, sino que exige mudar el objeto de observación. Dejas de preguntar “¿qué hay en la pila de este hilo?” y empiezas a preguntar “¿qué le pasa a esta tarea: qué eventos emitió, cuánto tardó cada sondeo, por qué no la despierta su waker?”. Toda la caja de herramientas de los niveles que siguen —tracing para la historia, tokio-console para la población de tareas, el profiling de poll times— no son parches sobre un depurador roto: son las herramientas nativas de un mundo donde el flujo de control es dato, y a los datos se los instrumenta y se los observa, no se los lee en una pila que dejó de contar la verdad.
En async, la pila del hilo refleja el bucle del executor, no tu cadena de .await: el backtrace muestra poll sobre poll y oculta quién hizo spawn y quién espera. El planificador con robo de trabajo hace migrar las tareas entre hilos, así que el identificador de hilo no sirve para seguir una operación —y de ahí nace la exigencia de Send—. Un breakpoint congela un hilo, pero las tareas dormidas son valores aparcados en el heap, invisibles al depurador y ajenos a cualquier pila. La causa común: async reificó el flujo de control en datos. Depurarlo exige observar la tarea —sus eventos, sus tiempos, su waker— en lugar de leer la pila.
- Escribe una
async fnque llame a otra y captura un backtrace en la más interna constd::backtrace::Backtrace::force_capture. Identifica los marcos depolly del executor y señala qué relación lógica no aparece. - Lanza varias tareas con
tokio::spawnque impriman elstd::thread::current().id()antes y después de un.awaitsobretokio::time::sleep. Observa si alguna cambia de hilo y explica por qué es posible. - Sostén un valor
!Send(por ejemplo unRc) a través de un.awaitdentro de una tarea y explica el error del compilador conectándolo con la migración de hilos de este capítulo. - Pon un breakpoint dentro de un handler async con muchas conexiones simultáneas y razona por qué la tarea que atrapas casi nunca es la que querías inspeccionar.
- Argumenta en tus palabras por qué “poner más breakpoints” o “imprimir el thread id” no puede, ni en principio, decirte cuál de diez mil tareas dormidas está atascada.