【ROS2 通信机制】话题、服务、动作

一、ROS2通信机制概述

ROS 2 的三种核心通信机制(话题 Topic服务 Service动作 Action)是基于不同的底层层次结构与应用场景设计的。

ROS 2 三大通信机制核心对比:

| 维度 | 话题 (Topic) | 服务 (Service) | 动作 (Action) |
| 通信模式 | 异步发布/订阅 (Pub/Sub) | 同步/异步 请求-响应 (Req/Resp) | 异步 目标-反馈-结果 (Goal/Feedback/Result) |
| 基准关系 | 一对多 / 多对多 | 一对一 / 多对一(多个 Client 发往同一个 Server) | 一对一 / 多对一 |
| 底层实现 | DDS 主题发布 | DDS 双向 RPC 调用 | 由3 个 Topic + 2 个 Service 组合封装实现 |
| 数据流向 | 单向传输(Publisher \\to Subscriber) | 双向传输(Client Server Client) | 双向多通道传输(包含目标发送、状态反馈、最终结果) |

典型适用场景 传感器数据高频连续流(如激光雷达 /scan、相机图像、底盘里程计) 简短、即时完成的逻辑查询或状态切换(如开灯、参数查询、状态复位) 耗时长、可打断、需要过程反馈的任务(如导航到目标点、机械臂抓取)
1. 话题 (Topic)
  • 同步/异步非阻塞异步通信。Publisher 只管推送数据到 DDS 网络,无需等待 Subscriber 确认接收,不会阻塞主线程。

  • 拓扑结构 :支持多对多。同一话题可以存在多个 Publisher 和多个 Subscriber。

2. 服务 (Service)
  • 同步/异步

    • 客户端(Client) :在 C++ 中通过 async_send_request() 默认实现异步非阻塞发送(推荐,配合 Future/Callback 操作);也支持等待结果的同步阻塞调用。

    • 服务端(Server):接收请求并处理完成后返回响应。

  • 拓扑结构 :支持 多对一。多个 Client 可以向同一个 Server 发送请求,但每一次具体的请求-响应交互都是一对一独立的。

3. 动作 (Action)
  • 同步/异步全异步通信。Client 发送 Goal 后不会阻塞,可以一边执行其他逻辑,一边接收 Server 实时发回的 Feedback;支持随时发送 Cancel 请求中断任务。

  • 底层构成(重要概念):动作是一种应用层的通信机制,其底层是基于话题和服务来实现的。

    1. Goal Service:发送任务目标并返回是否接受目标。

    2. Cancel Service:发送取消任务指令。

    3. Result Service:任务结束时获取最终执行结果。

    4. Feedback Topic:连续广播任务过程中的中间状态数据。

    5. Status Topic:广播 Action 服务端的当前状态机状态。

  • Action 状态机:ROS 2 Action 定义的标准状态转移包含以下几类核心状态:

状态类型 状态名称 含义说明
初始状态 STATUS_UNKNOWN 状态未定义/未初始化。
目标处理 STATUS_ACCEPTED Server 已接收目标,等待开始执行。
执行中 STATUS_EXECUTING Server 正在后台执行长时任务。
取消流程 STATUS_CANCELING Client 提出了取消请求,Server 正在清理资源并准备打断任务。
终止状态 STATUS_SUCCEEDED 任务成功完成(终态)。
终止状态 STATUS_CANCELED 任务已被成功取消(终态)。
终止状态 STATUS_ABORTED 执行过程中发生错误,Server 主动终止了任务(终态)。

二、ROS2 通信接口与接口文件定义

ROS 2 使用强类型的接口定义文件,在编译时会自动生成对应语言(C++ / Python)的数据结构和序列化/反序列化代码。

复制代码
<ros_package>/
  ├── msg/
  │   └── SensorRead.msg    # 话题接口文件
  ├── srv/
  │   └── TriggerTask.srv   # 服务接口文件
  └── action/
      └── NavigateTo.action # 动作接口文件
1. 话题接口文件 (.msg)

仅包含传输的数据字段定义。

复制代码
# SensorRead.msg
std_msgs/Header header
float64 temperature
float64[3] position
2. 服务接口文件 (.srv)

