Skip to content

perf: add synchronous callback path for Node-scoped subscriptions#652

Open
azerupi wants to merge 2 commits into
ros2-rust:mainfrom
azerupi:feature/node-scoped-sync-callback
Open

perf: add synchronous callback path for Node-scoped subscriptions#652
azerupi wants to merge 2 commits into
ros2-rust:mainfrom
azerupi:feature/node-scoped-sync-callback

Conversation

@azerupi

@azerupi azerupi commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

perf: add a synchronous callback path for node-scoped subscriptions

This is a fix for part of the performance issue reported in #627.

Subscriptions were paying an async tax they never used

Receiving a message with a plain synchronous callback is the single most common thing you do in ROS 2:

node.create_subscription::<StringMsg, _>("topic", |msg| {
    println!("{}", msg.data);
})?;

Yet under the hood, every node-scoped subscription callback was wrapped as if it were async, even a one-line synchronous closure. For each message received, that meant:

  • Box::pin(async { … }) to turn the callback into a future,
  • Arc::new(Task { … }) to enqueue it, and
  • an Arc::clone for the dispatch

Three heap allocations per message, plus a hop through the executor's task queue so the callback runs on a later turn instead of inline. That's pure overhead on the hottest path in the system, paid by the ~95% of subscriptions whose callbacks are perfectly synchronous and have no reason to touch a future at all.

The fix: call synchronous callbacks synchronously

This splits NodeSubscriptionCallback into Sync* and Async* variants:

  • Sync callbacks (FnMut(T) + Send) are now invoked directly in the wait-set processing, no Future, no Arc, no task-queue dispatch. The message is taken and the callback runs inline, immediately and in order.
  • Async callbacks keep the existing behavior (return a future, dispatched through the executor task queue) and are opted into explicitly via create_async_subscription / IntoAsyncSubscriptionCallback.

IntoNodeSubscriptionCallback now produces the Sync variants, so the default create_subscription path gets the fast path automatically, no API change at the call site.

A nice side effect: friendlier bounds

The synchronous path only needs FnMut + Send instead of the old Fn + Send + Sync. In practice that means your subscription closure can now capture and mutate state directly and doesn't have to be Sync.

Behavioral note

Synchronous callbacks now run inline during spin, in receipt order, rather than being deferred onto the task queue. This matches the intuition (and rclcpp's default) that a sync subscription callback runs while you're spinning. Genuinely asynchronous work should use create_async_subscription, which is unchanged.

Breaking change

The public NodeSubscriptionCallback enum variants are renamed (RegularAsyncRegular, BoxedAsyncBoxed, LoanedAsyncLoaned, and the …WithMessageInfo counterparts), with the new Sync* variants added alongside. Code that matches on these variants directly needs updating; code that creates subscriptions through the normal closure API does not.

azerupi and others added 2 commits June 20, 2026 17:42
Previously, all Node-scoped subscription callbacks were wrapped in async
futures even when the callback was synchronous. This added 3 heap
allocations per received message: Arc::clone, Box::pin(async { }),
and Arc::new(Task { }) for the async task queue.

Add explicit Sync/Async variants to NodeSubscriptionCallback. Sync
callbacks are called directly with no Arc, no Future, and no task queue
dispatch. Rename existing variants with Async prefix (breaking change).

IntoNodeSubscriptionCallback now produces Sync variants, with relaxed
bounds (FnMut + Send instead of Fn + Send + Sync).
IntoAsyncSubscriptionCallback produces Async variants as before.
CI failed on the fmt colcon test; these files were not formatted
with nightly rustfmt as required by the workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant