ROS 2 话题与服务深度对比:从通信模型到工程选型

问题背景

在 ROS 2 开发中,话题(Topic)和服务(Service)是最常用的两种通信方式。很多初学者在选型时的判断标准比较模糊——觉得话题写起来简单就全用话题,或者觉得服务有返回值就更"安全"而全用服务。结果是要么在需要确认的场景里用话题导致数据"石沉大海",要么在高频数据流场景用服务导致大量请求堆积。

这篇笔记从核心概念、通信模型、代码实现、QoS 行为、性能特征和应用场景六个维度做系统对比,目标是在遇到新的通信需求时,能快速判断该用话题还是服务,并说清楚理由。

本笔记基于 ROS 2 Humble Hawksbill(2022 年 5 月发布,LTS 版本,官方支持到 2027 年 5 月)编写,使用 C++ 为主进行讲解。

核心概念对比

话题(Topic)

话题采用**发布-订阅(Publish-Subscribe)**模型。发布者(Publisher)把消息发到指定话题,订阅者(Subscriber)从话题接收消息。发布者和订阅者完全解耦——发布者不知道有没有人在听,订阅者不知道消息从哪来,双方只要话题名称、消息类型和 QoS 兼容就能自动匹配。

话题是单向数据流:数据只从发布者流向订阅者,没有"回复"的概念。

服务(Service)

服务采用**请求-响应(Request-Response)**模型。客户端(Client)向服务端(Server)发送请求,服务端处理后返回响应。一次请求严格对应一次响应,是双向的交互。

服务是同步事务:客户端发出请求后,期望在某个时间点收到一个明确的响应——要么成功结果,要么失败信息。

一句话区分

  • 话题:像广播电台,持续播送,谁想听谁调频。
  • 服务:像打电话,拨号、说话、等回复、挂断。

通信模型对比

维度 话题(Topic) 服务(Service)
通信模型 发布-订阅(pub/sub) 请求-响应(req/rep)
数据流向 单向(发布者 → 订阅者) 双向(客户端 ↔ 服务端)
参与者关系 多对多(N 个发布者,M 个订阅者) 一对一(一次请求对应一个响应)
耦合度 低(双方互不感知) 较高(客户端需知道服务名和类型)
时序性 异步、连续流 同步事务(也有异步 API)
是否有确认 无(fire-and-forget) 有(响应即确认)
中间件实现 DDS DataWriter / DataReader 底层用两对 Topic 模拟(request + reply)

最后一点值得展开:ROS 2 的服务在 RMW 层实际上是用两对话题实现的——一对用于发送请求(client → server),一对用于返回响应(server → client)。服务只是在话题之上封装了"请求-响应"的语义配对。这意味着服务的底层开销并不比话题低,反而多了配对管理和序列号匹配的成本。

连接生命周期

话题的生命周期很简单:

  1. 发布者创建 → 向 DDS 注册 DataWriter。
  2. 订阅者创建 → 向 DDS 注册 DataReader。
  3. DDS 发现机制自动匹配(名称 + 类型 + QoS 兼容)。
  4. 数据开始流动。
  5. 任一方销毁 → 自动断开。

服务的生命周期多了一步"等待":

  1. 服务端创建 → 注册服务。
  2. 客户端创建 → 发现服务。
  3. 客户端调用 wait_for_service() 确认服务端就绪。
  4. 客户端发送请求 → 服务端回调处理 → 返回响应。
  5. 客户端收到响应(或超时)。

wait_for_service() 在服务端尚未启动时会阻塞。这是实际项目中启动顺序问题的常见来源——如果客户端比服务端先启动,不做等待就会导致请求丢失。话题没有这个问题,因为 DDS 的发现和匹配是自动且持续的。

代码层面对比

创建与使用的 API 差异

话题——发布者:

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/float64.hpp"

class TempPublisher : public rclcpp::Node
{
public:
    TempPublisher() : Node("temp_publisher"), count_(0)
    {
        publisher_ = this->create_publisher<std_msgs::msg::Float64>("temperature", 10);
        timer_ = this->create_wall_timer(
            std::chrono::milliseconds(500),
            std::bind(&TempPublisher::timer_callback, this));
    }

private:
    void timer_callback()
    {
        auto msg = std_msgs::msg::Float64();
        msg.data = 25.0 + (count_ % 10) * 0.5;
        publisher_->publish(msg);
        RCLCPP_INFO(this->get_logger(), "Publishing: %.1f", msg.data);
        count_++;
    }

    rclcpp::Publisher<std_msgs::msg::Float64>::SharedPtr publisher_;
    rclcpp::TimerBase::SharedPtr timer_;
    int count_;
};

