ExplorRobo

ROS 2 intra-process communication and composition explained

By ExplorRobo Editorial · · 12 min read

A ROS 2 system is usually a set of small nodes, each doing one job and talking over topics. That layout is good for fault isolation and development speed, and it costs something at runtime: even two nodes in the same executable send their messages through the middleware, which may involve serialisation and a transport hop (intra-process design document).

Composition and intra-process communication are the two halves of the fix. Composition puts the nodes in one process. Intra-process communication lets the messages between them skip the middleware. Neither is automatic, and the details decide whether a pipeline actually gets faster. If nodes, topics and executors are new, start with What is ROS 2? and ROS 2 executors and callback groups explained.

Composition is a deploy-time decision

The ROS 2 concept article states the design goal directly: process layout should be chosen at deploy time, not baked into the source. A node is written once, then either launched as its own process or loaded into a container with other nodes (About Composition).

That is the improvement over ROS 1 nodelets. A ROS 1 node and a nodelet were different APIs. In ROS 2, a component is an ordinary rclcpp::Node subclass compiled into a shared library, and the same class can run either way.

Three properties make a class loadable:

  • It takes a const rclcpp::NodeOptions & in its constructor.
  • It has no main function.
  • It registers itself with RCLCPP_COMPONENTS_REGISTER_NODE from rclcpp_components.

The composable node tutorial shows the constructor signature and the macro include. In CMake, the library is built with add_library(... SHARED ...), and registration uses one of two macros. rclcpp_components_register_node also generates a standalone executable, which is what you want when the node must still run on its own. rclcpp_components_register_nodes registers the plugin without an executable, for pure component libraries.

One consequence is easy to miss. A component does not own its thread. The concept article warns that it should not perform long running or blocking work in the constructor, since the component is not in control of the thread. Timers are the intended way to do periodic work.

Containers and the executor they carry

A container is a host process that loads components at runtime. The choice of container is really a choice of executor, and the documentation lists three:

ContainerExecutor
component_containerOne SingleThreadedExecutor for all components
component_container_mtOne MultiThreadedExecutor, four threads by default
component_container_isolatedA dedicated executor per component

component_container_mt accepts a thread_num parameter, so the thread count is a runtime decision:

ros2 run rclcpp_components component_container_mt --ros-args -p thread_num:=4

component_container_isolated takes --use_multi_threaded_executor to give each component a multi threaded executor instead of the default single threaded one. The isolated form protects components from each other’s blocking callbacks at the cost of more threads and more scheduling. Multi threaded containers interact with callback groups in ways worth understanding before you use one, which the executors guide covers.

Loading components at runtime

The CLI is the quickest way to see composition work. Start a container, then inspect and load into it:

ros2 run rclcpp_components component_container

ros2 component list
ros2 component types

ros2 component load /ComponentManager composition composition::Talker
ros2 component load /ComponentManager composition composition::Listener

ros2 component unload /ComponentManager 1 2

ros2 component list prints the container and its loaded components with their numeric ids. ros2 component types prints every component registered in the sourced workspace, grouped by package. Load prints the id and the node name it created, for example Loaded component 1 into '/ComponentManager' container node as '/talker'.

Names and parameters are set at load time rather than in code:

ros2 component load /ComponentManager composition composition::Talker --node-name talker2
ros2 component load /ComponentManager composition composition::Talker --node-namespace /ns
ros2 component load /ComponentManager image_tools image_tools::Cam2Image -p burger_mode:=true

Remapping the container itself does not remap the components inside it. The documentation calls this out twice, because it surprises people:

ros2 run rclcpp_components component_container --ros-args -r __node:=MyContainer -r __ns:=/ns
ros2 component load /ns/MyContainer composition composition::Listener

Turning on intra-process communication

Being in the same process is necessary but not sufficient. Each node opts in through NodeOptions:

struct Producer : public rclcpp::Node
{
  Producer(const std::string & name, const std::string & output)
  : Node(name, rclcpp::NodeOptions().use_intra_process_comms(true))
  {
    pub_ = this->create_publisher<std_msgs::msg::Int32>(output, 10);
  }
};

That snippet follows the official intra-process tutorial, which uses a producer and a consumer in one SingleThreadedExecutor.

For a component loaded at runtime, the flag can be passed without rebuilding:

ros2 component load /ComponentManager composition composition::Talker -e use_intra_process_comms:=true

-e passes extra arguments to the component manager. The composition tutorial documents exactly two: use_intra_process_comms, default false, and forward_global_arguments, default true.

In a launch file the same flag goes into extra_arguments on the component description:

from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode

ComposableNodeContainer(
    name='a_buncha_nodes',
    namespace='',
    package='rclcpp_components',
    executable='component_container',
    composable_node_descriptions=[
        ComposableNode(
            package='palomino',
            plugin='palomino::VincentDriver',
            name='vincent_driver',
            extra_arguments=[{'use_intra_process_comms': True}],
        ),
    ],
)

This is the pattern the composable node tutorial recommends for replacing a plain Node action. Launch file structure is covered in ROS 2 launch files explained.

Where the copy disappears

The tutorial is precise about the condition for zero copy: the publisher must publish a std::unique_ptr and the subscription callback must take a std::unique_ptr. Then ownership moves through the process instead of the bytes being duplicated. The demo prints the address of the published message and the address of the received message, and they match.

