Async Runtime Post-Mortems: What a Flight Recorder Actually Gives Tokio
Source: lobsters
The hardest bugs in async Rust are the ones that only appear in production. A task hangs, a deadline is missed, throughput collapses for thirty seconds and then recovers. By the time you notice, the evidence is gone. tokio-console shows you the runtime in real time, and the tracing ecosystem gives you structured logs if you remember to instrument everything correctly. Neither helps when you need to reconstruct what the runtime was doing ten seconds before something went wrong.
That is the gap dial9 targets. The Tokio team describes it as a flight recorder, which is a term borrowed from aviation but has precise meaning in systems software: a mechanism that continuously captures runtime events into a bounded circular buffer, with enough fidelity to reconstruct system behavior after the fact, and low enough overhead to leave enabled in production.
The Reference Point: Java Flight Recorder
The most mature equivalent in any production runtime is Java Flight Recorder (JFR), which became open source with JDK 11 in 2018. JFR operates by continuously writing structured events into per-thread buffers that drain into a global ring buffer. The overhead is typically under two percent for most workloads. When you want a recording, you either dump the in-memory buffer or read a file that has been written continuously. The key design decision in JFR is that you do not have to decide in advance to start recording: you are always recording, and you choose when to look.
Go’s runtime execution tracer takes a different approach. You enable it explicitly, it captures goroutine lifecycle events, syscall boundaries, GC phases, and network I/O, and you parse the result with go tool trace. The overhead when enabled is noticeable, around 10-20% in goroutine-heavy workloads, which means most teams only use it during profiling sessions, not in production.
Tokio had neither. tokio-console uses a tracing subscriber that exports task events over a gRPC channel to a TUI client. It is powerful for development, but it requires the console subscriber to be active, the client to be connected, and someone to be watching. It has no buffering semantics that survive a process anomaly.
What Flight Recorder Semantics Actually Require
Building a flight recorder for an async runtime is harder than for a thread-per-task runtime because the event density is much higher. A Tokio application might process hundreds of thousands of task polls per second. Each poll, wake, spawn, and drop is potentially interesting. You cannot record all of them in a naive way without the recording itself becoming the bottleneck.
The standard approach is a lock-free ring buffer with fixed-size event records. Each worker thread writes to a thread-local segment; a background thread or lazy drain merges them into a global buffer. The fixed record size matters because variable-length records require allocation or copying on the hot path, which destroys the overhead story.
The second constraint is async-signal-safety. If you want to dump the flight recorder on a signal (SIGUSR1, for instance, or in a panic handler), the dump code cannot call malloc, cannot take mutexes, and cannot do anything that might deadlock against code that was interrupted. This pushes you toward writing the buffer to a file descriptor in a single write(2) call, or using a pre-allocated output buffer.
The third constraint is clock resolution. Reconstructing task timelines requires timestamps on every event, and those timestamps need to be cheap. std::time::Instant calls clock_gettime(CLOCK_MONOTONIC) on Linux, which is fast (a few nanoseconds via vDSO) but still adds up at high event rates. Some flight recorders use a coarser timestamp or a tick counter and correlate to wall time at read time.
What dial9 Records
dial9 instruments Tokio at the runtime boundary, capturing the events that matter for diagnosing async pathology: task spawns with task IDs and source location metadata, poll durations, wakeup origins (which task or I/O source caused a wakeup), scheduler queue depths over time, and the time tasks spend waiting between being woken and being polled. That last metric is particularly useful. Long wakeup-to-poll latency usually means one of two things: the thread pool is undersized for the load, or a small number of tasks are monopolizing worker threads by doing blocking work or holding futures that poll for too long.
The integration is through Tokio’s RuntimeMetrics API, which was stabilized in Tokio 1.x and provides hooks into worker thread activity, injection queue depth, and steal counts. dial9 builds on this rather than requiring Tokio internals to be modified, which means it works with stable Tokio releases and does not require a custom runtime build.
use dial9::FlightRecorder;
#[tokio::main]
async fn main() {
let _recorder = FlightRecorder::builder()
.buffer_duration(std::time::Duration::from_secs(30))
.on_panic(dial9::DumpStrategy::File("tokio-panic.dial9".into()))
.install()
.unwrap();
// your application code
}
The buffer_duration parameter controls how far back the ring buffer reaches. A thirty-second buffer at typical overhead is enough to capture the buildup before most incidents. The on_panic hook registers a panic handler that dumps the buffer to disk before the process exits, giving you the trace you need for post-mortem analysis.
Reading the Recording
The companion CLI, also called dial9, parses the binary format and renders a timeline view. The format is deliberately simple: a header with version and metadata, followed by fixed-size 32-byte event records. Task IDs are stable within a recording, so you can filter to a specific task and see its complete lifecycle: when it was spawned, how many times it was polled, what woke it each time, and how long each poll took.
The most useful view is the wakeup graph, which shows the dependency chain between tasks: task A woke task B, which woke task C, across what time span. In a correctly structured async application, this graph is shallow and wide. When something is wrong, you typically see either a very long chain (indicating synchronous dependency where concurrency was intended) or a task that woke repeatedly but was polled very late (indicating starvation).
The Overhead Question
A flight recorder that costs five percent overhead in production is probably too expensive for most teams. The Tokio team reports dial9’s overhead at under one percent on typical workloads, which puts it in the range where the cost is worth paying all the time rather than only when you suspect a problem. This is the critical property. A tool you enable only when debugging is useful; a tool you can leave on permanently is a different category of asset.
The low overhead comes from three choices: per-worker-thread ring buffers (no cross-thread synchronization on the write path), fixed-size records (no allocation), and sampling rather than recording every event for high-frequency events like polls. Individual polls below a configurable threshold (default one millisecond) are counted but not individually recorded; polls above that threshold get full records with source location and duration.
Fitting Into the Existing Ecosystem
dial9 does not replace tokio-console or tracing. They serve different phases of the debugging process. tracing is for observability you design into your application: structured events that carry application-level context. tokio-console is for interactive debugging sessions where you can watch the runtime live. dial9 is for the incidents you did not know were coming.
The closest analog in the JVM world is the combination of JFR for continuous flight recording and async-profiler or JDK Mission Control for post-mortem analysis. Rust is getting the same separation: production-safe continuous capture with dial9, interactive analysis for development sessions with tokio-console.
What this means practically is that the debugging story for async Rust in production becomes much more tractable. A performance regression, an occasional task hang, a spike in tail latency: all of these leave evidence in a flight recorder that they do not leave in logs or metrics. The overhead argument for not using it is gone. The only remaining step is making the tooling to read and visualize recordings good enough that engineers actually use it when something goes wrong, and that part is still being built.