话题——订阅者:

class TempSubscriber : public rclcpp::Node
{
public:
    TempSubscriber() : Node("temp_subscriber")
    {
        subscription_ = this->create_subscription<std_msgs::msg::Float64>(
            "temperature", 10,
            std::bind(&TempSubscriber::topic_callback, this, std::placeholders::_1));
    }

private:
    void topic_callback(const std_msgs::msg::Float64::SharedPtr msg)
    {
        RCLCPP_INFO(this->get_logger(), "Received: %.1f", msg->data);
    }

    rclcpp::Subscription<std_msgs::msg::Float64>::SharedPtr subscription_;
};

话题的代码模式是:创建 publisher/subscriber → 定时器或外部事件触发 publish → 回调函数接收数据。整个流程没有"等待对方"的概念。

服务——服务端:

#include "rclcpp/rclcpp.hpp"
#include "example_interfaces/srv/set_bool.hpp"

class ConfigServer : public rclcpp::Node
{
public:
    ConfigServer() : Node("config_server")
    {
        service_ = this->create_service<example_interfaces::srv::SetBool>(
            "enable_logging",
            std::bind(&ConfigServer::handle_request, this,
                      std::placeholders::_1, std::placeholders::_2));
    }

private:
    void handle_request(
        const std::shared_ptr<example_interfaces::srv::SetBool::Request> request,
        std::shared_ptr<example_interfaces::srv::SetBool::Response> response)
    {
        logging_enabled_ = request->data;
        response->success = true;
        response->message = logging_enabled_ ? "Logging enabled" : "Logging disabled";
        RCLCPP_INFO(this->get_logger(), "Request: %d -> %s",
                    request->data, response->message.c_str());
    }

    rclcpp::Service<example_interfaces::srv::SetBool>::SharedPtr service_;
    bool logging_enabled_ = false;
};

服务——客户端(同步调用):

class ConfigClient : public rclcpp::Node
{
public:
    ConfigClient() : Node("config_client")
    {
        client_ = this->create_client<example_interfaces::srv::SetBool>("enable_logging");

        while (!client_->wait_for_service(std::chrono::seconds(1))) {
            if (!rclcpp::ok()) return;
            RCLCPP_INFO(this->get_logger(), "Waiting for service...");
        }

        auto request = std::make_shared<example_interfaces::srv::SetBool::Request>();
        request->data = true;

        auto future = client_->async_send_request(request);
        if (rclcpp::spin_until_future_complete(
                this->get_node_base_interface(), future) ==
            rclcpp::FutureReturnCode::SUCCESS)
        {
            auto response = future.get();
            RCLCPP_INFO(this->get_logger(), "Response: success=%d, msg=%s",
                        response->success, response->message.c_str());
        } else {
            RCLCPP_ERROR(this->get_logger(), "Service call failed");
        }
    }

private:
    rclcpp::Client<example_interfaces::srv::SetBool>::SharedPtr client_;
};

服务的代码模式是:创建 server/client → client 等待 server 就绪 → 发送请求 → 阻塞或异步等待响应。多了 wait_for_service()async_send_request()spin_until_future_complete() 这些同步协调的步骤。

API 复杂度对比

操作 话题 服务
创建发送端 create_publisher<MsgType>(name, qos) create_service<SrvType>(name, callback)
创建接收端 create_subscription<MsgType>(name, qos, callback) create_client<SrvType>(name)
发送数据 publisher->publish(msg) client->async_send_request(req)
接收数据 回调函数自动触发 spin_until_future_complete() 或异步回调
等待对端 不需要 client->wait_for_service()
接口定义 .msg 文件 .srv 文件(request + response)

服务比话题多了"等待对端"和"等待响应"两个环节。这些环节增加了代码复杂度,但也提供了话题没有的确定性——能明确知道对方是否在线、请求是否被处理。

接口定义对比

话题使用 .msg 文件,只定义一种数据结构:

# TemperatureData.msg
float64 temperature
float64 humidity
builtin_interfaces/Time timestamp

服务使用 .srv 文件,用 --- 分隔请求和响应两种数据结构:

# SetConfig.srv
# Request
string key
string value
---
# Response
bool success
string message

.srv 本质上就是两个 .msg 的组合。如果请求或响应为空,对应部分可以省略,但 --- 分隔符必须保留。

QoS 行为对比

QoS(服务质量)是 ROS 2 通信的重要配置,但话题和服务对 QoS 的使用方式有本质差异。

话题的 QoS