--- 分割为请求(Request)响应(Response)两部分。

复制代码
# TriggerTask.srv
string task_name      # Request: 客户端请求的数据
---
bool success          # Response: 服务端返回的结果
string message
3. 动作接口文件 (.action)

由两个 --- 分割为 目标(Goal)结果(Result)反馈(Feedback) 三部分。

复制代码
# NavigateTo.action
geometry_msgs/PoseStamped target_pose  # 1. Goal: 导航目标点
---
geometry_msgs/PoseStamped final_pose   # 2. Result: 最终到达位置与耗时
float32 total_time
---
geometry_msgs/PoseStamped current_pose # 3. Feedback: 实时当前位置与剩余距离
float32 distance_remaining
4. 编译接口文件

完成定义后,还需要在功能包的CMakeLists.txt中配置编译选项,让编译器在编译过程中,根据接口定义,自动生成不同语言的代码:

复制代码
find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/GetObjectPosition.srv"
)

功能包的package.xml文件中也需要添加代码生成的功能依赖:

复制代码
 <build_depend>rosidl_default_generators</build_depend>
 <exec_depend>rosidl_default_runtime</exec_depend>
 <member_of_group>rosidl_interface_packages</member_of_group>

设计选型决策法则

在机器人系统设计时,选择通信接口的标准逻辑:

  1. 数据是连续高频发出的吗? Topic(如传感器数据数据流)。

  2. 需要执行一个快速动作并立马知道成功/失败吗? Service(如查询传感器状态、切换硬件模式)。

  3. 任务需要执行几秒甚至更久,中途需要知道进度,或者随时可能被用户取消吗? Action(如自主导航、轨迹规划执行)。

三、话题、服务、动作的创建

1. 创建发布者代码流程如下:
  • 编程接口初始化

  • 创建节点并初始化

  • 创建发布者对象

  • 创建并填充话题消息

  • 发布话题消息

  • 销毁节点并关闭接口

    import rclpy # ROS2 Python接口库
    from rclpy.node import Node # ROS2 节点类
    from std_msgs.msg import String # 字符串消息类型

    """
    创建一个发布者节点
    """
    class PublisherNode(Node):

    复制代码
      def __init__(self, name):
          super().__init__(name)                                    # ROS2节点父类初始化
          self.pub = self.create_publisher(String, "chatter", 10)   # 创建发布者对象(消息类型、话题名、队列长度)
          self.timer = self.create_timer(0.5, self.timer_callback)  # 创建一个定时器(单位为秒的周期,定时执行的回调函数)
    
      def timer_callback(self):                                     # 创建定时器周期执行的回调函数
          msg = String()                                            # 创建一个String类型的消息对象
          msg.data = 'Hello World'                                  # 填充消息对象中的消息数据
          self.pub.publish(msg)                                     # 发布话题消息
          self.get_logger().info('Publishing: "%s"' % msg.data)     # 输出日志信息,提示已经完成话题发布

    def main(args=None): # ROS2节点主入口main函数
    rclpy.init(args=args) # ROS2 Python接口初始化
    node = PublisherNode("topic_helloworld_pub") # 创建ROS2节点对象并进行初始化
    rclpy.spin(node) # 循环等待ROS2退出
    node.destroy_node() # 销毁节点对象
    rclpy.shutdown() # 关闭ROS2 Python接口

2. 创建订阅者代码流程如下:
  • 编程接口初始化

  • 创建节点并初始化

  • 创建订阅者对象

  • 回调函数处理话题数据

  • 销毁节点并关闭接口

    import rclpy # ROS2 Python接口库
    from rclpy.node import Node # ROS2 节点类
    from std_msgs.msg import String # ROS2标准定义的String消息

    """
    创建一个订阅者节点
    """
    class SubscriberNode(Node):

    复制代码
      def __init__(self, name):
          super().__init__(name)                             # ROS2节点父类初始化
          self.sub = self.create_subscription(\
              String, "chatter", self.listener_callback, 10) # 创建订阅者对象(消息类型、话题名、订阅者回调函数、队列长度)
    
      def listener_callback(self, msg):                      # 创建回调函数,执行收到话题消息后对数据的处理
          self.get_logger().info('I heard: "%s"' % msg.data) # 输出日志信息,提示订阅收到的话题消息

    def main(args=None): # ROS2节点主入口main函数
    rclpy.init(args=args) # ROS2 Python接口初始化
    node = SubscriberNode("topic_helloworld_sub") # 创建ROS2节点对象并进行初始化
    rclpy.spin(node) # 循环等待ROS2退出
    node.destroy_node() # 销毁节点对象
    rclpy.shutdown() # 关闭ROS2 Python接口

