服務(Service)
- ros2
- service
- rclpy
- future
問題定義
主題適合「持續發生、不需要確認」的資料流,但很多情境需要的是「我發出一個請求,明確知道對方有沒有處理成功、結果是什麼」——例如叫機器人「回到充電座」,你會想知道它有沒有成功接受這個指令。這種一問一答的同步模式,就是 ROS2 的服務(Service)。
核心概念說明
Service 的請求-回應模型
一個 Service 由一個 .srv 定義檔描述,內容分成請求(Request)與回應(Response)兩部分,用 --- 分隔:
int64 a
int64 b
---
int64 sum
Server 端註冊一個 callback,每次收到請求就執行一次、回傳一個回應;Client 端發起呼叫後,會拿到一個 Future 物件——這是非同步程式設計裡常見的抽象,代表「一個尚未完成、但將來會有結果」的操作。
為什麼要理解 Future,而不是只會用 spin_until_future_complete
網路上很多教學會直接教你這樣寫:
future = client.call_async(request)
rclpy.spin_until_future_complete(node, future)
result = future.result()
這在「主程式的最上層」呼叫是安全的,但正如上一節提到的,如果你在一個已經在 executor 裡執行的 callback 內部這樣寫,就會遇到死鎖——因為 spin_until_future_complete 本質上是在借用目前的 executor 再跑一次事件迴圈,而目前的 callback 本身還沒執行完,形成迴圈等待自己。這一節會用完整的 Server + Client 範例,把「安全」與「危險」的呼叫方式都示範一次。
實作範例:加法服務
1. 定義 Service 介面
依照上一節建立自訂 message 的方式,這次建立 .srv:
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_cmake my_package_msgs # 如果還沒建立過
mkdir -p my_package_msgs/srv
檔案位置:my_package_msgs/srv/AddTwoInts.srv
int64 a
int64 b
---
int64 sum
CMakeLists.txt 加上:
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/Detection.msg"
"srv/AddTwoInts.srv"
)
編譯後確認介面存在:
colcon build --packages-select my_package_msgs
source install/setup.bash
ros2 interface show my_package_msgs/srv/AddTwoInts
int64 a
int64 b
---
int64 sum
2. Service Server
檔案位置:~/ros2_ws/src/my_package/my_package/add_server.py
import rclpy
from rclpy.node import Node
from my_package_msgs.srv import AddTwoInts
class AddServer(Node):
def __init__(self):
super().__init__("add_server")
self.srv = self.create_service(
AddTwoInts, "add_two_ints", self.on_request
)
def on_request(self, request, response):
response.sum = request.a + request.b
self.get_logger().info(
f"收到請求 a={request.a}, b={request.b} -> sum={response.sum}"
)
return response
def main():
rclpy.init()
node = AddServer()
try:
rclpy.spin(node)
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
3. Service Client(安全的非同步寫法)
檔案位置:~/ros2_ws/src/my_package/my_package/add_client.py
import sys
import rclpy
from rclpy.node import Node
from my_package_msgs.srv import AddTwoInts
class AddClient(Node):
def __init__(self):
super().__init__("add_client")
self.client = self.create_client(AddTwoInts, "add_two_ints")
def send_request(self, a: int, b: int):
if not self.client.wait_for_service(timeout_sec=3.0):
self.get_logger().error("服務逾時:add_two_ints 未上線")
return
request = AddTwoInts.Request()
request.a = a
request.b = b
future = self.client.call_async(request)
future.add_done_callback(self.on_response)
def on_response(self, future):
response = future.result()
self.get_logger().info(f"回應: sum={response.sum}")
def main():
rclpy.init()
node = AddClient()
node.send_request(int(sys.argv[1]), int(sys.argv[2]))
rclpy.spin(node)
if __name__ == "__main__":
main()
編譯並在兩個終端機分別執行:
ros2 run my_package add_server
ros2 run my_package add_client 3 5
預期輸出
add_server 終端機:
[INFO] [add_server]: 收到請求 a=3, b=5 -> sum=8
add_client 終端機:
[INFO] [add_client]: 回應: sum=8
不寫程式碼、直接用 CLI 呼叫也可以:
ros2 service call /add_two_ints my_package_msgs/srv/AddTwoInts "{a: 10, b: 20}"
waiting for service to become available...
requester: making request: my_package_msgs.srv.AddTwoInts_Request(a=10, b=20)
response:
my_package_msgs.srv.AddTwoInts_Response(sum=30)
常見錯誤與除錯技巧
錯誤一:在自己的 callback 裡同步等待自己呼叫的 service,程式卡死
def on_ping(self, msg):
request = AddTwoInts.Request()
future = self.client.call_async(request)
rclpy.spin_until_future_complete(self, future) # 危險:在 callback 內部這樣寫
self.get_logger().info(f"sum={future.result().sum}")
現象:呼叫後整個節點卡住,沒有任何錯誤訊息,ros2 node list 顯示節點還活著,但完全沒有反應。
原因:跟上一節「節點」介紹的原理一樣——spin_until_future_complete 需要 executor 有機會執行「處理 service 回應」的 callback,但目前的 callback(on_ping)本身還沒結束、還佔用著 MutuallyExclusiveCallbackGroup 的執行權,導致回應永遠無法被處理,形成死鎖。
排除方式:改用本文範例裡的 future.add_done_callback(...) 模式,讓「發起請求」與「處理回應」變成兩個獨立的 callback,不需要在同一個 callback 內同步等待。
錯誤二:對還沒啟動的 Service 發起呼叫,Client 卡住不動
(沒有任何輸出,程式沒有結束也沒有報錯)
原因:call_async() 本身不會檢查 Server 是否存在,如果 Server 還沒啟動(或名稱打錯),future 永遠不會完成,如果你又用 spin_until_future_complete 等待,就會無限卡住。
排除方式:呼叫前一定要用 wait_for_service() 加上逾時:
if not self.client.wait_for_service(timeout_sec=3.0):
self.get_logger().error("服務逾時:add_two_ints 未上線")
return
也可以先用 CLI 確認服務是否存在:
ros2 service list | grep add_two_ints
小結
Service 提供的是同步、一問一答的通訊模式,底層透過 Future 物件實現非同步等待。真正的難點不在語法,而在於「什麼時候可以安全地等待 Future」——在 executor 的 callback 內部同步等待自己發起的請求,幾乎必然導致死鎖,正確做法是用 add_done_callback 拆成兩個獨立階段。下一節會介紹另一種通訊模式:處理長時間任務、可回報進度、可取消的動作(Action)。
延伸閱讀
常見問題
- Service 可以有多個 Client 同時呼叫嗎?
- 可以,Service Server 會依序處理收到的請求(除非你手動用 ReentrantCallbackGroup 讓多個請求並行處理)。如果請求處理時間長,多個 Client 同時呼叫時後面的請求會排隊等待。
- 什麼時候該用 Service,什麼時候該用 Topic?
- 需要「一次性、有明確回應」的操作(查詢狀態、觸發一個動作並確認完成)適合用 Service;需要「持續、不需要每次都確認」的資料流(感測器讀數、機器人姿態)適合用 Topic。經驗法則:如果你會問「這次呼叫成功了嗎、結果是什麼」,通常是 Service 的場景。
- Service 呼叫逾時了要怎麼處理?
- rclpy 的 Future 物件本身沒有內建逾時機制,需要自己實作:例如記錄呼叫時間,搭配 timer 檢查 future.done() 是否在期限內完成,超過期限就呼叫 future.cancel() 並記錄錯誤。也可以在等待前用 client.wait_for_service(timeout_sec=...) 先確認 Server 是否存在,避免對不存在的服務發起呼叫後無限等待。