话题的 QoS 策略直接影响数据投递行为,常用配置:

QoS 策略 说明 典型选择
Reliability 可靠性 RELIABLE(确保送达)或 BEST_EFFORT(尽力而为)
Durability 持久性 VOLATILE(不保留)或 TRANSIENT_LOCAL(保留最后一条)
History 历史记录 KEEP_LAST(N)KEEP_ALL
Deadline 截止时间 设置最大发布/接收间隔

QoS 不匹配会导致话题无法建立连接。比如发布者用 BEST_EFFORT,订阅者要求 RELIABLE,DDS 的兼容性规则判定不匹配,数据不会流动。用 ros2 topic info -v 可以检查双方的 QoS 是否兼容。

服务的 QoS

服务的 QoS 在 ROS 2 Humble 中是固定的:

  • Reliability: RELIABLE
  • Durability: VOLATILE
  • History: KEEP_LAST(10)

从 Humble 开始,ROS 2 不允许用户自定义服务的 QoS 策略。这是因为服务语义要求请求必须可靠送达、响应必须可靠返回,BEST_EFFORT 与服务的事务性语义矛盾。

对比小结

QoS 维度 话题 服务
可配置性 完全可配置 Humble 中固定(RELIABLE + VOLATILE)
不匹配风险 高(需手动确保两端兼容) 无(固定策略,不存在不匹配)
适用场景 根据场景选择 QoS 无需关心

话题的 QoS 灵活性是优势也是陷阱——配错了会导致"名称对了但收不到数据",这是 ROS 2 调试中最常见的问题之一。服务因为 QoS 固定,反而没有这个坑。

性能与行为特征对比

延迟与吞吐

特征 话题 服务
单次通信延迟 低(直接 DDS 投递) 略高(请求 + 处理 + 响应,两次序列化)
高吞吐能力 强(DDS 原生支持高频率数据流) 弱(每个请求需要独立的事务处理)
并发模型 多个订阅者同时接收,天然扇出 服务端按顺序处理请求(单线程 executor)

话题在高频率、大数据量的场景有明显优势。DDS 本身就是为实时数据分发设计的,100Hz 的传感器话题在大多数 RMW 实现下延迟只有亚毫秒级。

服务在处理并发请求时,默认情况下服务端的回调在 spin 线程中顺序执行。如果一次请求处理耗时 100ms,那连续 10 个请求的最后一个要等 1 秒才能被处理。可以通过多线程 executor 或回调组(callback group)来并行化,但这增加了代码复杂度。

内存与资源

特征 话题 服务
消息缓存 QoS 队列占用内存(KEEP_LAST(N) 缓存 N 条) 无缓存(请求和响应是即时的)
发现开销 DDS 发现协议(SED/SPDP) 基于 DDS 发现 + 服务配对管理
未连接时行为 消息丢弃或缓存(取决于 QoS) 请求丢失(服务端不在线时)

话题的 KEEP_LAST(N) 会在 DDS 层缓存最近 N 条消息,占用一定内存。对于图像、点云等大数据消息,N 值过大会导致内存消耗显著。服务没有缓存机制,请求发出时服务端不在线就直接丢失。

容错行为

场景 话题 服务
接收方未启动 消息丢弃(VOLATILE)或缓存(TRANSIENT_LOCAL) 请求丢失,需 wait_for_service
发送方中途断开 订阅者不再收到新消息 客户端收不到响应,future 挂起
网络抖动 取决于 QoS(RELIABLE 会重传) RELIABLE 会自动重传
超时处理 无超时概念 可设置 future 超时

话题对断连的容忍度更高——发布者不关心有没有人听,订阅者在发布者恢复后自动重新接收。服务的客户端在服务端断开后,future 会一直挂起直到超时或 rclcpp::shutdown(),需要额外的超时处理逻辑。

应用场景对比

适合用话题的场景

  1. 传感器数据流:温度、IMU、激光雷达、摄像头图像。这些是连续的高频数据,订阅者只需要最新的值,偶尔丢一帧不影响系统运行。

  2. 状态广播:机器人位姿、电池电量、系统心跳。多个节点可能需要同时监听,话题的多对多特性天然适合扇出。

  3. 控制指令下发cmd_vel 速度指令。控制器持续发布,底盘持续执行,不需要每次确认"你收到了吗"。

  4. 日志与诊断:调试信息、诊断状态。发布到 /rosout 话题,任何工具都可以订阅查看。

  5. 地图与环境信息:TF 变换、占据栅格地图。这些数据持续更新,多个消费者各自按需订阅。

