火柴人武林大会
156.74M · 2026-02-04
在 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 模块中。
所有 POSIX 系统都至少提供了一种机制,用于在文件描述符就绪时收到通知。这些机制包括select() 、 poll() 、 epoll() (Linux)和kqueue() (BSD/macOS)系统调用。直到最近,我一直认为它们只能用于引用套接字、管道等的文件描述符,但事实证明它们也可以用来等待进程 PID 上的事件!
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(),是因为它不需要创建额外的文件描述符。它也只需要一次系统调用,这在监控单个文件描述符时比监控多个文件描述符时效率更高。
源自 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
由于 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
在 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…