Skip to content
rusthacks.dev
Performance

Profiling Async Rust Services With Tokio Metrics in Steps

Use Tokio runtime and task metrics to see worker busy time, scheduling delay, queue depth and slow polls. Export them, read the numbers that matter and tell blocking code from a genuine lack of capacity.

3 min read
Summarize in AI
Flat illustration for the post “Profiling Async Rust Services With Tokio Metrics in Steps”, composed around threads.

When an async service is slow, a CPU profile can look innocent: the process is not busy, yet latency is high. Often the executor itself is the bottleneck: tasks are ready but waiting for a worker that is busy with something long or blocked. Tokio exposes numbers for exactly this. This post shows how to collect and read them. It compiles on Rust 1.90 with edition 2024, Tokio 1.47 and tokio-metrics 0.4 (with rt feature).

Runtime metrics

TOML
[dependencies]
tokio = { version = "1.47", features = ["full"] }
tokio-metrics = { version = "0.4", features = ["rt"] }
futures = "0.3"
rust
use std::time::Duration;
use tokio_metrics::RuntimeMonitor;

#[tokio::main]
async fn main() {
    let handle = tokio::runtime::Handle::current();
    let monitor = RuntimeMonitor::new(&handle);

    tokio::spawn(async move {
        for interval in monitor.intervals() {
            println!(
                "busy_ratio={:.2} workers={} global_queue={} mean_poll_us={:.0}",
                interval.busy_ratio(),
                interval.workers_count,
                interval.global_queue_depth,
                interval.mean_poll_duration.as_secs_f64() * 1e6,
            );
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    });

    // ... run the service
    tokio::time::sleep(Duration::from_secs(5)).await;
}

Each call to the intervals iterator yields metrics for the period since the last one. The ones that matter first:

MetricMeaningReading it
busy_ratio()Fraction of time workers spent running tasksNear 1.0: out of executor capacity
global_queue_depthTasks waiting in the shared queueSustained growth: tasks arrive faster than workers drain them
mean_poll_durationAverage time inside a task pollHigh: tasks run long without yielding
total_noop_count, steal counts (unstable)Parks with no work, steals between workersLoad balance behavior

Task metrics

rust
use tokio_metrics::TaskMonitor;

#[tokio::main]
async fn main() {
    let monitor = TaskMonitor::new();

    let m = monitor.clone();
    tokio::spawn(async move {
        for interval in m.intervals() {
            println!(
                "polls={} mean_poll_us={:.0} slow_polls={} mean_sched_delay_us={:.0}",
                interval.total_poll_count,
                interval.mean_poll_duration().as_secs_f64() * 1e6,
                interval.total_slow_poll_count,
                interval.mean_scheduled_duration().as_secs_f64() * 1e6,
            );
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        }
    });

    for _ in 0..1000 {
        tokio::spawn(monitor.instrument(async {
            tokio::task::yield_now().await;
        }));
    }
    tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}

instrument wraps a future so its polls are measured. Wrap the top-level request handler future or a specific operation, with one monitor per kind of work. The most useful number is scheduling delay (mean_scheduled_duration): the time from a task being woken to being polled. A healthy service has delays of microseconds; milliseconds mean tasks are queued behind something.

Reading the combinations

  1. High busy ratio, high queue depth, mean poll short: you simply need more capacity: more worker threads, more instances or less work per request.
  2. Low busy ratio but long scheduling delays or latency spikes: a few tasks hold workers for a long time (or block them), starving others; look at slow polls and for blocking calls (blocking in async code).
  3. High mean poll duration: CPU-heavy work inside async tasks (parsing, hashing, serialization of big bodies). Move it to spawn_blocking or Rayon (spawn_blocking and Rayon), or chunk it with yield_now.
  4. Everything looks fine, latency is still high: the wait is outside the executor: database, pool, downstream calls, locks. Add spans and pool metrics (pool sizing).

Exporting

rust
use tokio_metrics::RuntimeMonitor;

fn export_runtime_metrics(handle: tokio::runtime::Handle) {
    let monitor = RuntimeMonitor::new(&handle);
    tokio::spawn(async move {
        for m in monitor.intervals() {
            metrics::gauge!("tokio_busy_ratio").set(m.busy_ratio());
            metrics::gauge!("tokio_global_queue_depth").set(m.global_queue_depth as f64);
            metrics::gauge!("tokio_mean_poll_seconds").set(m.mean_poll_duration.as_secs_f64());
            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
        }
    });
}

Send the values to your metrics backend alongside request latency percentiles (tail latency). Alert on sustained busy ratio above about 0.8 and on scheduling delay above a few milliseconds. Correlate the alerts with deploys and traffic.

When metrics are not enough

Metrics tell you that the executor is struggling and roughly why; they do not name the offending function. Follow up with tokio-console for per-task views (tokio-console), a flamegraph under load (profiling with flamegraphs) and task dumps for hangs (task dumps). A shorter introduction to the built-in runtime counters is in Tokio runtime metrics.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.