适合用服务的场景

  1. 配置操作:保存/加载参数、切换运行模式。这些是一次性操作,调用方需要确认操作是否成功。

  2. 状态查询:获取设备信息、读取校准参数、查询当前模式。调用方需要一个明确的返回值来做后续判断。

  3. 触发一次性动作:拍照、触发标定、保存当前帧。操作有明确的"完成"概念,调用方需要知道结果。

  4. 系统管理:启动/停止某个子系统、重置状态。这些操作可能失败,调用方需要根据返回值决定下一步。

  5. 跨节点协调:一个节点需要另一个节点"做完某件事再继续"。服务的同步语义天然支持这种协调模式。

选型决策树

需要通信?
├── 数据是连续流(高频更新)?
│   ├── 是 → 话题(Topic)
│   └── 否 ↓
├── 需要对方返回结果?
│   ├── 是 → 操作会持续很久且需要进度反馈?
│   │         ├── 是 → 动作(Action)
│   │         └── 否 → 服务(Service)
│   └── 否 → 需要多个接收方?
│             ├── 是 → 话题(Topic)
│             └── 否 → 话题或服务均可,按耦合度偏好选择

实际项目中还有一种常见的"混合模式":用话题做高频数据流,用服务做低频控制。比如一个相机节点,通过话题持续发布图像,同时提供一个服务接口用来切换分辨率或触发对焦。这种模式在 Nav2、MoveIt 等成熟框架中非常普遍。

同一需求两种实现的对比

用一个"获取传感器当前读数"的需求来对比两种实现方式。

话题实现:订阅最新值

// 传感器节点:持续发布
class SensorNode : public rclcpp::Node
{
public:
    SensorNode() : Node("sensor_node")
    {
        pub_ = this->create_publisher<std_msgs::msg::Float64>("sensor_reading", 
            rclcpp::QoS(1).transient_local());  // TRANSIENT_LOCAL 保留最新值
        timer_ = this->create_wall_timer(
            std::chrono::milliseconds(100),
            [this]() {
                auto msg = std_msgs::msg::Float64();
                msg.data = read_sensor();
                pub_->publish(msg);
            });
    }

private:
    float read_sensor() { return 25.0f; }  // 模拟传感器读取
    rclcpp::Publisher<std_msgs::msg::Float64>::SharedPtr pub_;
    rclcpp::TimerBase::SharedPtr timer_;
};

// 消费者节点:订阅获取
class ConsumerNode : public rclcpp::Node
{
public:
    ConsumerNode() : Node("consumer_node")
    {
        sub_ = this->create_subscription<std_msgs::msg::Float64>(
            "sensor_reading",
            rclcpp::QoS(1).transient_local(),  // QoS 必须匹配
            [this](const std_msgs::msg::Float64::SharedPtr msg) {
                RCLCPP_INFO(this->get_logger(), "Sensor: %.1f", msg->data);
                latest_value_ = msg->data;
            });
    }

private:
    double latest_value_ = 0.0;
    rclcpp::Subscription<std_msgs::msg::Float64>::SharedPtr sub_;
};

特点:消费者启动后,因为 TRANSIENT_LOCAL 会立即收到最近一条缓存值,之后持续更新。但消费者无法主动触发一次读取,只能被动等待下一次发布。

服务实现:按需请求

// 传感器节点:提供读取服务
class SensorServiceNode : public rclcpp::Node
{
public:
    SensorServiceNode() : Node("sensor_service_node")
    {
        srv_ = this->create_service<example_interfaces::srv::Trigger>(
            "read_sensor",
            [this](const std::shared_ptr<example_interfaces::srv::Trigger::Request>,
                   std::shared_ptr<example_interfaces::srv::Trigger::Response> res) {
                res->success = true;
                res->message = std::to_string(read_sensor());
            });
    }

private:
    float read_sensor() { return 25.0f; }
    rclcpp::Service<example_interfaces::srv::Trigger>::SharedPtr srv_;
};

// 消费者节点:按需请求
class ServiceConsumerNode : public rclcpp::Node
{
public:
    ServiceConsumerNode() : Node("service_consumer_node")
    {
        client_ = this->create_client<example_interfaces::srv::Trigger>("read_sensor");
        client_->wait_for_service(std::chrono::seconds(5));
    }

    void request_reading()
    {
        auto req = std::make_shared<example_interfaces::srv::Trigger::Request>();
        auto future = client_->async_send_request(req);

        if (rclcpp::spin_until_future_complete(
                this->get_node_base_interface(), future,
                std::chrono::seconds(2)) ==
            rclcpp::FutureReturnCode::SUCCESS)
        {
            auto res = future.get();
            RCLCPP_INFO(this->get_logger(), "Sensor: %s, success=%d",
                        res->message.c_str(), res->success);
        } else {
            RCLCPP_WARN(this->get_logger(), "Request timed out");
        }
    }

private:
    rclcpp::Client<example_interfaces::srv::Trigger>::SharedPtr client_;
};