3. 创建服务客户端代码流程如下
  • 编程接口初始化

  • 创建节点并初始化

  • 创建客户端对象

  • 创建并发送请求数据

  • 等待服务器端应答数据

  • 销毁节点并关闭接口

    import sys

    import rclpy # ROS2 Python接口库
    from rclpy.node import Node # ROS2 节点类
    from learning_interface.srv import AddTwoInts # 自定义的服务接口

    class adderClient(Node):
    def init(self, name):
    super().init(name) # ROS2节点父类初始化
    self.client = self.create_client(AddTwoInts, 'add_two_ints') # 创建服务客户端对象(服务接口类型,服务名)
    while not self.client.wait_for_service(timeout_sec=1.0): # 循环等待服务器端成功启动
    self.get_logger().info('service not available, waiting again...')
    self.request = AddTwoInts.Request() # 创建服务请求的数据对象

    复制代码
      def send_request(self):                                          # 创建一个发送服务请求的函数
          self.request.a = int(sys.argv[1])
          self.request.b = int(sys.argv[2])
          self.future = self.client.call_async(self.request)           # 异步方式发送服务请求

    def main(args=None):
    rclpy.init(args=args) # ROS2 Python接口初始化
    node = adderClient("service_adder_client") # 创建ROS2节点对象并进行初始化
    node.send_request() # 发送服务请求

    复制代码
      while rclpy.ok():                            # ROS2系统正常运行
          rclpy.spin_once(node)                    # 循环执行一次节点
    
          if node.future.done():                   # 数据是否处理完成
              try:
                  response = node.future.result()  # 接收服务器端的反馈数据
              except Exception as e:
                  node.get_logger().info(
                      'Service call failed %r' % (e,))
              else:
                  node.get_logger().info(          # 将收到的反馈信息打印输出
                      'Result of add_two_ints: for %d + %d = %d' % 
                      (node.request.a, node.request.b, response.sum))
              break
    
      node.destroy_node()                          # 销毁节点对象
      rclpy.shutdown()                             # 关闭ROS2 Python接口
4. 创建服务服务端代码流程如下
  • 编程接口初始化

  • 创建节点并初始化

  • 创建服务器端对象

  • 通过回调函数处进行服务

  • 向客户端反馈应答结果

  • 销毁节点并关闭接口

    import rclpy # ROS2 Python接口库
    from rclpy.node import Node # ROS2 节点类
    from learning_interface.srv import AddTwoInts # 自定义的服务接口

    class adderServer(Node):
    def init(self, name):
    super().init(name) # ROS2节点父类初始化
    self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.adder_callback) # 创建服务器对象(接口类型、服务名、服务器回调函数)

    复制代码
      def adder_callback(self, request, response):   # 创建回调函数,执行收到请求后对数据的处理
          response.sum = request.a + request.b       # 完成加法求和计算,将结果放到反馈的数据中
          self.get_logger().info('Incoming request\na: %d b: %d' % (request.a, request.b))   # 输出日志信息,提示已经完成加法求和计算
          return response                          # 反馈应答信息

    def main(args=None): # ROS2节点主入口main函数
    rclpy.init(args=args) # ROS2 Python接口初始化
    node = adderServer("service_adder_server") # 创建ROS2节点对象并进行初始化
    rclpy.spin(node) # 循环等待ROS2退出
    node.destroy_node() # 销毁节点对象
    rclpy.shutdown() # 关闭ROS2 Python接口

5. 创建动作服务端
复制代码
import time

import rclpy                                      # ROS2 Python接口库
from rclpy.node   import Node                     # ROS2 节点类
from rclpy.action import ActionServer             # ROS2 动作服务器类
from learning_interface.action import MoveCircle  # 自定义的圆周运动接口

class MoveCircleActionServer(Node):
    def __init__(self, name):
        super().__init__(name)                   # ROS2节点父类初始化
        self._action_server = ActionServer(      # 创建动作服务器(接口类型、动作名、回调函数)
            self,
            MoveCircle,
            'move_circle',
            self.execute_callback)

    def execute_callback(self, goal_handle):            # 执行收到动作目标之后的处理函数
        self.get_logger().info('Moving circle...')
        feedback_msg = MoveCircle.Feedback()            # 创建一个动作反馈信息的消息

        for i in range(0, 360, 30):                     # 从0到360度,执行圆周运动,并周期反馈信息
            feedback_msg.state = i                      # 创建反馈信息,表示当前执行到的角度
            self.get_logger().info('Publishing feedback: %d' % feedback_msg.state)
            goal_handle.publish_feedback(feedback_msg)  # 发布反馈信息
            time.sleep(0.5)

        goal_handle.succeed()                           # 动作执行成功
        result = MoveCircle.Result()                    # 创建结果消息
        result.finish = True                            
        return result                                   # 反馈最终动作执行的结果

def main(args=None):                                    # ROS2节点主入口main函数
    rclpy.init(args=args)                               # ROS2 Python接口初始化
    node = MoveCircleActionServer("action_move_server") # 创建ROS2节点对象并进行初始化
    rclpy.spin(node)                                    # 循环等待ROS2退出
    node.destroy_node()                                 # 销毁节点对象
    rclpy.shutdown()                                    # 关闭ROS2 Python接口
6. 创建动作客户端
复制代码
import rclpy                                      # ROS2 Python接口库
from rclpy.node   import Node                     # ROS2 节点类
from rclpy.action import ActionClient             # ROS2 动作客户端类

from learning_interface.action import MoveCircle  # 自定义的圆周运动接口

class MoveCircleActionClient(Node):
    def __init__(self, name):
        super().__init__(name)                   # ROS2节点父类初始化
        self._action_client = ActionClient(      # 创建动作客户端(接口类型、动作名)
            self, MoveCircle, 'move_circle') 

    def send_goal(self, enable):                 # 创建一个发送动作目标的函数
        goal_msg = MoveCircle.Goal()             # 创建一个动作目标的消息
        goal_msg.enable = enable                 # 设置动作目标为使能,希望机器人开始运动

        self._action_client.wait_for_server()    # 等待动作的服务器端启动
        self._send_goal_future = self._action_client.send_goal_async(   # 异步方式发送动作的目标
            goal_msg,                                                   # 动作目标
            feedback_callback=self.feedback_callback)                   # 处理周期反馈消息的回调函数

        self._send_goal_future.add_done_callback(self.goal_response_callback) # 设置一个服务器收到目标之后反馈时的回调函数

    def goal_response_callback(self, future):           # 创建一个服务器收到目标之后反馈时的回调函数
        goal_handle = future.result()                   # 接收动作的结果
        if not goal_handle.accepted:                    # 如果动作被拒绝执行
            self.get_logger().info('Goal rejected :(')
            return

        self.get_logger().info('Goal accepted :)')                            # 动作被顺利执行

        self._get_result_future = goal_handle.get_result_async()              # 异步获取动作最终执行的结果反馈
        self._get_result_future.add_done_callback(self.get_result_callback)   # 设置一个收到最终结果的回调函数 

    def get_result_callback(self, future):                                    # 创建一个收到最终结果的回调函数
        result = future.result().result                                       # 读取动作执行的结果
        self.get_logger().info('Result: {%d}' % result.finish)                # 日志输出执行结果

    def feedback_callback(self, feedback_msg):                                # 创建处理周期反馈消息的回调函数
        feedback = feedback_msg.feedback                                      # 读取反馈的数据
        self.get_logger().info('Received feedback: {%d}' % feedback.state) 

def main(args=None):                                       # ROS2节点主入口main函数
    rclpy.init(args=args)                                  # ROS2 Python接口初始化
    node = MoveCircleActionClient("action_move_client")    # 创建ROS2节点对象并进行初始化
    node.send_goal(True)                                   # 发送动作目标
    rclpy.spin(node)                                       # 循环等待ROS2退出
    node.destroy_node()                                    # 销毁节点对象
    rclpy.shutdown()                                       # 关闭ROS2 Python接口

