動作(Action)

2026-07-18
  • ros2
  • action
  • rclpy-action
  • goal-handle

問題定義

Service 適合「很快就有結果」的請求(例如加法運算),但如果請求是「導航到某個地點」這種可能要跑幾十秒甚至幾分鐘的任務,Service 的同步等待模型就不合適了——你會想知道「進行到哪裡了」,也可能想在中途喊停。這正是動作(Action)存在的原因。

核心概念說明

Action 的三段式生命週期

一個 Action 由三個部分組成(定義在 .action 檔案裡,用兩個 --- 分隔):

text
# Goal
int32 target_count
---
# Result
int32 total_elapsed_sec
---
# Feedback
int32 current_count
  • Goal:Client 發起任務時傳入的目標參數
  • Feedback:任務執行過程中,Server 可以持續回報的進度資訊
  • Result:任務結束(成功/失敗/取消)時的最終結果

Goal Handle:管理單一任務的狀態機

Server 端每次接受一個新 goal,會產生一個 goal handle,這個物件封裝了該次任務目前的狀態(executing/canceling/succeeded/aborted/canceled)。Server 的程式邏輯需要在正確的時機呼叫對應的方法:

  • goal_handle.succeed():任務成功完成
  • goal_handle.abort():任務執行失敗
  • goal_handle.canceled():任務被使用者取消,且 Server 已妥善處理取消

為什麼 Action Server 需要 ReentrantCallbackGroup

Action 的實作內部同時牽涉到 goal_callback(決定接不接受新 goal)、cancel_callback(決定能不能取消)、execute_callback(實際執行任務邏輯,通常會跑一段時間)。如果全部都用預設的 MutuallyExclusiveCallbackGroupexecute_callback 執行期間,cancel_callback 會完全沒有機會被觸發——使用者送出取消請求也不會有反應,直到任務執行完畢。因此 Action Server 幾乎都需要搭配 ReentrantCallbackGroup,讓取消請求可以在任務執行期間被處理。

實作範例:倒數計時 Action(含 feedback 與取消)

1. 定義 Action 介面

bash
mkdir -p ~/ros2_ws/src/my_package_msgs/action

檔案位置:my_package_msgs/action/Countdown.action

text
# Goal
int32 target_count
---
# Result
int32 total_elapsed_sec
---
# Feedback
int32 current_count

CMakeLists.txt 加上:

cmake
find_package(rosidl_default_generators REQUIRED)

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

編譯確認:

bash
colcon build --packages-select my_package_msgs
source install/setup.bash
ros2 interface show my_package_msgs/action/Countdown

2. Action Server

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

python
import time
import rclpy
from rclpy.action import ActionServer, CancelResponse, GoalResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.node import Node
from my_package_msgs.action import Countdown


class CountdownServer(Node):
    def __init__(self):
        super().__init__("countdown_server")
        self.cb_group = ReentrantCallbackGroup()

        self._action_server = ActionServer(
            self,
            Countdown,
            "countdown",
            execute_callback=self.execute_callback,
            goal_callback=self.goal_callback,
            cancel_callback=self.cancel_callback,
            callback_group=self.cb_group,
        )

    def goal_callback(self, goal_request):
        if goal_request.target_count <= 0:
            self.get_logger().warn("拒絕 goal:target_count 必須大於 0")
            return GoalResponse.REJECT
        return GoalResponse.ACCEPT

    def cancel_callback(self, goal_handle):
        self.get_logger().info("收到取消請求")
        return CancelResponse.ACCEPT

    def execute_callback(self, goal_handle):
        target = goal_handle.request.target_count
        feedback_msg = Countdown.Feedback()
        start = time.time()

        for current in range(target, 0, -1):
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                self.get_logger().info(f"任務在倒數 {current} 時被取消")
                result = Countdown.Result()
                result.total_elapsed_sec = int(time.time() - start)
                return result

            feedback_msg.current_count = current
            goal_handle.publish_feedback(feedback_msg)
            self.get_logger().info(f"倒數: {current}")
            time.sleep(1.0)

        goal_handle.succeed()
        result = Countdown.Result()
        result.total_elapsed_sec = int(time.time() - start)
        return result


def main():
    rclpy.init()
    node = CountdownServer()
    executor = rclpy.executors.MultiThreadedExecutor()
    executor.add_node(node)
    try:
        executor.spin()
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

注意這裡用了 MultiThreadedExecutor 搭配 ReentrantCallbackGroup——如果只用預設的 spin(node)(等同 SingleThreadedExecutor),cancel_callbackexecute_callback 執行期間依然拿不到執行機會。

3. Action Client

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

python
import sys
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from my_package_msgs.action import Countdown


