動作(Action)
- ros2
- action
- rclpy-action
- goal-handle
問題定義
Service 適合「很快就有結果」的請求(例如加法運算),但如果請求是「導航到某個地點」這種可能要跑幾十秒甚至幾分鐘的任務,Service 的同步等待模型就不合適了——你會想知道「進行到哪裡了」,也可能想在中途喊停。這正是動作(Action)存在的原因。
核心概念說明
Action 的三段式生命週期
一個 Action 由三個部分組成(定義在 .action 檔案裡,用兩個 --- 分隔):
# 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(實際執行任務邏輯,通常會跑一段時間)。如果全部都用預設的 MutuallyExclusiveCallbackGroup,execute_callback 執行期間,cancel_callback 會完全沒有機會被觸發——使用者送出取消請求也不會有反應,直到任務執行完畢。因此 Action Server 幾乎都需要搭配 ReentrantCallbackGroup,讓取消請求可以在任務執行期間被處理。
實作範例:倒數計時 Action(含 feedback 與取消)
1. 定義 Action 介面
mkdir -p ~/ros2_ws/src/my_package_msgs/action
檔案位置:my_package_msgs/action/Countdown.action
# Goal
int32 target_count
---
# Result
int32 total_elapsed_sec
---
# Feedback
int32 current_count
CMakeLists.txt 加上:
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/Detection.msg"
"srv/AddTwoInts.srv"
"action/Countdown.action"
)
編譯確認:
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
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_callback 在 execute_callback 執行期間依然拿不到執行機會。
3. Action Client
檔案位置:~/ros2_ws/src/my_package/my_package/countdown_client.py
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()
編譯後分別執行:
ros2 run my_package countdown_server
ros2 run my_package countdown_client 5
預期輸出
countdown_server 終端機:
[INFO] [countdown_server]: 倒數: 5
[INFO] [countdown_server]: 倒數: 4
[INFO] [countdown_server]: 倒數: 3
[INFO] [countdown_server]: 倒數: 2
[INFO] [countdown_server]: 倒數: 1
countdown_client 終端機:
[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):
ros2 action send_goal /countdown my_package_msgs/action/Countdown "{target_count: 3}" --feedback
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 節點另一個常用機制:可以動態調整的參數系統。
延伸閱讀
常見問題
- Action 底層是不是用 Topic 和 Service 組合出來的?
- 是的。Action 在協定層面其實是兩個 Service(send_goal、cancel)加上兩個 Topic(feedback、status)的組合,rclpy_action 把這些細節封裝起來,讓你只需要處理 goal_callback、cancel_callback、execute_callback 三個回呼。
- 一個 Action Server 可以同時處理多個 goal 嗎?
- 預設行為視實作而定——如果你在 execute_callback 裡沒有特別處理,新 goal 進來時舊 goal 通常還在執行。實務上常見的做法是在 goal_callback 裡明確拒絕新 goal(如果已有 goal 在執行中),或呼叫既有 goal 的 abort 來讓新 goal 取而代之,這需要自己在程式碼裡管理目前執行中的 goal_handle。
- Client 端一定要處理 feedback 嗎?
- 不一定,feedback 是選擇性的資訊流,就算不註冊 feedback_callback,Action 呼叫依然能正常送出 goal 並取得最終結果,只是拿不到過程中的進度回報。