Skip to content

Commit d5f4dc1

Browse files
authored
feat(executor): instrument tasks for tokio-console (#987)
* feat(executor): give tasks a runtime.spawn span tokio-console collects its data through tracing spans and events that follow a fixed naming convention; it is not tied to tokio's internals, so any executor emitting the same spans and events can be observed with it. Behind a new `console` feature, give every task a `runtime.spawn` span, entered while the task is polled. That is enough for the console to report poll counts, busy/idle/scheduled times and the poll time histograms. The instrumentation lives in the new `console` module, which has an enabled and a disabled variant. The disabled one is what is compiled without the feature: `TaskSpan` becomes zero-sized, so the task header keeps its layout, and every method an empty inlined function. The span sits in that header, so it is dropped along with the task allocation, during a panic as well. `drop_future` leaks the future while unwinding, since dropping it could panic a second time, but leaking the span would leave the task running in the console forever. The subscriber is therefore reentered while unwinding, where a panic inside it aborts. Since the span records where the task was spawned, `Executor::spawn` becomes `#[track_caller]`. Wrappers around it want the console to blame their own caller instead of themselves, so also add `spawn_at`, taking the `SpawnMeta` to attribute the task to. Adding `tracing` to the workspace also lets `compio-log` take it from there rather than pinning a version of its own. * feat(executor): emit runtime::waker events Emit a `runtime::waker` event from every waker operation of a task, which is what the console needs to report waker counts and to run its self-wake and lost-waker lints. The `op` values the console expects are collected in the `console` module, next to a note on why `Waker::wake` must not report a drop of its own. * feat(executor): instrument the future blocked on The future passed to `block_on` is driven by the runtime instead of being a task of the executor, so it is invisible to the console although it is usually the most interesting future of the application. Add `console::instrument_block_on`, wrapping it into a future that owns a `block_on` task span. Its waker belongs to the caller of `block_on` rather than to a task, so wrap it too, in a shim reporting the waker operations the console expects. Without the `console` feature the wrapper is the identity function. * test(executor): assert the console instrumentation Record the spans and events with a subscriber doing what `console-subscriber` does, and assert on what it saw: the fields of the task spans, the poll counts, and that the waker operations of a task balance out, which is what the console's lost-waker lint looks at. Run the new test in CI, under miri as well, since it exercises the waker vtables of both the tasks and the `block_on` shim. * feat(executor): report blocking closures as blocking tasks The console treats tasks whose `kind` is `blocking` or `block_on` as not being driven by a future, and skips the four lints that only make sense for one: self-wake ratio, lost waker, never-yielded and large future. `spawn_blocking` produces exactly the kind of task those lints misjudge, and reports the wrong times on top of that: the task is the future waiting for the pool, so all of the time in the closure counts as idle and none as busy. Instrument the closure instead of the future waiting for it. The span is created on the spawning thread, so the wait for a worker is reported as idle time, and entered around the closure, so its time is reported as busy. The future is then left unreported, since it stands for work that is already accounted for. * feat(executor): let a task be named The console gives `task.name` a column of its own, and leaves it empty for the tasks that do not have it. It is worth setting for the tasks a user did not spawn themselves, since the location of those points into compio rather than at the code that asked for the work. That is the case for every wrapper around `spawn` that is an `async fn`, since `#[track_caller]` is a no-op on those and a `SpawnMeta` cannot be forwarded through them, so note that in the limitations as well. Add `SpawnMeta::named` for the tasks that can make up for it. The crates that spawn tasks of their own name them in the commits that follow, one per crate. * test(executor): assert the console variants present one surface The `console` module has an enabled and a disabled variant of every type it exports, and only one of them is ever compiled. The disabled one is what nearly every build uses, so a difference between the two surfaces reaches whoever turns the feature on, in code written long after the difference. Assert that they match, by coercing each item to a function pointer, which pins its whole signature, and by naming the traits the rest of the crate relies on. The guard returned by entering a task's span needs more than a signature: the enabled one borrows the span, since the console measures the time the span is entered as the busy time of the task. A disabled guard that owned itself would let code hold it past the span it is timing and compile, and only fail once the feature is turned on. Both variants therefore name the guard through a `EnterGuard<'a>` alias, which an owned guard cannot fill. * feat(executor): let the task a runtime blocks on be named `instrument_block_on` captured the caller itself, so a `block_on` task could only ever be reported by the location of the call. That is enough for the runtime a user blocks on themselves, but not for the ones started on their behalf, which all report the same line inside compio. Take a `SpawnMeta` like the other spawns do, so that a caller can pass one. * docs(executor): correct what the console does with a task's kind The console does not treat every kind other than `task` as one it does not drive itself: it knows `blocking` and `block_on` by name, and lints a task of any other kind, including one it does not know, as a future of its own. Record the nightly escape hatch for the attribution of an `async fn` too. * feat(executor): instrument the future a compatibility layer executes `compio-compat`'s `execute` drives the executor from a foreign event loop, the way `block_on` drives it from a loop of its own. It is the same kind of task, so report it as one, under a name that says what it instruments.
1 parent c87c320 commit d5f4dc1

11 files changed

Lines changed: 1211 additions & 9 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ slab = "0.4.9"
7272
socket2 = "0.6.0"
7373
tempfile = "3.8.1"
7474
tokio = "1.33.0"
75+
tracing = { version = "0.1.41", default-features = false, features = ["std"] }
7576
tracing-subscriber = "0.3.18"
7677
webpki-roots = "1.0.0"
7778
widestring = "1.0.2"

compio-executor/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ compio-send-wrapper = { workspace = true }
2020
crossbeam-queue = { workspace = true }
2121
slotmap = { workspace = true }
2222

23+
# Console
24+
tracing = { workspace = true, optional = true }
25+
2326
[target.'cfg(loom)'.dependencies]
2427
loom = { version = "0.7", features = ["checkpoint"] }
2528

2629
[dev-dependencies]
2730
criterion = { workspace = true }
31+
tracing = { workspace = true }
2832
tracing-subscriber = { workspace = true, features = ["env-filter"] }
2933

3034
[target.'cfg(unix)'.dev-dependencies]
@@ -33,6 +37,9 @@ nix = { workspace = true, features = ["resource", "signal"] }
3337
[features]
3438
enable_log = ["compio-log/enable_log"]
3539

40+
# Instrumentation for `tokio-console`. See the `console` module.
41+
console = ["dep:tracing"]
42+
3643
[[bench]]
3744
name = "schedule"
3845
harness = false

compio-executor/src/console.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
//! [`tokio-console`] instrumentation.
2+
//!
3+
//! [`tokio-console`] collects its data through [`tracing`] spans and events
4+
//! that follow a fixed naming convention. It is *not* tied to tokio's internals
5+
//! in any way, so any executor emitting the same spans and events can be
6+
//! observed with it.
7+
//!
8+
//! Enable the `console` feature to make this executor emit them:
9+
//!
10+
//! * every task gets a `runtime.spawn` span, entered while the task is polled,
11+
//! so that the console can compute poll counts, busy/idle/scheduled times and
12+
//! the poll time histogram;
13+
//! * every waker operation emits a `runtime::waker` event, so that the console
14+
//! can compute waker counts and detect self-wakes and lost wakers;
15+
//! * a closure handed to the blocking pool gets such a span too, entered around
16+
//! the closure instead of around a poll, so that the time spent in it is
17+
//! reported as busy time rather than as idle time.
18+
//!
19+
//! When the feature is disabled, all of this compiles down to nothing: the
20+
//! types in this module become zero-sized and every method an empty inlined
21+
//! function.
22+
//!
23+
//! # Usage
24+
//!
25+
//! `console-subscriber` refuses to run unless it can prove that the runtime is
26+
//! instrumented, which for tokio means the `tokio_unstable` cfg. For other
27+
//! runtimes it provides the `console_without_tokio_unstable` escape hatch, so a
28+
//! binary observing compio needs:
29+
//!
30+
//! ```toml
31+
//! # .cargo/config.toml
32+
//! [build]
33+
//! rustflags = ["--cfg", "console_without_tokio_unstable"]
34+
//! ```
35+
//!
36+
//! Depending on `console-subscriber` and installing it is then all it takes:
37+
//!
38+
//! ```ignore
39+
//! console_subscriber::init();
40+
//! compio::runtime::Runtime::new().unwrap().block_on(async {
41+
//! // ...
42+
//! });
43+
//! ```
44+
//!
45+
//! # Limitations
46+
//!
47+
//! * The console's data model has one runtime per process, while compio is
48+
//! thread-per-core and has one executor per thread. The tasks of all of them
49+
//! are listed together; the `thread` field tells them apart.
50+
//! * The subscriber has to be the global default, which
51+
//! `console_subscriber::init` makes it. A span carries the subscriber it was
52+
//! created with, but an event goes to whichever one is current on the thread
53+
//! emitting it, so a thread-local subscriber misses the waker operations
54+
//! other threads perform. Wakers cross threads routinely — that is what
55+
//! waking a task from another executor is — and the clone and drop counts of
56+
//! one that does no longer balance, leaving the console to report a lost
57+
//! waker that is not lost.
58+
//! * A `block_on` nested inside a task — a runtime built within another one —
59+
//! reports the two as separate tasks, but both of their spans are entered on
60+
//! the same stack. The console attributes the polls to the inner one for as
61+
//! long as that is the case.
62+
//! * A blocking task has no waker operations, since it is a closure rather than
63+
//! a future. The console knows this from its `kind` and does not report a
64+
//! lost waker for it.
65+
//! * A task spawned by an `async fn` is attributed to that function rather than
66+
//! to its caller, since [`#[track_caller]`][async-track-caller] is a no-op on
67+
//! `async fn`s and [`SpawnMeta`] therefore cannot be forwarded through them.
68+
//! The ones compio spawns itself are named to make up for it. A crate willing
69+
//! to build on nightly can lift this for its own `async fn`s with the
70+
//! `async_fn_track_caller` feature, which attributes them to the `.await` of
71+
//! the future they return.
72+
//! * The resources tab stays empty: timers and in-flight operations are not
73+
//! instrumented yet.
74+
//! * A task's span is closed even when the thread is unwinding, or the console
75+
//! would show the task as running forever. The subscriber therefore runs
76+
//! during a panic, where a panic of its own aborts instead of unwinding.
77+
//!
78+
//! [`tokio-console`]: https://github.com/tokio-rs/console
79+
//! [`tracing`]: https://docs.rs/tracing
80+
//! [async-track-caller]: https://github.com/rust-lang/rust/issues/110011
81+
82+
cfg_select! {
83+
feature = "console" => {
84+
mod enabled;
85+
use enabled as imp;
86+
}
87+
_ => {
88+
mod disabled;
89+
use disabled as imp;
90+
}
91+
}
92+
93+
pub(crate) use imp::TaskSpan;
94+
pub use imp::{SpawnMeta, instrument_block_on, instrument_blocking, instrument_execute};
95+
96+
/// An operation on a task's waker, reported as a `runtime::waker` event.
97+
///
98+
/// Note that [`Waker::wake`](std::task::Waker::wake) does not call the `drop`
99+
/// implementation, so the console counts [`Self::Wake`] as both a wake and a
100+
/// drop. Emitting an additional [`Self::Drop`] for it would make the live waker
101+
/// count (clones - drops) go negative.
102+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103+
pub(crate) enum WakerOp {
104+
Clone,
105+
Drop,
106+
Wake,
107+
WakeByRef,
108+
}
109+
110+
impl WakerOp {
111+
/// The `op` value of the event, as expected by the console.
112+
///
113+
/// Only the enabled variant reports anything, so only it reads this.
114+
#[cfg(feature = "console")]
115+
pub(crate) const fn as_str(self) -> &'static str {
116+
match self {
117+
Self::Clone => "waker.clone",
118+
Self::Drop => "waker.drop",
119+
Self::Wake => "waker.wake",
120+
Self::WakeByRef => "waker.wake_by_ref",
121+
}
122+
}
123+
}
124+
/// Assertions that the two variants above present the same surface.
125+
///
126+
/// Only one of them is ever compiled, and the one compiled by default is the
127+
/// one nearly every build uses: a difference between the two shows up as a
128+
/// build failure for whoever turns the feature on, long after the code that
129+
/// assumed the other shape was written.
130+
///
131+
/// Coercing each item to a function pointer pins its whole signature, and
132+
/// naming [`EnterGuard`] with a lifetime pins the shape of the guard: the
133+
/// enabled one borrows the span, so a disabled one that owns itself, and would
134+
/// let code outlive the span it is timing, does not have a lifetime to name.
135+
#[cfg(test)]
136+
mod parity {
137+
use std::{fmt::Debug, future::Future};
138+
139+
use super::{imp::EnterGuard, *};
140+
141+
const _: fn() -> SpawnMeta = SpawnMeta::capture;
142+
const _: fn(SpawnMeta, &'static str) -> SpawnMeta = SpawnMeta::named;
143+
const _: fn() -> SpawnMeta = SpawnMeta::untracked;
144+
145+
const _: fn(SpawnMeta) -> TaskSpan = TaskSpan::new::<()>;
146+
const _: for<'a> fn(&'a TaskSpan) -> EnterGuard<'a> = TaskSpan::enter;
147+
const _: fn(&TaskSpan, WakerOp) = TaskSpan::waker_op;
148+
149+
/// [`SpawnMeta`] is copied out of a spawn call rather than moved, and
150+
/// reaches the dispatcher's threads through its channel.
151+
const fn meta<T: Copy + Send + Sync + Unpin + Debug + 'static>() {}
152+
const _: () = meta::<SpawnMeta>();
153+
154+
/// [`TaskSpan`] sits in the task header, which threads share.
155+
const fn span<T: Send + Sync + Debug>() {}
156+
const _: () = span::<TaskSpan>();
157+
158+
/// The wrappers return `impl Trait`, so pin them by use instead.
159+
#[test]
160+
fn the_wrappers_pass_their_argument_through() {
161+
assert_eq!(instrument_blocking(SpawnMeta::untracked(), || 1u8)(), 1);
162+
163+
let fut = instrument_block_on(SpawnMeta::untracked(), std::future::ready(1u8));
164+
let _: &dyn Future<Output = u8> = &fut;
165+
166+
let fut = instrument_execute(SpawnMeta::untracked(), std::future::ready(1u8));
167+
let _: &dyn Future<Output = u8> = &fut;
168+
}
169+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! No-op stand-ins used when the `console` feature is disabled.
2+
3+
use std::marker::PhantomData;
4+
5+
/// Metadata of a spawned task, reported to the console.
6+
///
7+
/// Zero-sized and inert unless the `console` feature is enabled.
8+
#[derive(Debug, Clone, Copy)]
9+
pub struct SpawnMeta;
10+
11+
impl SpawnMeta {
12+
/// Capture the location of the caller.
13+
///
14+
/// Discards it. The spawns that call this stay `#[track_caller]` either
15+
/// way: a feature of this crate cannot reach the wrappers in the crates
16+
/// that depend on it, so gating them on one of their own would attribute
17+
/// every task to compio itself in a build that enables only this one. The
18+
/// implicit argument is dead here, and mostly optimised away.
19+
#[inline(always)]
20+
pub fn capture() -> Self {
21+
Self
22+
}
23+
24+
/// Name the task, which the console displays in a column of its own.
25+
#[inline(always)]
26+
pub fn named(self, _name: &'static str) -> Self {
27+
self
28+
}
29+
30+
/// Do not report the task to the console at all.
31+
#[inline(always)]
32+
pub fn untracked() -> Self {
33+
Self
34+
}
35+
}
36+
37+
/// The guard returned by [`TaskSpan::enter`].
38+
///
39+
/// The enabled variant measures the busy time of a task as the time its guard
40+
/// is alive, so dropping it right away is a bug that this makes visible in both
41+
/// configurations. It borrows the span for the same reason: the enabled guard
42+
/// does, and code that compiles without the feature has to compile with it.
43+
#[must_use = "the task span is exited as soon as this is dropped"]
44+
pub(crate) struct Entered<'a>(PhantomData<&'a TaskSpan>);
45+
46+
/// The guard [`TaskSpan::enter`] returns, named the same in both variants so
47+
/// that the parity assertions can reach it.
48+
pub(crate) type EnterGuard<'a> = Entered<'a>;
49+
50+
/// The `runtime.spawn` span of a task.
51+
#[derive(Debug)]
52+
pub(crate) struct TaskSpan;
53+
54+
impl TaskSpan {
55+
#[inline(always)]
56+
#[expect(
57+
clippy::extra_unused_type_parameters,
58+
reason = "mirrors the enabled variant, which records the future's size"
59+
)]
60+
pub(crate) fn new<F>(_meta: SpawnMeta) -> Self {
61+
Self
62+
}
63+
64+
#[inline(always)]
65+
pub(crate) fn enter(&self) -> EnterGuard<'_> {
66+
Entered(PhantomData)
67+
}
68+
69+
#[inline(always)]
70+
pub(crate) fn waker_op(&self, _op: super::WakerOp) {}
71+
}
72+
73+
/// Instrument a closure about to be handed to the blocking pool, so that it
74+
/// shows up as a blocking task in the console.
75+
///
76+
/// This is a no-op unless the `console` feature is enabled.
77+
///
78+
/// Plumbing for `compio-runtime`, not covered by this crate's semver.
79+
#[doc(hidden)]
80+
#[inline(always)]
81+
pub fn instrument_blocking<T, F: FnOnce() -> T>(_meta: SpawnMeta, f: F) -> impl FnOnce() -> T {
82+
f
83+
}
84+
85+
/// Instrument a future blocked on by the runtime, so that it shows up as a
86+
/// task in the console.
87+
///
88+
/// This is a no-op unless the `console` feature is enabled.
89+
///
90+
/// Plumbing for `compio-runtime`, not covered by this crate's semver.
91+
#[doc(hidden)]
92+
#[inline(always)]
93+
pub fn instrument_block_on<F: Future>(_meta: SpawnMeta, fut: F) -> impl Future<Output = F::Output> {
94+
fut
95+
}
96+
97+
/// Instrument a future executed by a compatibility layer, so that it shows up
98+
/// as a task in the console.
99+
///
100+
/// This is a no-op unless the `console` feature is enabled.
101+
///
102+
/// Plumbing for `compio-compat`, not covered by this crate's semver.
103+
#[doc(hidden)]
104+
#[inline(always)]
105+
pub fn instrument_execute<F: Future>(_meta: SpawnMeta, fut: F) -> impl Future<Output = F::Output> {
106+
fut
107+
}

0 commit comments

Comments
 (0)