class CountdownClient(Node):
    def __init__(self):
        super().__init__("countdown_client")
        self._client = ActionClient(self, Countdown, "countdown")

    def send_goal(self, target_count: int):
        self._client.wait_for_server()

        goal_msg = Countdown.Goal()
        goal_msg.target_count = target_count

        send_goal_future = self._client.send_goal_async(
            goal_msg, feedback_callback=self.on_feedback
        )
        send_goal_future.add_done_callback(self.on_goal_response)

    def on_feedback(self, feedback):
        self.get_logger().info(
            f"feedback: current_count={feedback.feedback.current_count}"
        )

    def on_goal_response(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().error("goal 被拒絕")
            return

        result_future = goal_handle.get_result_async()
        result_future.add_done_callback(self.on_result)

    def on_result(self, future):
        result = future.result().result
        self.get_logger().info(
            f"任務結束,總耗時 {result.total_elapsed_sec} 秒"
        )
        rclpy.shutdown()


def main():
    rclpy.init()
    node = CountdownClient()
    node.send_goal(int(sys.argv[1]))
    rclpy.spin(node)


if __name__ == "__main__":
    main()

編譯後分別執行:

bash
ros2 run my_package countdown_server
bash
ros2 run my_package countdown_client 5

預期輸出

countdown_server 終端機:

text
[INFO] [countdown_server]: 倒數: 5
[INFO] [countdown_server]: 倒數: 4
[INFO] [countdown_server]: 倒數: 3
[INFO] [countdown_server]: 倒數: 2
[INFO] [countdown_server]: 倒數: 1

countdown_client 終端機:

text
[INFO] [countdown_client]: feedback: current_count=5
[INFO] [countdown_client]: feedback: current_count=4
[INFO] [countdown_client]: feedback: current_count=3
[INFO] [countdown_client]: feedback: current_count=2
[INFO] [countdown_client]: feedback: current_count=1
[INFO] [countdown_client]: 任務結束,總耗時 5 秒

用 CLI 觀察正在執行中的 action(不需要自己寫 client):

bash
ros2 action send_goal /countdown my_package_msgs/action/Countdown "{target_count: 3}" --feedback
text
Waiting for an action server to become available...
Sending goal:
     target_count: 3

Goal accepted with ID: 4c3f...

Feedback:
    current_count: 3

Feedback:
    current_count: 2

Feedback:
    current_count: 1

Result:
    total_elapsed_sec: 3

Goal finished with status: SUCCEEDED

常見錯誤與除錯技巧

錯誤一:送出取消請求後,任務完全沒反應,等到自然結束才停止

現象:呼叫 ros2 action send_goal ... --feedback 執行中,按 Ctrl+C 或另外呼叫取消,Server 端的倒數還是繼續跑到底才結束。

原因:Action Server 用了預設的 rclpy.spin(node)(也就是 SingleThreadedExecutor + MutuallyExclusiveCallbackGroup),execute_callback 正在執行時,cancel_callback 完全沒有機會被呼叫,goal_handle.is_cancel_requested 永遠不會被更新成 True

排除方式:如本文範例,改用 MultiThreadedExecutor 搭配 ReentrantCallbackGroup,讓 cancel_callback 能在 execute_callback 執行期間被觸發。

錯誤二:goal_handle.result() 拿到的欄位是空的或預設值

原因execute_callback 回傳前,忘記把 result 物件的欄位確實賦值,或是在某個提早 return 的分支(例如取消時)忘記回傳一個填好欄位的 Result 物件。

排除方式:檢查每一個 return 路徑(正常完成、取消、拒絕)是否都建立並填寫了完整的 Result 物件,本文範例中無論是正常完成還是被取消,都各自建立了對應的 result 並設定 total_elapsed_sec

小結

Action 是 Service 與 Topic 的組合:用 Service 語意處理「接受/拒絕 goal」與「取消」,用 Topic 語意持續回報 feedback。它的複雜度主要來自併發模型——沒有正確設定 ReentrantCallbackGroup,取消功能就形同虛設。下一節我們會介紹 ROS2 節點另一個常用機制:可以動態調整的參數系統。

延伸閱讀

常見問題

faq_01.log
Action 底層是不是用 Topic 和 Service 組合出來的?
是的。Action 在協定層面其實是兩個 Service(send_goal、cancel)加上兩個 Topic(feedback、status)的組合,rclpy_action 把這些細節封裝起來,讓你只需要處理 goal_callback、cancel_callback、execute_callback 三個回呼。
faq_02.log
一個 Action Server 可以同時處理多個 goal 嗎?
預設行為視實作而定——如果你在 execute_callback 裡沒有特別處理,新 goal 進來時舊 goal 通常還在執行。實務上常見的做法是在 goal_callback 裡明確拒絕新 goal(如果已有 goal 在執行中),或呼叫既有 goal 的 abort 來讓新 goal 取而代之,這需要自己在程式碼裡管理目前執行中的 goal_handle。
faq_03.log
Client 端一定要處理 feedback 嗎?
不一定,feedback 是選擇性的資訊流,就算不註冊 feedback_callback,Action 呼叫依然能正常送出 goal 並取得最終結果,只是拿不到過程中的進度回報。