11.16 Python 订阅节点、多进程管理

77 阅读6分钟

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:消息类型,例如 IsWaving
    • topic_name:话题名,例如 'waving_signal'
    • callback:收到消息时调用的回调函数,例如 self.listener_callback
    • qos_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_fnsubprocess.Popen 的一个参数:

  • 表示:在子进程真正执行命令(exec)之前,先在子进程中调用一次你指定的函数
  • 这里传入的是 os.setsid,也就是调用系统调用 setsid()

os.setsid() 的核心效果:

  1. 在子进程中创建一个新的 session(会话)
  2. 同时创建一个新的 process group(进程组)
  3. 让当前子进程成为这个新会话的首领,并成为这个新进程组的“组长”

结合 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。


今天学习内容的整体小结

  1. Python 订阅节点:

    • class WavingSubscriber(Node) 通过继承 ROS2 的 Node 类成为一个真正的 ROS 节点。
    • super().__init__('waving_subscriber') 调用父类构造函数,注册节点名。
    • create_subscription(...) 绑定消息类型、话题名、回调函数和 QoS。
    • self.subscription / self.processes 通过 self.xxx = ... 成为成员变量。
  2. subprocess 与 Popen:

    • subprocess 是标准库模块,专门用于操作外部进程。

    • Popen 是其中的类,每次调用 subprocess.Popen(...) 都会创建一个子进程,并返回一个“子进程控制对象”:

      • p.pid:子进程 PID;
      • p.wait():等待进程结束;
      • p.poll():检查是否结束;
      • p.terminate() / p.kill():结束进程(但无法像 killpg 那样一并结束所有后代进程)。
  3. ["/bin/bash", "-lc", cmd] 的意义:

    • 使用 bash -lc 启动一个新的登录 shell:

      • -l:加载环境(确保 ros2 等命令可用);
      • -c cmd:执行 cmd 字符串。
    • 实际上就是在一个“干净、正确配置的终端环境”中执行 cmd

  4. preexec_fn=os.setsid 的意义:

    • 在子进程 exec 前调用 setsid()

      • 创建新的 session 和新的进程组;
      • 让当前子进程成为新进程组组长
    • 后续 ros2 run / ros2 launch 及其子进程都属于这个进程组。

    • 配合:

      os.killpg(os.getpgid(p.pid), signal.SIGINT)
      

      即可一条指令关停整个模块(无论是单节点还是多节点)。

  5. 一条可以记住的总结句:

["/bin/bash", "-lc", cmd]
相当于“开一个新的终端,加载环境,然后执行 cmd”。

preexec_fn=os.setsid + os.killpg(os.getpgid(p.pid), SIGINT)
相当于“给这一条命令及其所有后代进程分配一个独立的帮派(进程组),
以后可以通过一次 killpg 把整个帮派干净利落地关闭”。

这就是今天围绕 wake_node、Python 订阅节点、多进程管理学到的核心内容。