ROS 2 回调里调服务:Future 的完成回调为何会死锁
在 ROS 2 Jazzy 中拆开同步服务死锁的等待环,用 call_async、callback group 和可重复命令验证响应路径。

有个定时器每秒向标定服务取一次状态。把 client.call(request) 塞进 timer callback 后,第一次日志停在“request sent”,节点没有崩,服务端也确实收到了请求。这个现象很像网络问题,其实等待环发生在本节点内部。
节点没有显式指定 callback group 时,timer 和 client 都进入默认的 MutuallyExclusiveCallbackGroup。同步调用要等 Future 完成,而 Future 的完成也需要 executor 执行一个隐藏的 done-callback。同一互斥组此时仍被 timer callback 占着:timer 等 Future,Future 又等 timer 释放组,于是形成死锁。增加 executor 线程数并不会打破同一个互斥组的约束。
先让 callback 返回
Jazzy 的 Python 客户端优先用 call_async()。下面还用 _pending 阻止定时器在上一次请求未完成时不断堆请求:
from example_interfaces.srv import AddTwoInts
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
from rclpy.node import Node
class StatusClient(Node):
def __init__(self):
super().__init__("status_client")
self._group = MutuallyExclusiveCallbackGroup()
self._client = self.create_client(
AddTwoInts,
"/add_two_ints",
callback_group=self._group,
)
self._pending = None
self._timer = self.create_timer(
1.0,
self._tick,
callback_group=self._group,
)
def _tick(self):
if self._pending is not None or not self._client.service_is_ready():
return
request = AddTwoInts.Request()
request.a = 20
request.b = 22
self._pending = self._client.call_async(request)
self._pending.add_done_callback(self._finished)
def _finished(self, future):
try:
response = future.result()
self.get_logger().info(f"sum={response.sum}")
except Exception as exc:
self.get_logger().error(f"service failed: {exc!r}")
finally:
self._pending = Nonetimer 发出异步请求后立即返回,互斥组随即空闲,executor 才有机会处理响应和 Future 的完成回调。这个版本在单线程 executor 下也能工作,因为它没有在 callback 内阻塞等待。
wait_for_service() 也不该无期限放在高频 callback 里。上例用 service_is_ready() 快速探测;若服务未启动,就等下一次 timer。请求本身还应有业务超时:超时后调用 future.cancel() 只能取消本地 Future 的等待语义,不能保证远端服务停止正在执行的工作。需要可取消的长任务时,Action 往往比 Service 更合适。
必须同步等待时,要拆开调度约束
确实要同步调用时,发起调用的 callback 与 client 必须位于不同 callback group,或者同处一个 ReentrantCallbackGroup,并由能够并发执行的 executor 驱动。仅换成 MultiThreadedExecutor、却仍把所有实体留在默认互斥组,行为和单线程没有本质区别。
ReentrantCallbackGroup 允许同一个 callback 被并发再次进入。若 callback 会读写同一份状态,它可能把死锁换成数据竞争。我更愿意用两个明确的 MutuallyExclusiveCallbackGroup,再审查共享状态;不过多数状态查询根本不需要同步等待,异步版本更简单。
还有一种常见写法是在 callback 里对 C++ Future 调 rclcpp::spin_until_future_complete()。节点已经由 executor spin 时再嵌套 spin,不是可靠的逃生通道;应让原 callback 返回,在响应 callback 里续接状态机。
用重复响应证明没有“偶然跑通”
先启动 Jazzy 自带的服务端:
ros2 run demo_nodes_cpp add_two_ints_server另一个终端检查类型并启动客户端:
ros2 service list -t | grep add_two_ints
ros2 run my_pkg status_client客户端应持续打印 sum=42,而不是只出现一次发送日志。随后在服务端停止、重启各一次:客户端不应阻塞 executor,服务恢复后应继续得到响应。若节点还有订阅或 watchdog timer,同时记录它们的 steady_clock 间隔;服务不可用期间这些 callback 仍应继续执行。
最后要测慢服务。_pending 会把并发在途请求限制为一个,但它没有定义超时后的降级输出,也没有重试退避。死锁消失只是调度正确,服务级别的超时、幂等和故障状态仍要由节点协议明确处理。
相关
也可以看看
- ·15 分钟阅读
ContentFilteredTopic:把过滤下推到中间件还是应用层
对比订阅端丢弃、应用层条件判断与 DDS 内容过滤的 CPU/带宽边界,说明表达式能力与发现时序限制;用高频率噪声话题与延迟尖峰验收过滤位置选择。
- ·16 分钟阅读
ROS 2 Action 取消语义:收到 cancel 不等于执行器已经停下
梳理 goal handle 状态机、取消回调与硬件停止之间的异步边界;用可中断执行循环、截止时间和终态发布构建契约并以注入验收。
- ·9 分钟阅读
ROS 2 大消息内存路径:intra-process、loaned message 与 shared memory 不是一回事
以 4K 图像和点云管线为例,逐段核对 rclcpp、RMW 与进程边界上的所有权、分配和真实拷贝。
johan's blog