feat: try_tick() for single-threaded runtimes

Make single-threaded tick driving observable so callers can tell whether a tick actually performed work.

- src/runtime.rs: add try_tick() returning whether any worker did work, add has_work() reporting schedulable work, and reduce tick() to a thin wrapper that ignores try_tick()'s result
- src/worker.rs: extract the fast-idle predicate into a reusable Worker::has_work() (backlog plus non-empty spawn/transfer/admin queues plus pending extension work) and reuse it from both the new runtime has_work() and the idle short-circuit in tick_once()

Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-15 14:48:13 +04:00
parent 9f950aa985
commit a185c8068c
2 changed files with 44 additions and 17 deletions

View file

@ -438,18 +438,42 @@ impl Runtime {
} }
} }
/// Return whether the single-threaded runtime currently has schedulable work.
///
/// Panics if called on a multi-threaded runtime — use `run()` instead.
pub fn has_work(&self) -> bool {
assert!(
self.config.num_threads < 2,
"has_work() is only valid for single-threaded runtimes; use run() for multi-threaded"
);
self.tick_workers.borrow().iter().any(Worker::has_work)
}
/// Try to drive one tick of the single-threaded runtime.
///
/// Returns `false` if no worker performed work.
/// Returns `true` if at least one worker performed work.
///
/// Panics if called on a multi-threaded runtime — use `run()` instead.
pub fn try_tick(&self) -> bool {
assert!(
self.config.num_threads < 2,
"try_tick() is only valid for single-threaded runtimes; use run() for multi-threaded"
);
let tc = self.make_tick_context();
self.tick_workers
.borrow_mut()
.iter_mut()
.fold(false, |did_work, worker| worker.tick_once(&tc) || did_work)
}
/// Drive one tick of the single-threaded worker. /// Drive one tick of the single-threaded worker.
/// ///
/// Panics if called on a multi-threaded runtime — use `run()` instead. /// Panics if called on a multi-threaded runtime — use `run()` instead.
pub fn tick(&self) { pub fn tick(&self) {
assert!( let _ = self.try_tick();
self.config.num_threads < 2,
"tick() is only valid for single-threaded runtimes; use run() for multi-threaded"
);
let tc = self.make_tick_context();
for worker in self.tick_workers.borrow_mut().iter_mut() {
worker.tick_once(&tc);
}
} }
/// Spawn worker threads and start processing, returning a handle /// Spawn worker threads and start processing, returning a handle

View file

@ -105,6 +105,17 @@ impl Worker {
} }
} }
pub(crate) fn has_work(&self) -> bool {
self.has_backlog
|| !self.spawn_rx.is_empty()
|| !self.transfer_rx.is_empty()
|| !self.admin_rx.is_empty()
|| self
.worker_ext
.as_ref()
.map_or(false, |e| e.has_pending_work())
}
/// Run one iteration of the worker loop. Returns `true` if any work was done. /// Run one iteration of the worker loop. Returns `true` if any work was done.
/// Drain the spawn queue, inserting new actors into the pool. /// Drain the spawn queue, inserting new actors into the pool.
/// Used in phases 1 and 4 of tick_once. /// Used in phases 1 and 4 of tick_once.
@ -283,15 +294,7 @@ impl Worker {
// Fast idle path: skip the entire tick when nothing could have changed. // Fast idle path: skip the entire tick when nothing could have changed.
// Cost: ~3 atomic loads, zero syscalls, zero actor iteration. // Cost: ~3 atomic loads, zero syscalls, zero actor iteration.
if !self.has_backlog if !self.has_work() {
&& self.spawn_rx.is_empty()
&& self.transfer_rx.is_empty()
&& self.admin_rx.is_empty()
&& !self
.worker_ext
.as_ref()
.map_or(false, |e| e.has_pending_work())
{
return false; return false;
} }