Skip to the content
FixTheRobot Explore Robotics / UAV / Module 7 / M7-T5

UAV Systems Engineering · Module 7

M7-T5: ROS 2 execution and scheduling

A ROS 2 node does not run when a message arrives; it runs when the executor gets to it. Latency lives in that gap.

  • Lesson 5 of 19
  • Embedded Systems, Timing and System Integration
  • Implementation-specific
  • About 6 minutes to read

ROS 2 is easy to start and easy to make slow in ways that are not visible in the code. The executor, the callback groups and the QoS queue settings decide the order and timing of your callbacks. A long callback, a shared mutex or a deep queue silently adds hundreds of milliseconds.

Learning objectives

After this lesson, you should be able to:

  • Describe the ROS 2 executor model and the role of callback groups.
  • Predict how a long callback affects other callbacks on the same thread.
  • Choose QoS history, depth and reliability for sensor and command streams.
  • Apply practical measures: separate executors, reentrant groups and bounded work in callbacks.

Conceptual explanation

Implementation-specific ROS 2 executor behaviour has changed between distributions (new executors and events-based execution have been added). The principles below hold across them; check the documentation of the distribution you use for the available executors and their guarantees.

In ROS 2 a node’s work is a set of callbacks: subscription handlers, timers, service handlers, action handlers. An executor waits for ready callbacks and runs them on one or more threads.

  • Single-threaded executor: one callback at a time; a callback that takes 80 ms delays every other callback, in all nodes spun on that executor, by up to 80 ms.
  • Multi-threaded executor: several callbacks in parallel, subject to callback groups: a mutually exclusive group runs one callback at a time; a reentrant group allows parallel execution of its callbacks (your code must then be thread-safe).
  • QoS sets the queue behaviour: history (keep last NN or keep all), depth, reliability (best effort or reliable) and durability. A deep queue on a slow subscriber turns an overload into growing latency, as in the workload lesson.
ROS 2 callback executionMessages arrive from DDS into the node’s subscription queues. An executor picks ready callbacks (timers, subscriptions, services) and runs them on its threads according to callback groups. A long callback delays everything that shares its thread.DDS receiveQoS, historySubscription queuedepth, dropsExecutorready setCallback groupmutex / reentrantCallbackruns to completionLatency comes from queues, from the executor’s order, and from long callbacks.
The path from network to callback. Each box can add delay.
Animation of a callback timeline. A 100 hertz timer callback and a 250 millisecond image-processing callback share one thread in the first schedule: whenever the heavy callback runs, the timer callbacks queue up behind it and fire up to 80 milliseconds late. In the second schedule the heavy callback runs on its own thread and the timer fires on time. A strip shows the timer start latency in both cases and the largest latency is printed.
ROS 2 executors run callbacks to completion: a long callback on the same thread delays every other callback by up to its duration. Separate callback groups and executors (or a worker thread) keep time-critical callbacks on time. Simulated with an 80 ms heavy callback every 250 ms and a 0.5 ms timer callback at 100 Hz. (Looping, about 6 seconds; drawn and computed for this course with illustrative values.)

Engineering intuition

An executor is a single cashier serving a queue of customers (callbacks). One customer with a huge basket (a 100 ms callback) delays everyone behind them. Opening more tills (threads) helps only if customers are independent: two customers who need the same item from the same shelf (a mutex, a mutually exclusive group) still wait for each other.

The fixes are structural: keep callbacks short (hand heavy work to a worker thread or a separate node), put time-critical callbacks (IMU, control) on their own executor and thread, make sensor subscriptions best-effort with depth 1–5, and measure the callback durations.

Mathematical formulation

For callbacks sharing one thread, the worst-case latency of a callback of a short job ii is bounded by the longest job that may be running when it becomes ready plus the work queued ahead:

Single-thread worst-case latency
Li≤max⁡jCj+∑k≠i, queuedCk+CiL_i \le \max_j C_j + \sum_{k\ne i,\ \text{queued}} C_k + C_i

For periodic timers the same fixed-priority analysis of the RTOS lesson applies, but ROS 2 executors do not preempt: they are non-preemptive, so every callback contributes blocking equal to its full execution time. A callback of 30 ms on the same thread as a 10 ms timer makes the timer jitter by up to 30 ms. With a subscription queue of depth qq and a callback taking SS for a message stream at period Tm<ST_{m}<S, messages are dropped at the rate (1/Tm−1/S)(1/T_{m}-1/S) once the queue is full, and the data age at processing is about qq SS.

CjC_{j}
execution time of callback jj.
qq
subscription queue depth.
SS
callback service time per message.

Architecture and data flow

DDS middleware receives samples into a per-subscription history buffer (QoS) → the executor’s wait set marks the subscription ready → the executor takes the message and runs the callback → the callback publishes downstream messages. Timers are executed in the same way. The order among ready callbacks depends on the executor implementation (for example round-robin over entities in the classic executor); do not rely on an order that is not documented.

Real-world UAV applications

  • Making a VIO node and its consumers meet frame deadlines.
  • Separating IMU preintegration from heavy image processing.
  • Tuning the offboard setpoint publisher so it never stalls.
  • Diagnosing latency spikes seen in end-to-end measurements.

Implementation example

