服務(Service)

2026-07-18
  • ros2
  • service
  • rclpy
  • future

問題定義

主題適合「持續發生、不需要確認」的資料流,但很多情境需要的是「我發出一個請求,明確知道對方有沒有處理成功、結果是什麼」——例如叫機器人「回到充電座」,你會想知道它有沒有成功接受這個指令。這種一問一答的同步模式,就是 ROS2 的服務(Service)。

核心概念說明

Service 的請求-回應模型

一個 Service 由一個 .srv 定義檔描述,內容分成請求(Request)與回應(Response)兩部分,用 --- 分隔:

text
int64 a
int64 b
---
int64 sum

Server 端註冊一個 callback,每次收到請求就執行一次、回傳一個回應;Client 端發起呼叫後,會拿到一個 Future 物件——這是非同步程式設計裡常見的抽象,代表「一個尚未完成、但將來會有結果」的操作。

為什麼要理解 Future,而不是只會用 spin_until_future_complete

網路上很多教學會直接教你這樣寫:

python
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

bash
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

text
int64 a
int64 b
---
int64 sum

CMakeLists.txt 加上:

cmake
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Detection.msg"
  "srv/AddTwoInts.srv"
)

編譯後確認介面存在:

bash
colcon build --packages-select my_package_msgs
source install/setup.bash
ros2 interface show my_package_msgs/srv/AddTwoInts
text
int64 a
int64 b
---
int64 sum

2. Service Server

檔案位置:~/ros2_ws/src/my_package/my_package/add_server.py

python
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

python
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()

編譯並在兩個終端機分別執行:

bash
ros2 run my_package add_server
bash
ros2 run my_package add_client 3 5

預期輸出

add_server 終端機:

text
[INFO] [add_server]: 收到請求 a=3, b=5 -> sum=8

add_client 終端機:

text
[INFO] [add_client]: 回應: sum=8

不寫程式碼、直接用 CLI 呼叫也可以:

bash
ros2 service call /add_two_ints my_package_msgs/srv/AddTwoInts "{a: 10, b: 20}"
text
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,程式卡死

python
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 卡住不動

text
(沒有任何輸出,程式沒有結束也沒有報錯)

原因call_async() 本身不會檢查 Server 是否存在,如果 Server 還沒啟動(或名稱打錯),future 永遠不會完成,如果你又用 spin_until_future_complete 等待,就會無限卡住。

排除方式:呼叫前一定要用 wait_for_service() 加上逾時:

python
if not self.client.wait_for_service(timeout_sec=3.0):
    self.get_logger().error("服務逾時:add_two_ints 未上線")
    return

也可以先用 CLI 確認服務是否存在:

bash
ros2 service list | grep add_two_ints

小結

Service 提供的是同步、一問一答的通訊模式,底層透過 Future 物件實現非同步等待。真正的難點不在語法,而在於「什麼時候可以安全地等待 Future」——在 executor 的 callback 內部同步等待自己發起的請求,幾乎必然導致死鎖,正確做法是用 add_done_callback 拆成兩個獨立階段。下一節會介紹另一種通訊模式:處理長時間任務、可回報進度、可取消的動作(Action)。

延伸閱讀

常見問題

faq_01.log
Service 可以有多個 Client 同時呼叫嗎?
可以,Service Server 會依序處理收到的請求(除非你手動用 ReentrantCallbackGroup 讓多個請求並行處理)。如果請求處理時間長,多個 Client 同時呼叫時後面的請求會排隊等待。
faq_02.log
什麼時候該用 Service,什麼時候該用 Topic?
需要「一次性、有明確回應」的操作(查詢狀態、觸發一個動作並確認完成)適合用 Service;需要「持續、不需要每次都確認」的資料流(感測器讀數、機器人姿態)適合用 Topic。經驗法則:如果你會問「這次呼叫成功了嗎、結果是什麼」,通常是 Service 的場景。
faq_03.log
Service 呼叫逾時了要怎麼處理?
rclpy 的 Future 物件本身沒有內建逾時機制,需要自己實作:例如記錄呼叫時間,搭配 timer 檢查 future.done() 是否在期限內完成,超過期限就呼叫 future.cancel() 並記錄錯誤。也可以在等待前用 client.wait_for_service(timeout_sec=...) 先確認 Server 是否存在,避免對不存在的服務發起呼叫後無限等待。