Debugging async Rust code requires understanding that the tokio runtime (or other runtimes) schedules tasks across threads, meaning standard breakpoints often hit at the wrong logical points or miss the actual execution context. You should primarily use cargo expand to inspect the generated state machines, rely on tokio-console for runtime visibility, and use RUST_BACKTRACE=1 combined with --features console-subscriber to trace async execution paths effectively.
For static analysis and understanding the state machine transformation, run cargo expand on your async function. This reveals the hidden enum state machine that the compiler generates, helping you identify where a task is suspended or if a borrow checker issue is caused by the state machine's structure.
cargo expand --lib --features console-subscriber | grep -A 20 "enum MyAsyncFunctionState"
For dynamic debugging, the most effective tool is tokio-console, which provides a real-time dashboard of running tasks, their locations, and their suspension points. You need to enable the console-subscriber feature in your Cargo.toml and run your application with the RUST_LOG environment variable set.
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-console = "0.1"
console-subscriber = "0.1"
use console_subscriber::ConsoleLayer;
use tokio::runtime::Builder;
fn main() {
let console_layer = ConsoleLayer::default();
let runtime = Builder::new_multi_thread()
.enable_all()
.push_tracing(console_layer)
.build()
.unwrap();
runtime.block_on(async {
// Your async code here
println!("Task started");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
println!("Task finished");
});
}
Run the application and connect to the dashboard:
# Terminal 1: Run your app
RUST_LOG=info cargo run
# Terminal 2: Start the console
tokio-console
If you need to inspect a panic or stack trace, ensure you set RUST_BACKTRACE=1 before running. However, note that standard backtraces in async code can be confusing because they show the state machine's internal transitions rather than your source code lines. For complex hangs, use tokio::task::JoinHandle to inspect if a task is stuck in a specific .await point by printing the task ID and location before awaiting.
let handle = tokio::spawn(async {
println!("Task ID: {:?}", tokio::task::Id::current());
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
});
// If the task hangs, inspect the runtime state via tokio-console
Avoid using println! inside tight async loops for debugging, as I/O can block the executor if not handled carefully. Instead, rely on structured logging with tracing and the console subscriber to correlate logs with specific task states.