This runs as shown. A model of a single-threaded, non-preemptive executor with a 100 Hz timer and an 80 ms heavy callback sharing the thread, against the same system with the heavy callback moved to a separate thread. It reports the timer’s jitter. (AA model of the mechanism, not rclcpp code.)

import numpy as np
def timer_jitter(shared, T=0.010, C_timer=0.0005, C_heavy=0.080, T_heavy=0.250, H=30.0):
    # non-preemptive single thread: each callback runs to completion. Returns the start latency of every timer firing.
    t = 0.0; lat = []; next_timer = 0.0; next_heavy = 0.05; free = 0.0
    while next_timer < H:
        if shared and next_heavy <= next_timer and free <= next_heavy + 1e-12 or (shared and next_heavy <= free and next_heavy <= next_timer):
            start = max(free, next_heavy); free = start + C_heavy; next_heavy += T_heavy; continue
        start = max(free, next_timer); lat.append(start - next_timer); free = start + C_timer; next_timer += T
    return np.array(lat)
a = timer_jitter(True); b = timer_jitter(False)
print(f"timer start latency, shared thread: p99 {np.percentile(a, 99) * 1e3:5.1f} ms max {a.max() * 1e3:5.1f} ms ; separate thread: max {b.max() * 1e3:5.2f} ms")
assert a.max() > 0.06 and b.max() < 1e-9
A heavy callback on the same non-preemptive thread delays the 100 Hz timer by tens of milliseconds. A model of the mechanism, not rclcpp code.

The corresponding structure in rclpy: a reentrant callback group and a multi-threaded executor, with best-effort QoS for the sensor stream (check the API of your ROS 2 distribution).

import rclpy
from rclpy.node import Node
from rclpy.callback_groups import ReentrantCallbackGroup, MutuallyExclusiveCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from sensor_msgs.msg import Image, Imu

class Perception(Node):
    def __init__(self):
        super().__init__('perception')
        qos = QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, history=HistoryPolicy.KEEP_LAST, depth=2)    # fresh and incomplete beats stale and complete
        self.heavy_group = MutuallyExclusiveCallbackGroup()      # image processing: one at a time
        self.fast_group = ReentrantCallbackGroup()               # IMU and timers: never wait behind images
        self.create_subscription(Image, 'camera/image', self.on_image, qos, callback_group=self.heavy_group)
        self.create_subscription(Imu, 'imu/data', self.on_imu, qos, callback_group=self.fast_group)
    def on_image(self, msg): pass       # heavy work here, or hand it to a worker thread
    def on_imu(self, msg): pass         # short and bounded

rclpy.init(); node = Perception(); ex = MultiThreadedExecutor(num_threads=3); ex.add_node(node); ex.spin()
Callback groups and a multi-threaded executor so the IMU handler never waits behind an image callback. Syntax-checked only.

Common engineering mistakes

Heavy work inside a callback on the shared thread

It delays every other callback; offload or separate.

Reliable QoS with deep history for camera streams

A slow consumer builds latency; use best effort, depth 1–5.

Reentrant groups without thread safety

Races on shared state; protect shared data.

Relying on undocumented callback order

It differs between executors and releases.

Not measuring callback durations

Intuition about execution times is usually wrong; instrument and log.

Failure modes

Failure modes, symptoms and causes
Failure modeHow it shows upLikely cause
Timers fire late in burstsJitter on a periodic publisherLong callback on the same thread
Latency grows with run timeData age risingDeep queue and a slower consumer
Deadlock or stallNode stops processingMutually exclusive group waits on a service call from inside a callback
Messages dropped silentlyLower rate than publishedQoS depth too small for a bursty consumer or best-effort loss

Debugging approach

  1. Log the start time and duration of each callback.
  2. Use ros2 topic delay or header-stamp comparisons for message age.
  3. Trace with tools such as ros2_tracing to see executor activity.
  4. Change one thing at a time: thread count, group type, QoS depth.

Practical exercises

  1. Measure the duration of every callback in a node of yours and list the three longest.
  2. Move the longest callback to its own executor and compare the timer jitter.
  3. Set the subscription depth to 1, 5 and 50 for a camera stream and measure data age.
  4. Explain why a service call inside a mutually exclusive callback can deadlock.

Interview questions

Write an answer in your own words, then reveal the model answer. Nothing is sent anywhere.

Question 1

Why can a ROS 2 timer fire late?

Model answer

The executor is non-preemptive: if another callback on the same thread is running, the timer waits until it finishes, so jitter is bounded by the longest callback sharing the thread.

Question 2

What is the difference between mutually exclusive and reentrant callback groups?

Model answer

Callbacks in a mutually exclusive group never run concurrently; callbacks in a reentrant group can, so their code must be thread-safe.

Question 3

Which QoS would you choose for a camera stream feeding a real-time estimator?

Model answer

Best-effort reliability and keep-last with a small depth, so the consumer sees recent frames and an overload drops old data rather than building latency.

References and further reading

Primary papers, official documentation and standards. Each claim above that is specific to a tool or version points here.

What’s next

Mark this lesson

Saved in this browser and synced to your account when you are signed in.

Next lesson

M7-T6: End-to-end latency

Budget and measure end-to-end latency and jitter in percentiles, and relate delay to position error and stability.