Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rust async monitoring: Futures, Streams and Channels performance

hotpath lets you monitor Rust channels performance in real time - alongside streams and futures. Track channel throughput, queue depth, identify slow consumers, monitor futures resolution, and discover bottlenecks while your system is running. With minimal instrumentation, you can get a clear picture of how data moves through your app’s async pipeline.

All monitoring macros (channel!, stream!, future! and future_fn) are noop unless hotpath feature is activated.

Channels monitoring

channel! macro

This macro wraps channel creation to automatically track performance metrics and data flow:

use tokio::sync::mpsc;

#[tokio::main]
#[hotpath::main]
async fn main() {
    // Create and instrument a channel in one step
    let (tx, mut rx) = hotpath::channel!(mpsc::channel::<String>(100));

    // Use the channel exactly as before
    tx.send("Hello".to_string()).await.unwrap();
    let msg = rx.recv().await.unwrap();
}

By default channel! uses wrap mode: it returns instrumented endpoint wrappers (hotpath::wrap::<backend>::{Sender, Receiver}) that intercept send/recv inline. This gives an exact live queue depth and an exact send-receive latency histogram, with no forwarder task or thread. See Wrapped types for how this affects the return type, and proxy = true for the alternative forwarder mode (and the backends that require it).

Supported channel libraries

std::sync channels are instrumented by default. Enable the matching feature flag for each third-party library.

std

Built-in, no feature flag required.

Tokio

Enable the tokio feature.

futures-rs

Enable the futures feature.

async-channel

Enable the async-channel feature.

crossbeam

Enable the crossbeam feature.

flume

Enable the flume feature.

Optional config

// Custom label for easier identification in TUI
let (tx, rx) = hotpath::channel!(mpsc::channel::<String>(100), label = "worker_queue");

// Enable message logging (requires std::fmt::Debug trait on message type)
let (tx, rx) = hotpath::channel!(mpsc::channel::<String>(100), log = true);

Label channels to display them on top of the list. By passing log = true TUI will display messages that a channel received.

hotpath-rs TUI showing channel message flow monitoring with send and receive logs

Send-receive latency and queue depth (default)

By default channel! wraps the Sender/Receiver endpoints directly and stamps each message with its send time, so the report gains an exact send-receive latency histogram (proc_avg plus the configured percentiles), alongside an exact live queue depth. No forwarder task or thread is inserted:

let (tx, rx) = hotpath::channel!(
    crossbeam_channel::unbounded::<i32>(),
    label = "jobs"
);

// tokio mpsc, bounded or unbounded - no `capacity` argument needed
let (tx, rx) = hotpath::channel!(
    tokio::sync::mpsc::channel::<i32>(100),
    label = "jobs"
);

The recorded latency is the full interval from send() to recv(), including backpressure wait on bounded channels. Because the timestamps are taken inside your own send/recv calls rather than in a forwarder task or thread, the value is exact - and wrap mode is lighter than the proxy, since it adds no extra task/thread or hop.

Wrap mode is available for tokio (mpsc), std, crossbeam, flume, and async-channel. It is not available for futures_channel (any kind) or tokio::sync::oneshot - those are forwarder-only and require proxy = true.

Capacity parameter requirement

Bounded std::sync::mpsc channels require an explicit capacity, and the value must match the sync_channel(N) argument:

use std::sync::mpsc;

// std bounded - capacity MUST equal the sync_channel argument
let (tx, rx) = hotpath::channel!(mpsc::sync_channel::<String>(100), capacity = 100);

Wrap mode rebuilds the inner channel from capacity (std exposes no way to read it back from the endpoints) and discards the channel you constructed. If the two disagree - e.g. sync_channel(100) with capacity = 1 - the profiled build gets a different bound than the unprofiled one (where channel! returns your original channel untouched), which can change backpressure or even deadlock only when profiling is enabled. Keep the numbers equal.

Tokio, crossbeam, flume, and async-channel recover the bound from the endpoint, so they need no capacity argument. futures_channel::mpsc bounded channels (forwarder-only, see below) also require capacity = N because their API doesn’t expose it after creation.

proxy = true (forwarder mode)

Passing proxy = true selects the original forwarder-based mode. Instead of wrapping the endpoints, hotpath keeps your original endpoint types (the return type is unchanged) and spawns a background task/thread that relays every message through a second internal channel, observing sent/received counts at that boundary:

// keep the raw endpoint types; instrument via a forwarder
let (tx, rx) = hotpath::channel!(mpsc::channel::<String>(100), proxy = true);

// required for the forwarder-only backends
let (tx, rx) = hotpath::channel!(futures_channel::mpsc::channel::<i32>(10), proxy = true, capacity = 10);
let (tx, rx) = hotpath::channel!(tokio::sync::oneshot::channel::<i32>(), proxy = true);

Use proxy = true when you need type-transparent endpoints (see Wrapped types), or for a backend that has no wrap implementation - futures_channel (mpsc and oneshot) and tokio::sync::oneshot. Calling channel! on one of those without proxy = true is a compile error that tells you to add it.

