Python 订阅者节点
一个小例子(简化版):
class WavingSubscriber(Node):
# 子类构造函数实现
def __init__(self):
# 调用父类 Node 的构造函数,创建名为 "waving_subscriber" 的 ROS2 节点
super().__init__('waving_subscriber')
# 订阅话题 waving_signal
self.subscription = self.create_subscription(
IsWaving, # 消息类型
'waving_signal', # 话题名
self.listener_callback, # 回调函数
10 # QoS 队列大小(队列深度)
)
self.subscription # 防止“变量未使用”警告,也确保订阅对象不被回收
self.processes = {} # 保存启动的子进程(每个模块对应一个 Popen 对象)
self.get_logger().info("已启动节点:等待接收挥手信号 (waving_signal)...")
...
create_subscription 简要说明
-
create_subscription(msg_type, topic_name, callback, qos_depth)用于在当前节点上创建一个订阅者(subscriber):msg_type:消息类型,例如IsWavingtopic_name:话题名,例如'waving_signal'callback:收到消息时调用的回调函数,例如self.listener_callbackqos_depth:队列长度(缓存多少条尚未处理的消息)
当有其他节点在 waving_signal 上发布 IsWaving 消息时,ROS2 会自动调用:
def listener_callback(self, msg):
...
并将收到的消息实例作为 msg 传入。
Python 成员变量的小结
Python 中没有像 C++ 那样单独的“成员变量声明”。
只要在类的方法里写了:
self.xxx = 值
那么 xxx 就成为这个对象的成员变量(属性) 。例如:
self.subscription:保存订阅对象,防止被垃圾回收造成订阅失效。self.processes:一个字典,保存所有通过subprocess.Popen启动的子进程对象,便于后续统一管理和关闭。
多进程基础知识(结合 wake_node 的用法)
在这个场景下,涉及两个常用模块:
subprocess:用于启动和管理外部进程(ros2 run/ros2 launch等)。os:提供操作系统接口(例如os.setsid()、os.killpg()、os.getpgid()等)。
在 wake_all_nodes() 里典型的启动代码如下:
p = subprocess.Popen(
["/bin/bash", "-lc", cmd],
stdout=subprocess.PIPE, # 标准输出重定向到管道,便于在 Python 中读取
stderr=subprocess.STDOUT, # 标准错误合并到标准输出
preexec_fn=os.setsid # 子进程 exec 前调用 setsid(),创建新的会话 & 进程组
)
下面分点说明每一部分的含义。
1. ["/bin/bash", "-lc", cmd] 的真实含义
这行参数相当于在系统中执行:
/bin/bash -lc "cmd"
即启动一个新的 bash 进程,并让它执行你传入的 cmd 字符串。
-
/bin/bash:启动一个 bash 解释器。 -
-l(login shell):- 以“登录 shell”的方式启动。
- 会读取 profile / bashrc 等配置文件,加载环境变量。
- 这一步对 ROS2 非常重要,因为
ros2命令、SDK 环境通常是在这些脚本里source进去的。
-
-c "cmd":-
表示“执行后面的这段命令字符串”。
-
例如:
"ros2 run visual_function camera_capture_c_node""ros2 launch sensor_function extreme_light_signals"
-
可以通俗地理解为:
“开了一个新的终端,加载好环境,再在里面执行cmd这条命令。”
而不是在当前 Python 进程里直接运行cmd。
如果不通过 /bin/bash -lc,直接尝试:
subprocess.Popen(["ros2", "run", "xxx", "xxx"])
在很多环境下会报错:
ros2: command not found
原因就是没有加载 ROS2 所需的环境变量。
2. preexec_fn=os.setsid 的作用
preexec_fn 是 subprocess.Popen 的一个参数:
- 表示:在子进程真正执行命令(exec)之前,先在子进程中调用一次你指定的函数。
- 这里传入的是
os.setsid,也就是调用系统调用setsid()。
os.setsid() 的核心效果:
- 在子进程中创建一个新的 session(会话) ;
- 同时创建一个新的 process group(进程组) ;
- 让当前子进程成为这个新会话的首领,并成为这个新进程组的“组长” 。
结合 wake_node 的场景:
-
这条
Popen启动的bash进程,成为一个独立进程组的组长; -
后续在这个
bash中运行的:ros2 run ...ros2 launch ...- 以及这些命令内部产生的子进程
都会继承这个进程组 ID(PGID)。
注意:
- 父子进程关系不变:
wake_node 仍然是bash的父进程。
setsid()改变的是“会话和进程组”,不是“谁是爸爸”。- 变化的是:
现在这条命令及其所有后代,属于一个单独的“进程组” ,可以整体被信号管理。
3. 如何“关停整个模块”(单/多节点)
每一次 Popen(...),都会创建一个新的 bash 子进程,并在其中调用 os.setsid()。于是:
- 这个 bash 有一个进程 ID:
p.pid(例如 2001); - 调用
setsid()后,它成为一个新的进程组组长,该组的 ID 通常就是它自己的 PID(PGID = PID)。
随后你可以通过:
pgid = os.getpgid(p.pid) # 获取进程组 ID(PGID)
os.killpg(pgid, signal.SIGINT) # 向整个进程组发送 SIGINT 信号(类似 Ctrl+C)
这样:
-
这条命令对应的所有进程都会收到 SIGINT:
bash -lc "cmd"ros2 run / ros2 launch主进程- 以及它们 fork 出来的所有子节点进程
不论这个模块是:
- 一个简单的
ros2 run单节点(但内部可能自己 fork 多进程),还是 - 一个复杂的
ros2 launch多节点树,
都可以通过这一条 killpg 一次性优雅关停。
为什么不能只 kill(p.pid)?
如果你只写:
os.kill(p.pid, signal.SIGINT)
通常只会杀掉最外层的 bash 进程(组长),而:
- 里面实际运行的
ros2 run/ros2 launch - 以及由它们产生的各个节点进程
很可能仍然存活,变为“孤儿进程 / 僵尸进程”,占用串口、相机等资源。
因此 正确的做法是:通过 killpg 杀“进程组”,而不是只杀组长的 PID。
今天学习内容的整体小结
-
Python 订阅节点:
class WavingSubscriber(Node)通过继承 ROS2 的Node类成为一个真正的 ROS 节点。super().__init__('waving_subscriber')调用父类构造函数,注册节点名。create_subscription(...)绑定消息类型、话题名、回调函数和 QoS。self.subscription/self.processes通过self.xxx = ...成为成员变量。
-
subprocess 与 Popen:
-
subprocess是标准库模块,专门用于操作外部进程。 -
Popen是其中的类,每次调用subprocess.Popen(...)都会创建一个子进程,并返回一个“子进程控制对象”:p.pid:子进程 PID;p.wait():等待进程结束;p.poll():检查是否结束;p.terminate()/p.kill():结束进程(但无法像 killpg 那样一并结束所有后代进程)。
-
-
["/bin/bash", "-lc", cmd]的意义:-
使用
bash -lc启动一个新的登录 shell:-l:加载环境(确保ros2等命令可用);-c cmd:执行cmd字符串。
-
实际上就是在一个“干净、正确配置的终端环境”中执行
cmd。
-
-
preexec_fn=os.setsid的意义:-
在子进程 exec 前调用
setsid():- 创建新的 session 和新的进程组;
- 让当前子进程成为新进程组组长。
-
后续
ros2 run/ros2 launch及其子进程都属于这个进程组。 -
配合:
os.killpg(os.getpgid(p.pid), signal.SIGINT)即可一条指令关停整个模块(无论是单节点还是多节点)。
-
-
一条可以记住的总结句:
["/bin/bash", "-lc", cmd]:
相当于“开一个新的终端,加载环境,然后执行 cmd”。
preexec_fn=os.setsid+os.killpg(os.getpgid(p.pid), SIGINT):
相当于“给这一条命令及其所有后代进程分配一个独立的帮派(进程组),
以后可以通过一次 killpg 把整个帮派干净利落地关闭”。
这就是今天围绕 wake_node、Python 订阅节点、多进程管理学到的核心内容。