特点:消费者主动发起请求,得到确定性的响应。可以设置超时,可以判断成功/失败。但每次读取都是一次完整的事务,如果每秒读 100 次,服务端的串行处理可能成为瓶颈。

对比总结

方面 话题实现 服务实现
获取方式 被动接收(订阅后等数据来) 主动请求(需要时调用)
实时性 取决于发布频率 请求时刻即为读取时刻
频率适应性 适合高频(100Hz+) 适合低频(<10Hz)
错误感知 无(不知道传感器是否正常) 有(success/message 字段)
多消费者 天然支持 每个消费者都要独立调用
代码复杂度 中(需处理等待、超时、错误)

命令行工具对比

调试时的命令行工具也有所不同:

# 话题调试
ros2 topic list                           # 列出活跃话题
ros2 topic info -v /temperature           # 查看详细信息(含 QoS)
ros2 topic echo /temperature              # 实时打印消息
ros2 topic hz /temperature                # 查看发布频率
ros2 topic bw /temperature                # 查看带宽占用
ros2 topic pub /temperature std_msgs/msg/Float64 '{data: 26.5}'  # 手动发布

# 服务调试
ros2 service list                         # 列出活跃服务
ros2 service type /enable_logging         # 查看服务类型
ros2 service find example_interfaces/srv/SetBool  # 按类型查找服务
ros2 service call /enable_logging example_interfaces/srv/SetBool '{data: true}'

ros2 topic echo 是最常用的调试命令之一,可以实时观察数据流。服务没有 echo 的等价物,因为服务不是持续的数据流。ros2 service call 适合在终端快速测试一个服务是否正常响应。

踩坑记录

第一个坑:用话题做"请求-响应"导致数据竞争。 有些开发者为了"灵活",用两个话题模拟服务:一个话题发请求,另一个话题收响应。这种做法看似可行,但在多客户端场景下会出问题——客户端 A 的请求对应的响应可能被客户端 B 收到。服务之所以存在,就是因为它在底层用序列号做了请求-响应的配对,话题做不到这一点。

第二个坑:在服务回调里调用另一个服务。 服务回调在 spin 线程中执行。如果在回调里用 spin_until_future_complete() 调用另一个服务,会导致当前 spin 线程被嵌套占用,外层的响应无法返回,形成死锁。正确做法是用多线程 executor 或在回调中只做异步调用。

第三个坑:客户端不等待服务就发请求。 如果客户端在 create_client 后立即发送请求,而服务端还没启动,请求会直接丢失。这不是报错,而是静默失败。务必在发送请求前调用 wait_for_service(),或者在异步框架中检查 service_is_ready()

第四个坑:话题 QoS 不匹配导致"连上了但收不到"。 发布者和订阅者的话题名称完全一致,但 ros2 topic echo 能看到数据而自己的订阅者收不到。这时候用 ros2 topic info -v 检查双方的 QoS——大概率是一端用了 BEST_EFFORT,另一端要求 RELIABLE。ROS 2 的 QoS 兼容性规则是"订阅者的要求不能高于发布者的承诺"。

第五个坑:高频场景误用服务。 有人用服务做 50Hz 的传感器轮询,结果服务端串行处理跟不上,请求堆积导致延迟越来越大。如果确实需要高频的"按需读取",更好的方案是让传感器节点以话题方式持续发布,消费者在本地缓存最新值,需要时直接读缓存。

总结与延伸

话题和服务不是互斥的,而是互补的。话题适合"数据在流动,谁需要谁来取"的场景,服务适合"我需要你做一件事并告诉我结果"的场景。在实际的 ROS 2 系统中,两者几乎总是同时存在:话题承载数据流,服务承载控制流。

掌握这两者的区别后,可以继续深入几个方向:一是动作(Action),它本质上是服务的增强版,适用于长时间任务,在之前的笔记中已经展开过;二是 QoS 策略的深度调优,特别是 TRANSIENT_LOCALRELIABLE 在不同 RMW 实现下的行为差异;三是回调组(callback group)和多线程 executor,它们决定了服务和话题回调在哪个线程执行,直接影响并发性能;四是 lifecycle 节点中的通信管理——节点在未激活状态下是否应该发布话题或响应服务,是需要明确设计的。

参考资料

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