四、ROS 2 调度器

单线程执行器(SingleThreadedExecutor 的底层的运行机制使得回调函数默认不由新线程运行,而是由 rclpy.spin(node) 所在的唯一主线程运行。因此读写共享变量时,完全不需要加锁,也不存在数据竞争。

复制代码
                          rclpy.spin(node) 死循环
                                     │
                                     ▼
                   ┌──────────────────────────────────┐
                   │  1. 轮询 DDS 底层事件网络通知      │
                   └─────────────────┬────────────────┘
                                     │
                 ┌───────────────────┴───────────────────┐
                 ▼                                       ▼
    收到 Image 话题数据                      收到 GetObjectPosition 服务请求
                 │                                       │
                 ▼                                       ▼
  将 listener_callback 压入队列            将 object_position_callback 压入队列
                 │                                       │
                 └───────────────────┬───────────────────┘
                                     │
                                     ▼
                   ┌──────────────────────────────────┐
                   │   2. 从执行队列中按 FIFO 取出任务  │
                   └─────────────────┬────────────────┘
                                     │
                  ┌──────────────────┴──────────────────┐
                  │ 逐个串行执行(单线程)                │
                  └──────────────────┬────────────────┘
                                     │
         ┌───────────────────────────┴───────────────────────────┐
         ▼                                                       ▼
[执行 listener_callback]                               [执行 object_position_callback]
 ├─ 图像转换                                            ├─ 读取 self.objectX / Y
 ├─ object_detect()                                    ├─ 构造 response 消息
 └─ 写 self.objectX = 120 (完成)                       └─ return response (完成)
         │                                                       │
         └───────────────────────────┬───────────────────────────┘
                                     │
                                     ▼
                          (等待并处理下一个事件)

什么时候 ROS 2 节点才"必须加锁?

场景 1:显式改用了多线程执行器(MultiThreadedExecutor
复制代码
from rclpy.executors import MultiThreadedExecutor

def main(args=None):
    rclpy.init(args=args)
    node = ImageSubscriber("service_object_server")
    
    # 显式开启多线程执行器(例如分配 4 个线程)
    executor = MultiThreadedExecutor(num_threads=4)
    executor.add_node(node)
    
    # 此时,两个回调函数可能分别被 线程A 和 线程B 同时拉起执行!
    executor.spin()
场景 2:自己在节点里手动开辟了 Python 原生子线程

如果在节点内部用 threading.Thread(target=...) 启动了一个独立的后台线程去跑耗时算法或串口通信,这个子线程与 rclpy.spin() 的主线程是并发运行的,此时读写共享变量必须加锁。

相关推荐
zhangrelay2 天前
答疑-是否软件源问题都必须更换为国内呢-
linux·笔记·学习·ubuntu·ros2
CS_Zero4 天前
激光雷达YDLiDAR驱动与ROS2环境搭建
无人机·飞控·ros2·激光雷达·避障
海阔天空任鸟飞~5 天前
rclpy
ros2
微小冷5 天前
ROS2 URDF机器人建模初步教程
机器人·ros2·urdf·机器人建模
witton9 天前
MacOS中在目录或者文件右键菜单中添加“使用VSCode打开”
vscode·macos·文件·目录·服务·automator·右键菜单
zh路西法10 天前
【玩转VLA具身智能机械臂】(三):视觉避障——从 RGB-D 点云到 MoveIt octomap 的完整落地
c++·ros2·gazebo·moveit2·ompl·octomap
YQ_0110 天前
ROS 2 Humble Nav2 生命周期教程:启动流程、状态监控、超时诊断与自愈设计
linux·机器人·ros2·nav2
路弥行至10 天前
ROS2 通信避坑实录:从 topic/msg 底层原理到一次内存错位复盘
网络·经验分享·笔记·其他·内存·topic·ros2
zhangrelay22 天前
ROS 2 Lyrical 第9章 计算机视觉与摄像头应用
linux·笔记·学习·ubuntu·机器人·ros2