節點(Node)概念
- ros2
- node
- rclpy
- executor
問題定義
前面幾節我們處理的都是「環境」——怎麼裝好 ROS2、怎麼建立套件。這一節開始寫真正的程式碼:一個 ROS2 節點(Node)。但「寫一個節點」背後其實牽涉三件事:Node 物件本身、驅動它運作的 executor、以及底層 DDS 怎麼讓其他節點知道你存在。如果只學會複製貼上 minimal publisher 範例,遇到稍微複雜一點的併發情境(例如在 callback 裡呼叫 service)就會卡關,所以這一節要把這三層都講清楚。
核心概念說明
Node:ROS2 世界裡的一個參與者
rclpy.node.Node 是你跟 ROS2 系統互動的入口,所有的 publisher、subscriber、service、timer 都是透過 Node 物件建立的。建立一個 Node 時,rclpy 在底層會建立一個對應的 DDS DomainParticipant——這是 DDS 規範裡「一個網路參與者」的抽象,負責處理發現(discovery)、資料傳輸的底層細節。這也解釋了為什麼建立大量節點(例如幾百個)會有明顯的啟動開銷:每個節點都要完成一次 DDS 的參與者初始化與探索過程。
Executor:誰來呼叫你的 callback
寫過 minimal node 範例的人常常忽略一個問題:create_subscription() 註冊的 callback 函式,到底是「誰」在什麼時候呼叫它?答案是 executor。rclpy.spin(node) 這行程式碼,本質上是建立一個預設的 SingleThreadedExecutor,把你的節點加進去,然後進入一個迴圈:檢查有沒有任何已註冊的 callback(訂閱、計時器、服務……)有事件在等待處理,有的話就依序呼叫。
這裡的關鍵字是「依序」——SingleThreadedExecutor 一次只執行一個 callback,執行完才會處理下一個。這代表如果你的某個 callback 執行時間很長(例如裡面做了一次耗時的影像處理),這段時間裡這個節點其他所有的 callback 都不會被觸發,包括其他主題的訂閱。
如果你需要多個 callback 平行執行,就要改用 MultiThreadedExecutor:
from rclpy.executors import MultiThreadedExecutor
executor = MultiThreadedExecutor(num_threads=4)
executor.add_node(node)
executor.spin()
Callback Group:控制哪些 callback 可以同時執行
光換成 MultiThreadedExecutor 還不夠——預設情況下,同一個節點的所有 callback 屬於同一個 MutuallyExclusiveCallbackGroup,代表即使 executor 有多條執行緒,同一個節點裡的 callback 還是不會同時執行(這是為了避免你的節點內部狀態被多執行緒同時讀寫而出錯,屬於安全預設值)。
如果你確定某些 callback 之間可以安全地並行(例如彼此不共用可變狀態),可以手動指定 ReentrantCallbackGroup:
from rclpy.callback_groups import ReentrantCallbackGroup
self.cb_group = ReentrantCallbackGroup()
self.create_subscription(String, "topic_a", self.callback_a, 10, callback_group=self.cb_group)
self.create_subscription(String, "topic_b", self.callback_b, 10, callback_group=self.cb_group)
這個機制在「常見錯誤」小節會用一個實際會卡死的例子來說明為什麼它很重要。
實作範例:一個會用到多種 callback 的節點
以下範例建立一個節點,同時有一個 timer callback 與一個 subscription callback,用來觀察 executor 怎麼交錯處理它們。
檔案位置:~/ros2_ws/src/my_package/my_package/node_demo.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class NodeDemo(Node):
def __init__(self):
super().__init__("node_demo")
self.counter = 0
# 每 1 秒觸發一次的 timer callback
self.timer = self.create_timer(1.0, self.on_timer)
# 訂閱 /ping,收到訊息就印出來
self.subscription = self.create_subscription(
String, "ping", self.on_ping, 10
)
self.get_logger().info("node_demo 已啟動")
def on_timer(self):
self.counter += 1
self.get_logger().info(f"timer tick #{self.counter}")
def on_ping(self, msg: String):
self.get_logger().info(f"收到 ping: {msg.data}")
def main():
rclpy.init()
node = NodeDemo()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
別忘了在 setup.py 的 entry_points 裡註冊這個節點,才能用 ros2 run 執行:
entry_points={
"console_scripts": [
"node_demo = my_package.node_demo:main",
],
},
編譯並執行:
colcon build --symlink-install --packages-select my_package
source install/setup.bash
ros2 run my_package node_demo
預期輸出
[INFO] [1752800001.123456789] [node_demo]: node_demo 已啟動
[INFO] [1752800002.124012345] [node_demo]: timer tick #1
[INFO] [1752800003.124987654] [node_demo]: timer tick #2
打開另一個終端機發布一則訊息到 /ping:
ros2 topic pub --once /ping std_msgs/msg/String "data: 'hello'"
回到節點的終端機視窗,會看到 on_ping 被觸發,並且是插入在 timer tick 之間依序執行(因為預設是 SingleThreadedExecutor + MutuallyExclusiveCallbackGroup):
[INFO] [1752800004.125555555] [node_demo]: timer tick #3
[INFO] [1752800004.301222333] [node_demo]: 收到 ping: hello
[INFO] [1752800005.126789012] [node_demo]: timer tick #4
用另一個終端機確認節點確實在 ROS2 的節點列表裡:
ros2 node list
/node_demo
常見錯誤與除錯技巧
錯誤一:在 callback 裡呼叫 service 導致整個節點卡死
這是新手最常踩、也最難理解的坑。假設你在一個 subscription callback 裡,同步呼叫另一個 service:
def on_ping(self, msg):
request = Trigger.Request()
future = self.client.call_async(request)
rclpy.spin_until_future_complete(self, future) # 問題出在這裡
現象:程式完全沒有報錯訊息,但整個節點就是卡住不動,ros2 topic echo 也看不到任何後續輸出。
原因:rclpy.spin_until_future_complete(self, future) 內部其實是在「目前的 executor」上又跑了一次事件迴圈,等待 future 完成。但 on_ping 這個 callback 本身就是目前 executor 正在執行的 callback——在預設的 MutuallyExclusiveCallbackGroup 底下,同一個 callback group 裡的 callback 不能重入(re-entrant),所以「等待 service 回應」這件事,永遠等不到「處理 service 回應的 callback」被執行的機會,形成死鎖。
排除方式:兩種做法都可以:
- 改用
ReentrantCallbackGroup,讓 service 的回應 callback 可以在等待期間被觸發:
from rclpy.callback_groups import ReentrantCallbackGroup
self.cb_group = ReentrantCallbackGroup()
self.client = self.create_client(Trigger, "my_service", callback_group=self.cb_group)
self.create_subscription(String, "ping", self.on_ping, 10, callback_group=self.cb_group)
- 或者不要在 callback 裡同步等待,改成非同步註冊回呼:
def on_ping(self, msg):
future = self.client.call_async(Trigger.Request())
future.add_done_callback(self.on_service_response)
def on_service_response(self, future):
result = future.result()
self.get_logger().info(f"service 回應: {result.message}")
第二種做法通常更推薦,因為它完全不依賴 executor 的執行緒模型細節,換成 MultiThreadedExecutor 也不會有問題。
錯誤二:忘記 destroy_node(),重複執行時出現資源警告
[WARN] [rclpy]: Publisher <...> was garbage collected without being destroyed
原因:如果程式因為例外中斷,跳過了 node.destroy_node() 這一步,節點持有的 publisher/subscription 等資源沒有被正確釋放,Python 的垃圾回收機制介入時會發出警告(雖然通常不會導致程式崩潰,但長期執行的服務型節點應該避免這種狀況)。
排除方式:務必用 try/finally 包住 spin(),確保無論是否發生例外,都會執行清理:
try:
rclpy.spin(node)
finally:
node.destroy_node()
rclpy.shutdown()
小結
一個 ROS2 節點的行為,其實是 Node 物件(定義了「有哪些 callback」)加上 executor(決定「這些 callback 怎麼被呼叫、能不能同時執行」)共同決定的。多數簡單場景用預設的 spin() 就夠,但只要你的節點需要在 callback 裡等待其他非同步操作(呼叫 service、action),就必須理解 callback group 的併發模型,否則很容易寫出看起來正常、實際上會死鎖的程式碼。下一節我們會進入 ROS2 最常用的通訊模式:主題發布與訂閱。
延伸閱讀
常見問題
- 一個 Python 檔案裡可以有多個 Node 嗎?
- 可以。Node 只是一個物件實例,一個行程(process)裡可以建立多個 Node 物件,只要把它們都加進同一個 executor 就好。這在需要把多個相關功能包在同一個行程裡(減少行程間通訊開銷)時很常見,稱為節點組合(component composition)。
- spin() 和 spin_once() 差在哪?
- spin() 會持續阻塞,不斷處理 callback 直到節點被關閉;spin_once() 只處理「目前」佇列裡已經到位的事件一次就返回。spin_once() 通常用在你需要在 ROS2 的事件迴圈之外,還要跑自己的迴圈邏輯的情境(例如搭配 GUI 框架)。
- 為什麼我的 service callback 裡呼叫另一個 service,程式就卡死了?
- 這是 executor 併發模型的經典陷阱,本文「常見錯誤」小節有完整說明:用 SingleThreadedExecutor 時,同一個 callback group 裡的 callback 不能重入等待,會造成死鎖。解法是用 MultiThreadedExecutor 搭配 ReentrantCallbackGroup,或是把該次呼叫改成非阻塞的方式處理。
- 節點的名字可以重複嗎?
- 同一個 ROS_DOMAIN_ID 底下,理論上不建議重複,重複的節點名稱會讓 ros2 node list 難以區分,且部分工具(如 rqt_graph)可能顯示異常。如果需要執行多個相同邏輯的節點實例,應該用 namespace 或在啟動時傳入不同的節點名稱參數來區分。