feat: add a shared timer scheduler for high-rate timers (Tokio Executor 4/4)#657
Draft
azerupi wants to merge 4 commits into
Draft
feat: add a shared timer scheduler for high-rate timers (Tokio Executor 4/4)#657azerupi wants to merge 4 commits into
azerupi wants to merge 4 commits into
Conversation
Add an opt-in way for primitives to report readiness via rcl's push callbacks (rcl_*_set_on_new_*_callback) instead of being polled in a wait set, as the foundation for an event-driven executor. `RclPrimitive::register_on_ready` installs a callback that the middleware invokes when the entity becomes ready and returns an `OnReadyHandle` (RAII) that deregisters on drop. `OnReadyRegistration` wraps the unsafe rcl setter: it boxes the callback context for a stable address and, on drop, clears the callback before freeing the context (finalizing the rcl entity first) so the middleware can never invoke a freed context during teardown. Implemented for subscriptions, services, and clients. No executor consumes this yet, so the basic executor is unchanged.
Add a Tokio-based executor (opt-in via the `tokio-executor` feature, enabled by default) that learns entity readiness from the rcl push callbacks added in the previous commit instead of polling rcl_wait. Each Worker drains its own mailbox on a dedicated Tokio task, so one Worker's callbacks are serialized and ordered while independent Workers run concurrently across Tokio's thread pool — multi-core concurrency with no per-event task spawn. Subscriptions, services, and clients are driven by push callbacks; timers by tokio::time. Worker tasks are gated by spinning (callbacks only run while spinning, and spin() waits for in-flight callbacks before returning); spin() honors only_next_available_work (spin_once) and reports a timeout as a Timeout error, matching the basic executor. Notifications coalesce per entity to bound the mailbox, a panicking callback is contained rather than wedging the worker, and push-callback registrations finalize the rcl entity before freeing their context to avoid a teardown use-after-free. Opt out with `default-features = false` to drop the Tokio multi-threaded runtime and macros for a lighter build. Action support follows in a separate commit.
Drive action servers and clients with rcl's action push callbacks (rcl_action_*_set_*_callback) on the event-driven executor. An action is a composite primitive: each internal source (the server's goal/cancel/result services; the client's feedback/status subscriptions and goal/cancel/result clients) registers its own push callback tagged with the ReadyKind flag it satisfies. Because the notifications for one action coalesce into a single mailbox wakeup, the executor accumulates a merged ReadyKind per entity and runs the primitive with it. Goal expiration has no push callback, so the server polls it on a coarse interval. Adds end-to-end tests for a goal round-trip (feedback + result) and for cancellation on the Tokio executor. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note: This is based on #655 , I was hoping I could target that PR as base branch but that seems to only work if the branches are in the ros2_rust repo and not in my fork.
This is part of a set of PRs that attempt to add an event-driven Tokio executor to rclrs. The goal is to have something that:
I tried to split the work up into multiple PRs that build on top of each other to make the reviewing easier.
To make the CI work reliably for the Tokio event executor changes we need the following PRs to be merged:
Disclaimer
This work was heavily helped by the use of AI. That helped me clear a lot of ground quickly, but I want to be honest about it.
I'm submitting this set of PRs as a draft to get eyes on it and have other people help test, find limitations and flaws and provide feedback on how to improve it in order to reach the quality to be able to merge this.
a shared timer scheduler for high-rate timers
In #654, timers are driven one task each: a
tokio::timesleep to the next deadline, then a wait for the worker to run the callback before scheduling the next fire. That capped high-rate timers, a 1 kHz timer fired at roughly 600 Hz, as called out in #654's known limitations. This PR removes that cap.Why it was capped
Profiling the old driver found three costs per fire, only the first of which is fundamental:
is_ready.Costs 2 and 3 are what a per-timer, execution-coupled design pays. Ceiling controls (a bare loop counting ticks, no ROS) confirmed that both a dedicated thread on a
Condvarand Tokio'ssleep_untilhold 100% of target through 20 kHz on this host, so the wait mechanism itself was never the limit, the coupling was.How it works
The per-timer drivers are replaced by one shared
TimerSchedulerper executor, modeled on theTimersManagerin rclcpp's EventsExecutor. It keeps a min-heap of timers keyed by next deadline. A single dedicated OS thread sleeps until the earliest deadline, enqueues a tick on the owning worker's mailbox, and immediately advances that timer's deadline, without waiting for the callback to run. Decoupling pacing from execution removes costs 2 and 3.Pacing reuses the same coalescing path as every other entity (see #654): each tick is delivered through the timer's
EntityDispatch, so a tick only enqueues a mailbox message if one is not already pending. When the worker is slower than the timer, the late tick is dropped rather than allowed to build a backlog. Deadlines advance byperiodfrom the previous deadline, not from "now", so a timer that keeps up does not drift.Why a dedicated thread and not Tokio
The waiter is a portable
std::threadparked onCondvar::wait_timeout, not a Tokio task.I tried to make this work with Tokio using
tokio::select!oversleep_untiland awatch/Notifybecause that would have made the design significantly simpler. However the performance was significantly worse. I measured two Tokio waiters against the condvar thread and got the following results (single timer, % of target achieved):The first Tokio variant runs the waiter on the executor's shared multi-threaded runtime, and my first guess was that it degraded because it contends with the very worker tasks it is pacing. So I built a second variant on its own dedicated single-thread runtime to remove that contention and it was worse, capping at ~900 fires/s regardless of target.
The cost of the Condvar design is that there is a lost-wakeup window between "deciding to sleep" and "actually sleeping" that has to be managed. The waiter does that with the "generation counter" that acts as a version stamp. It snapshots under the state lock and re-checks under the condvar's own lock right before sleeping, so a registration or reset that lands in that gap is not missed.
Lifecycle
Timers notify the scheduler when their state changes, through a small handle stored on the timer.
reset()re-seeds the deadline from rcl and wakes the waiter.cancel()drops the timer from the heap but keeps a tombstone entry so a laterreset()can re-arm it. Dropping the timer removes the entry entirely, ordered beforercl_timer_finiso the scheduler thread can no longer touch it.Performance
Measured on a Linux machine, 5 s windows, empty callbacks unless noted. A single timer now tracks its target up to the worker round-trip limit (the old driver delivered ~600 Hz at a 1 kHz target):
A single timer holds ~100% through ~16 kHz, then cliffs near 20 kHz to about half the target: the scheduler still paces at the requested rate, but the worker sustains only ~10 kHz of round-trips and the overload policy drops every other tick. That is far above the sub-kHz range timers are normally used at; ≤5 kHz is a comfortable single-timer recommendation.
Many timers scale to a high aggregate because the heap is cheap and the cost is total callback CPU on the worker, not scheduling. 64 timers at 10 kHz each delivered ~640k fires/s at 100%. Callbacks that do real work serialize on the worker, so the practical limit is
rate × work_usapproaching one CPU-second per wall second.As with the executor itself, I'd encourage people to run their own timer benchmarks so we can find cases this doesn't handle well.
Known limitations
Cross-platform
I've only tested this on Linux.
Simulation time
Like #654, this PR does not properly support simulation time. Sim-time timers run on the scheduler but are paced against the wall clock, so their rate does not track a paused or scaled
/clock. Proper support should be a follow-up and probably depends on jump-callback infrastructure (#629).