Forwarder mode has two accuracy caveats. The proxy is bounded to capacity 1, so sent/received counts are observed at the proxy boundary rather than at the final consumer, and try_send may behave slightly differently since the proxy adds one slot of extra capacity. It also cannot measure send-receive latency: it stamps events inside the forwarder, in the middle of the pipeline, so proc_avg/percentiles and exact queue depth are omitted. Prefer the default wrap mode when you care about latency or queue depth.

Instrumentation overhead

Because wrap mode hits the real channel directly instead of relaying every message through a forwarder task or thread, the default is dramatically cheaper than proxy = true for the libraries whose proxy needs a background relay. For tokio and flume, wrap mode cuts per-message instrumentation overhead roughly 5-6x versus the forwarder proxy, since their proxies cost a scheduler round-trip per message. async-channel’s proxy also relays every message through a background async task, so it benefits similarly. std also gets a large reduction (its proxy overhead drops by around 4x). crossbeam’s forwarder is already cheap (a tight relay thread, no async scheduling), so the two modes are close there.

Streams monitoring

stream! macro

This macro instruments async streams to track performance metrics and items yielded:

use futures::stream::{self, StreamExt};

#[tokio::main]
#[hotpath::main]
async fn main() {
    // Create and instrument a stream in one step
    let s = hotpath::stream!(stream::iter(1..=100));

    // Use it normally
    let items: Vec<_> = s.collect().await;
}

Optional config

// Custom label
let s = hotpath::stream!(stream::iter(1..=100), label = "data_stream");

// Enable item logging (requires std::fmt::Debug trait on item type)
let s = hotpath::stream!(stream::iter(1..=100), log = true);

Label streams to display them on top of the list. By passing log = true TUI will display values that a stream yielded.

hotpath-rs TUI showing async stream item monitoring and throughput

Futures monitoring

future! and future_fn macros

The future! macro and #[future_fn] attribute instrument any async function or piece of code or to track poll counts and future lifecycle:

#[tokio::main]
#[hotpath::main]
async fn main() {
    // Instrument a future expression
    let result = hotpath::future!(async { 42 }, log = true).await;

    instrumented_fetch().await;
}

// Or use the attribute on async functions
#[hotpath::future_fn(log = true)]
async fn instrumented_fetch() -> Vec<u8> {
    vec![1, 2, 3]
}

Optional config

// Custom label for easier identification in TUI
let result = hotpath::future!(async { 42 }, label = "my_future").await;

// Enable output logging (requires std::fmt::Debug trait on output type)
let result = hotpath::future!(async { 42 }, log = true).await;

Label futures to display them on top of the list. By passing log = true TUI will display values that future resolved to:

hotpath-rs TUI showing async futures poll tracking and value logging

Wrapped types

By default channel! does not return the endpoints you passed in - it returns instrumented wrappers around them. The macro expands to a different type than the original:

// before: a plain crossbeam receiver
let (tx, rx): (crossbeam_channel::Sender<i32>, crossbeam_channel::Receiver<i32>) =
    crossbeam_channel::unbounded();

// after: the macro returns hotpath wrappers, not crossbeam_channel::Sender/Receiver
let (tx, rx) = hotpath::channel!(crossbeam_channel::unbounded::<i32>());

At a let binding this is invisible - type inference picks up whatever the macro returns. It only matters when you need to name the type, for example a struct field or a function signature. There you cannot write crossbeam_channel::Sender<T>, because the value is a wrapper, not a crossbeam_channel::Sender. (If you would rather keep the original endpoint types, use proxy = true, which is type-transparent.)

Use the hotpath::wrap:: path instead. It mirrors the original module layout, so you prefix the original path with hotpath::wrap:::

// before
struct Pipeline {
    jobs_tx: crossbeam_channel::Sender<Job>,
    jobs_rx: crossbeam_channel::Receiver<Job>,
}

// after - prefix the type with hotpath::wrap::
struct Pipeline {
    jobs_tx: hotpath::wrap::crossbeam_channel::Sender<Job>,
    jobs_rx: hotpath::wrap::crossbeam_channel::Receiver<Job>,
}

The same prefix works for every wrap-capable library:

  • hotpath::wrap::std::sync::mpsc::{Sender, SyncSender, Receiver}
  • hotpath::wrap::tokio::sync::mpsc::{Sender, Receiver, UnboundedSender, UnboundedReceiver}
  • hotpath::wrap::crossbeam_channel::{Sender, Receiver}
  • hotpath::wrap::flume::{Sender, Receiver}
  • hotpath::wrap::async_channel::{Sender, Receiver}

This is purely to keep the compiler police happy: the hotpath::wrap:: types are noop unless the hotpath feature is enabled. With the feature off they are plain re-exports of the original endpoints (zero overhead, identical behavior); with the feature on they resolve to the instrumented wrappers. Either way the field type lines up with what the macro returns, so the same code compiles in both configurations.