在 POSIX 系统中,进程管理中不太令人愉快的部分之一就是等待进程终止。自大约 15 年前 Python 3.3 中Popen.wait()添加了超时参数以来,标准库 subprocess 模块一直依赖于忙循环轮询(busy-loop polling)方法(参见源代码)。psutil 的Process.wait()方法也使用了完全相同的技术(参见源代码)。

逻辑很简单:检查进程是否已使用非阻塞方式退出waitpid(WNOHANG),短暂休眠,再次检查,再休眠一段时间,依此类推。

import os, time

def wait_busy(pid, timeout):
    end = time.monotonic() + timeout
    interval = 0.0001
    while time.monotonic() < end:
        pid_done, _ = os.waitpid(pid, os.WNOHANG)
        if pid_done:
            return
        time.sleep(interval)
        interval = min(interval * 2, 0.04)
    raise TimeoutExpired

在这篇博文中,我将展示我是如何最终解决这个长期存在的效率低下问题的,首先是在 psutil 中,最令人兴奋的是,直接在 CPython 标准库 subprocess 模块中。

忙轮询的问题

  • CPU 唤醒:即使采用指数退避(从 0.1 毫秒开始,上限为 40 毫秒),系统仍会不断唤醒以检查进程状态,浪费 CPU 周期并耗尽电池电量。
  • 延迟:进程实际终止与检测到进程终止之间总是存在时间差。
  • 可扩展性:同时监控多个进程会放大上述所有问题。

事件驱动等待

所有 POSIX 系统都至少提供了一种机制,用于在文件描述符就绪时收到通知。这些机制包括select() 、 poll() 、 epoll() (Linux)和kqueue() (BSD/macOS)系统调用。直到最近,我一直认为它们只能用于引用套接字、管道等的文件描述符,但事实证明它们也可以用来等待进程 PID 上的事件!

Linux

2019 年,Linux 5.3 引入了一个新的系统调用 pidfd_open() ,Python 3.9 将其添加到了 os 模块中。它返回一个指向进程 PID 的文件描述符。有趣的是,pidfd_open() 可以与 select()poll()epoll() 结合使用,有效地等待进程退出。例如,可以使用 poll()

import os, select

def wait_pidfd(pid, timeout):
    pidfd = os.pidfd_open(pid)
    poller = select.poll()
    poller.register(pidfd, select.POLLIN)
    # block until process exits or timeout occurs
    events = poller.poll(timeout * 1000)
    if events:
        return
    raise TimeoutError

这种方法完全避免了忙循环。内核会在进程终止时或等待超时时(进程 ID 仍然存活)唤醒我们。

我选择 poll() 而不是 select(),因为 select() 有历史文件描述符限制 (FD_SETSIZE),通常每个进程最多只能有 1024 个文件描述符(这让我想起了BPO-1685000 )。

我选择poll()而不是epoll(),是因为它不需要创建额外的文件描述符。它也只需要一次系统调用,这在监控单个文件描述符时比监控多个文件描述符时效率更高。

macOS 和 BSD

源自 BSD 的系统(包括 macOS)提供了 kqueue() 系统调用。它在概念上类似于 select()poll()epoll(),但功能更强大(例如,它还可以处理普通文件)。kqueue() 可以直接接收进程 ID (PID) 参数,并在 PID 消失或超时后返回。

import select

def wait_kqueue(pid, timeout):
    kq = select.kqueue()
    kev = select.kevent(
        pid,
        filter=select.KQ_FILTER_PROC,
        flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT,
        fflags=select.KQ_NOTE_EXIT,
    )
    # block until process exits or timeout occurs
    events = kq.control([kev], 1, timeout)
    if events:
        return
    raise TimeoutError

Windows

由于 WaitForSingleObject 的贡献,Windows 在 psutil 和 subprocess 模块中都不会出现忙循环。这意味着 Windows 从一开始就有效地实现了事件驱动的进程等待。因此,这方面无需任何操作。

优雅的备选方案

pidfd_open()kqueue() 都可能因不同的原因而失败。例如,EMFILE 会导致进程耗尽文件描述符(通常为 1024 个),EACCES / EPERM 会导致系统调用被系统管理员在系统级别显式阻塞(例如通过 SECCOMP)。在所有这些情况下,psutil 都会静默地回退到传统的忙循环轮询方式,而不是抛出异常。

这种快速路径带回退的方法与BPO-33671 的精神类似,我在 2018 年通过使用零拷贝系统调用加快了 shutil.copyfile() 的速度。在 BPO-33671 中,首先尝试更高效的 os.sendfile() 方法,如果失败(例如在网络文件系统上),则回退到传统的 read() / write() 方法来复制常规文件。

性能测试

作为一个简单的实验,这里有一个简单的程序,它会在不终止的情况下等待自身运行 10 秒钟:

# test.py
import psutil, os
try:
    psutil.Process(os.getpid()).wait(timeout=10)
except psutil.TimeoutExpired:
    pass

我们可以使用/usr/bin/time -v来测量 CPU 上下文切换。补丁之前(忙循环):

$ /usr/bin/time -v python3 test.py 2>&1 | grep context
    Voluntary context switches: 258
    Involuntary context switches: 4

补丁之后(事件驱动方法):

$ /usr/bin/time -v python3 test.py 2>&1 | grep context
    Voluntary context switches: 2
    Involuntary context switches: 1

这表明,进程不会在用户空间中循环,而是阻塞在 poll() / kqueue() 中,只有当内核通知它时才会被唤醒,从而只导致几次 CPU 上下文切换。

睡眠状态

值得注意的是,通过 poll()(或 kqueue())进行等待会使进程进入与直接调用 time.sleep() 完全相同的睡眠状态。从内核的角度来看,两者都是可中断的睡眠:进程会被取消调度,不占用任何 CPU 资源,并静默地驻留在内核空间。

下面 ps 表示的 "S+" 状态表示该进程“在前台休眠”。

  • time.sleep()
$ (python3 -c 'import time; time.sleep(10)' & pid=$!; sleep 0.3; ps -o pid,stat,comm -p $pid) && fg &>/dev/null
    PID STAT COMMAND
 491573 S+   python3
  • poll()
$ (python3 -c 'import os,select; fd = os.pidfd_open(os.getpid(),0); p = select.poll(); p.register(fd,select.POLLIN); p.poll(10_000)' & pid=$!; sleep 0.3; ps -o pid,stat,comm -p $pid) && fg &>/dev/null
    PID STAT COMMAND
 491748 S+   python3

CPython 贡献

在 psutil 实现 ( psutil/PR-2706 ) 之后,我更进一步,为 CPython subprocess 模块提交了一个匹配的拉取请求: cpython/PR-144047 ,将于 2026 年的 Python 3.15 发布。

我对此感到特别自豪:这是 psutil 17 年多历史中第二次有功能被上游 Python 标准库采纳。第一次是在 2011 年,当时 psutil.disk_usage() 启发了shutil.disk_usage() 的开发(参见python-ideas ML 提案)。

graph TD
    A[开始] --> B["Popen()"]
    B --> C[子进程启动<br>独立运行]
    B --> D[主进程继续执行]
    D --> E{需要<br>子进程结果?}
    E -->|否| D
    E -->|是| F["P.wait()"]
    F -->|阻塞等待| G[子进程结束]
    G --> H[拿到 returncode]
    H --> I["可安全读 stdout/stderr<br>(如果用了 PIPE)"]
    I --> J[结束]

有趣的是: 15 年前,Python 3.3 为 subprocess.Popen.wait() 添加了timeout参数(参见提交记录)。我大概就是从那里获得了灵感,在同一时间也为 psutil 的 Process.wait() 添加了timeout参数(参见提交记录)。现在,15 年过去了,我又为同一个timeout参数做出了类似的改进。真是兜了个圈

gmpy.dev/blog/2026/e…

mp.weixin.qq.com/s/8lgKlWjEK…

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com