ROS2话题与服务深度对比-从通信模型到工程选型
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)。服务只是在话题之上封装了"请求-响应"的语义配对。这意味着服务的底层开销并不比话题低,反而多了配对管理和序列号匹配的成本。
连接生命周期
话题的生命周期很简单:
- 发布者创建 → 向 DDS 注册 DataWriter。
- 订阅者创建 → 向 DDS 注册 DataReader。
- DDS 发现机制自动匹配(名称 + 类型 + QoS 兼容)。
- 数据开始流动。
- 任一方销毁 → 自动断开。
服务的生命周期多了一步"等待":
- 服务端创建 → 注册服务。
- 客户端创建 → 发现服务。
- 客户端调用
wait_for_service()确认服务端就绪。 - 客户端发送请求 → 服务端回调处理 → 返回响应。
- 客户端收到响应(或超时)。
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(),需要额外的超时处理逻辑。
应用场景对比
适合用话题的场景
-
传感器数据流:温度、IMU、激光雷达、摄像头图像。这些是连续的高频数据,订阅者只需要最新的值,偶尔丢一帧不影响系统运行。
-
状态广播:机器人位姿、电池电量、系统心跳。多个节点可能需要同时监听,话题的多对多特性天然适合扇出。
-
控制指令下发:
cmd_vel速度指令。控制器持续发布,底盘持续执行,不需要每次确认"你收到了吗"。 -
日志与诊断:调试信息、诊断状态。发布到
/rosout话题,任何工具都可以订阅查看。 -
地图与环境信息:TF 变换、占据栅格地图。这些数据持续更新,多个消费者各自按需订阅。
适合用服务的场景
-
配置操作:保存/加载参数、切换运行模式。这些是一次性操作,调用方需要确认操作是否成功。
-
状态查询:获取设备信息、读取校准参数、查询当前模式。调用方需要一个明确的返回值来做后续判断。
-
触发一次性动作:拍照、触发标定、保存当前帧。操作有明确的"完成"概念,调用方需要知道结果。
-
系统管理:启动/停止某个子系统、重置状态。这些操作可能失败,调用方需要根据返回值决定下一步。
-
跨节点协调:一个节点需要另一个节点"做完某件事再继续"。服务的同步语义天然支持这种协调模式。
选型决策树
需要通信?
├── 数据是连续流(高频更新)?
│ ├── 是 → 话题(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_LOCAL 和 RELIABLE 在不同 RMW 实现下的行为差异;三是回调组(callback group)和多线程 executor,它们决定了服务和话题回调在哪个线程执行,直接影响并发性能;四是 lifecycle 节点中的通信管理——节点在未激活状态下是否应该发布话题或响应服务,是需要明确设计的。
参考资料
更多推荐
所有评论(0)