The same page states the negative case plainly: you can also publish and subscribe with const & and std::shared_ptr, but zero copy will not occur. If your callbacks take const MessageT & and you enable intra-process communication expecting a speedup, you removed the middleware hop but kept a copy.

Fan-out has its own rule. When one publisher feeds two intra-process subscribers, ownership can go to only one of them. The image pipeline demo, with two viewer nodes after a watermark node, shows one viewer receiving the original address and the other receiving a copy. Which subscriber gets the original is not defined. A pipeline that branches will therefore copy at the branch, no matter how the callbacks are written.

The design document explains the machinery: the publisher pushes the message into a buffer belonging to each matching subscription and wakes the executor through a waitable, so no middleware traffic is needed between nodes in the same process. Per-subscription buffers are also what allow different QoS settings on each subscription, which the older single buffer per publisher design could not do (design document, proposed implementation).

That document is also the source of a useful caution about message size. Its 2020 measurements, taken against the earlier implementation, put the break-even point for latency and CPU at messages of at least 5 KB. Small messages still pay the bookkeeping without gaining much, so intra-process communication is not a default worth switching on everywhere.

Seeing it run

The intra_process_demo package contains the smallest useful experiments, and each one prints pointer addresses so the result is visible rather than assumed:

ros2 run intra_process_demo two_node_pipeline
ros2 run intra_process_demo cyclic_pipeline
ros2 run intra_process_demo image_pipeline_all_in_one

The two node pipeline publishes, receives, and prints matching addresses. The cyclic pipeline passes one message back and forth between two nodes in a loop, incrementing it each time, and the address never changes. The image pipeline captures from a camera, annotates, and displays, writing the process id and pointer address onto the image so you can pause the window and compare.

The demo output has a detail worth reading closely. The first published message never arrives. The tutorial attributes this to best effort, volatile QoS and no latching: if the publisher sends before the subscription has matched, that message is gone. The cyclic demo avoids the problem with a one second sleep before the first publish. Production pipelines meet the same race, which is one reason lifecycle nodes exist: they hold processing until configuration and activation are complete (ROS 2 lifecycle nodes explained). QoS behaviour itself is covered in ROS 2 Quality of Service policies explained.

Pitfalls

A blocking constructor stalls the container. The component does not own the thread. Open devices, load models and wait for hardware in a timer or a lifecycle transition, not in the constructor.

Fan-out copies. One publisher with several intra-process subscribers gives the original to one and copies to the rest. If the branch is hot, restructure the graph or accept the copy deliberately.

shared_ptr and const & callbacks do not get zero copy. The option is on, the middleware hop is gone, and the copy is still there.

A multi threaded container changes concurrency. Components that were isolated by process boundaries now share an executor and a thread pool. Shared state needs callback groups and locking, or the isolated container.

One process is one failure domain. The concept article lists process and fault isolation as the reason to keep nodes separate. A crash in a composed container takes every component in it down.

Composition does not make Python nodes composable. The documented tooling loads C++ shared libraries. Python nodes stay in their own processes.

Runtime loads are not reflected for every path. The composition tutorial notes that manually composed and dlopen composed components do not appear in ros2 component list. Only container loads do.

When composition is worth it

Compose when a pipeline carries large or high rate data between nodes you control: images, point clouds, audio, tensor-like payloads. Those are the cases where serialisation and transport dominate, and where a unique_ptr handover removes real work. Camera to rectification to detection to tracking is the canonical example, and it is what the image pipeline demo models.

Stay with separate processes when you need fault isolation, when components come from different teams with different release cycles, when a crash must not take down the rest of the stack, or when the messages are small enough that the bookkeeping outweighs the transport. The deployment decision is reversible, which is the point: keep the node code composable, then choose the layout per robot and per product.


Sources

Frequently asked questions

What is the difference between ROS 2 composition and intra-process communication?
Composition is a deployment choice: several nodes are loaded into one container process as shared libraries. Intra-process communication is a transport choice: when a publisher and a subscription live in the same process, messages can be handed over in memory instead of going through the middleware. Composition makes that possible, but each node still has to enable the option.
Which ROS 2 component container should I use?
component_container runs all components on one SingleThreadedExecutor, component_container_mt runs them on one MultiThreadedExecutor with a configurable thread_num, and component_container_isolated gives each component its own executor. Start with the single threaded container and move up when a component blocks others.
Does intra-process communication always avoid message copies?
Only when the publisher and the subscription both use std::unique_ptr. Publishing with a const reference or a std::shared_ptr works, but the ROS 2 documentation states that zero copy does not occur in that case. When several intra-process subscriptions receive the same message, one can take ownership and the others receive copies.
Can I use ROS 2 composition with Python nodes?
The documented composition tooling is C++. rclcpp_components loads shared libraries into a container process, and Python nodes are not loadable that way. The intra-process switch described here is a C++ NodeOptions flag.
Why is the first message missing in the intra-process demo?
The two node pipeline demo publishes once per second with best effort, volatile QoS and no latching, so a message sent before the subscription matches is dropped. The documentation shows the message with value 0 missing and explains the race.

Related reading