Tomasz Czajka

Robotics 路 Async Rust

Why r2r beats the classic ROS 2 stack

ROS 2 is the backbone of modern robotics, but the way most of us write nodes, with rclcpp or rclpy built by colcon, hasn't aged well. r2r gives you idiomatic async Rust on top of the same ROS 2 core, and once you try it, it's hard to go back.

Rust r2r ROS 2 Tokio DDS
Atlas humanoid robot standing, front view
Atlas humanoid robot. Photo: DARPA, public domain.

First, what r2r actually is

r2r doesn't replace ROS 2 itself. It's a Rust client library that binds directly to rcl, the same C layer that rclcpp and rclpy sit on, so your nodes still talk to every other ROS 2 node over DDS. What changes is everything above that layer: the language, the concurrency model, and the build tooling. So this comparison is r2r versus the standard way of writing ROS 2 nodes: C++ or Python, built with colcon.

8 advantages of r2r

1

Async-first by design

Subscriptions are Streams; service calls and action goals are Futures. You compose them with tokio::select!, timeouts and channels instead of juggling executors, callback groups, and state shared across callbacks.

2

Plain cargo, no colcon

Message bindings are generated at build time from your sourced ROS 2 environment. No package.xml, no CMakeLists, no ament: cargo build, cargo test and rust-analyzer just work.

3

Memory and thread safety

The compiler rules out data races, use-after-free and dangling pointers in callbacks, the classic causes of crashing C++ nodes. On a walking humanoid, that matters.

4

Same ROS 2 core underneath

Because r2r wraps rcl, it works with any RMW implementation (Fast DDS, Cyclone DDS, Zenoh) and interoperates with existing C++ and Python nodes: topics, services, actions, parameters, and clocks.

5

Native speed, no GIL

Compiled Rust with zero-cost abstractions and no garbage collector. Unlike rclpy, there's no Global Interpreter Lock throttling high-rate topics, so latency stays predictable.

6

The whole crates.io ecosystem

serde, nalgebra, tracing, axum, WebRTC: put a ROS 2 node, a web API and a telemetry stream into one binary without a single CMake find_package.

7

Untyped messages for bridges

subscribe_untyped and create_publisher_untyped work with JSON via serde, so generic bridges, recorders and debugging tools need no compile-time message types.

8

Modern tooling and deployment

clippy, rustfmt, cargo test and reproducible Cargo.lock builds in CI. Ship one self-contained binary in a slim container instead of a whole install/ workspace tree.

What it looks like

A complete node: subscribe to joint states and publish velocity commands, all inside a Tokio runtime. Compare that with the equivalent rclcpp boilerplate.

# Cargo.toml
[dependencies]
r2r = "0.9"
tokio = { version = "1", features = ["full"] }
futures = "0.3"

# build & run: no colcon, no package.xml
$ source /opt/ros/jazzy/setup.bash
$ cargo run --release
use futures::StreamExt;
use r2r::{geometry_msgs::msg::Twist, sensor_msgs::msg::JointState, QosProfile};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let ctx = r2r::Context::create()?;
    let mut node = r2r::Node::create(ctx, "teleop_bridge", "")?;

    let mut joints = node.subscribe::<JointState>("/joint_states", QosProfile::default())?;
    let cmd = node.create_publisher::<Twist>("/cmd_vel", QosProfile::default())?;

    // rcl spins on its own thread, everything else is plain async Rust
    tokio::task::spawn_blocking(move || loop {
        node.spin_once(std::time::Duration::from_millis(5));
    });

    while let Some(state) = joints.next().await {
        let mut twist = Twist::default();
        twist.linear.x = state.velocity.first().copied().unwrap_or(0.0) * 0.5;
        cmd.publish(&twist)?;
    }
    Ok(())
}

To be fair: the trade-offs

  • r2r is community-maintained, not one of the official ROS 2 client libraries, so tutorials and Q&A are scarcer than for rclcpp or rclpy.
  • Building requires a sourced ROS 2 installation, because bindings are generated from the message packages it finds.
  • The first build is slow, since bindgen and message generation run for every sourced package. Incremental builds are fine.
  • Launch files and colcon workspaces need a little glue (for example colcon-ros-cargo) to treat a cargo crate as a ROS package.

Verdict

For new middleware, bridges, teleoperation and control pipelines, r2r is the better default: async that actually fits robotics I/O, compile-time safety, and a build that's just cargo. Keep rclcpp for legacy code and vendor drivers; reach for r2r for everything new.