很多 Python 项目会用 subprocess 启动外部命令:调用脚本、压缩工具、浏览器、编译器,或者一个负责继续派生任务的工作进程。为了避免命令卡住,最常见的写法是在 subprocess.run 中加 timeout。
问题出在一个容易被忽略的细节:超时以后,Python 确实结束了它直接启动的那个进程,但这个进程此前再启动的子进程,不一定跟着退出。父进程已经不在了,后台工作进程还在占 CPU、占端口、持有文件句柄,甚至继续执行本来应该被取消的任务。
我在 Windows 11、Python 3.13.13 上做了一个三层进程实验。设置一秒超时后,subprocess.run 抛出 TimeoutExpired,直接子进程已经退出,但它派生的孙进程仍然存活。随后用 psutil 按进程树清理,父进程和后代进程才全部结束。
这不是 timeout 参数失效,也不是 Python 忘记杀进程。关键在于:subprocess 管理的是一个 Popen 对象,而不是自动托管整棵进程树。把这两个范围混为一谈,才会出现"命令明明超时了,任务怎么还在跑"的问题。
一、先区分超时对象:不是整棵进程树
先把关系画清楚。假设主程序启动了一个外部命令,外部命令又启动一个长期工作的子进程,结构就是:
text
Python 主程序
└── 外部命令(直接子进程)
└── 工作进程(孙进程)
主程序调用 subprocess.run 时,Python 创建并管理的是外部命令对应的 Popen 实例。这个实例保存直接子进程的进程句柄、参数、标准输入输出管道和退出码。它并没有因为这个进程又启动了其他程序,就自动获得那些后代进程的完整生命周期管理权。
因此,"终止直接子进程"和"终止进程树"是两件事。前者针对一个进程对象;后者需要先找到它的后代,再按合适的顺序发出终止请求,并等待它们真正退出。操作系统不会因为父进程退出,就替应用程序推断所有后代也应该退出。子进程可能已经独立运行,也可能继续持有父进程创建时继承的句柄。
这个区别在简单命令里不明显。比如一个只运行几秒、不会派生其他程序的命令,结束直接子进程通常就够了。但只要外部程序会再启动 worker、浏览器、shell、下载器或其他辅助进程,超时处理就必须考虑整个进程树。
还有一个很容易踩中的场景是 shell=True。此时 Python 可能先启动一个 shell,再由 shell 启动真正的命令。Python 所持有的直接子进程可能只是 shell;如果 shell 派生出的实际程序没有随着它退出,单纯结束 shell 并不等于结束了实际任务。能直接传参数列表时,优先使用 shell=False,减少不必要的中间层,但这仍不能阻止目标程序自己继续派生进程。
二、Python 3.13 的源码只调用了一次 kill
先看官方文档的承诺。Python 3.13 的 subprocess.run 文档说明:指定 timeout 后,如果时间到期,子进程会被终止并等待,然后重新抛出 TimeoutExpired。这里的 child process 指本次 Popen 创建的那个直接子进程,并没有承诺递归结束它派生出的所有后代。
对应的 CPython 3.13 源码也很直接。run 内部通过 Popen.communicate 等待;捕获 TimeoutExpired 后调用 process.kill(),再完成等待和管道收尾。它没有在这条路径里遍历进程列表,也没有调用系统级进程树管理接口。换句话说,代码执行的是对当前 Popen 对象所代表进程的一次终止操作,而不是对子孙进程递归执行 kill。
这里还要区分 run 和 Popen.communicate 两种写法。直接使用 Popen.communicate(timeout=...) 时,官方文档明确提醒:超时本身不会替调用方杀掉子进程。调用方需要捕获 TimeoutExpired,主动终止进程,再次 communicate 完成管道收尾。subprocess.run 是更高一层的便捷封装,它会替你处理直接子进程的终止;但它没有把进程树托管也一并包进来。
Windows 和 POSIX 的底层终止方式不同,范围却有相同的限制。Windows 上 Popen.kill 是 terminate 的别名,最终调用 TerminateProcess 结束指定进程;POSIX 上 kill 对应向指定 PID 发送 SIGKILL。两者都不是"根据父子关系递归清理全部后代"的系统调用。
所以,不要把文档里的"子进程会被终止"扩展理解成"这个命令启动的一切进程都会被终止"。如果你的程序只启动一个独立进程,这个行为很合适;如果它启动的是一棵进程树,就需要额外的生命周期边界。
源码和文档可以直接对照:
- Python 3.13 subprocess 文档:docs.python.org/3.13/librar...
- CPython 3.13 subprocess.py:github.com/python/cpyt...
- Windows Popen.kill 的行为说明也见上述文档中的 Popen.kill 与 Popen.terminate 小节。
三、用三层进程在本机复现
为了排除"刚好进程启动太慢"或"PID 读取错了"的干扰,我让父进程先把自己的 PID 写入文件,再启动孙进程;孙进程也把自己的 PID 写入另一个文件。两层进程都进入长时间 sleep,主程序只给 subprocess.run 一秒钟。
实验不依赖网络、外部服务或随机负载。父进程和孙进程执行的是同一台机器上的 Python 解释器,超时阈值固定为一秒。读取 PID 后,再用 psutil.pid_exists 分别检查直接子进程和孙进程是否仍存在。检查完成后,实验脚本会主动结束留下的孙进程,避免把测试进程留在系统里。
下面是实验的核心结构,省略了临时目录创建和结果文件读取部分:
python
import subprocess
import sys
from subprocess import TimeoutExpired
grandchild_code = (
"import os,sys,time; "
"open(sys.argv[1], 'w').write(str(os.getpid())); "
"time.sleep(20)"
)
parent_code = (
"import os,subprocess,sys,time; "
"open(sys.argv[2], 'w').write(str(os.getpid())); "
"subprocess.Popen([sys.executable, '-c', "
+ repr(grandchild_code) + ", sys.argv[1]]); "
"time.sleep(20)"
)
try:
subprocess.run(
[sys.executable, "-c", parent_code,
grandchild_pid_file, parent_pid_file],
timeout=1,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except TimeoutExpired:
print("timeout")
这段实验只回答一个问题:timeout 结束以后,两个 PID 的状态是否相同。实际在本机运行时,直接子进程的 PID 已经不存在,孙进程 PID 仍然存在。随后对孙进程执行一次显式清理,再检查一次,PID 才消失。
本机环境为 Windows 11 10.0.26200、Python 3.13.13、psutil 7.2.2。测试脚本读取了本机 subprocess.run 的实际源码位置,并在同一环境中重复执行了两种清理路径。这里的结果是一次本地可复现实验,不是性能基准,也不代表所有外部程序都会派生孙进程。
四、最小修复:在父进程退出前收集后代
如果只是把 subprocess.run 改成 Popen,然后在超时后调用 proc.kill(),问题仍然存在。因为 proc.kill() 还是只针对 Popen 对应的直接子进程。正确的处理顺序应该是:先取得直接子进程的进程对象,在它仍然存在时读取 descendants,再从后代开始逐个终止,最后终止根进程,并等待全部目标退出。
psutil 提供了 Process.children(recursive=True) 来读取全部后代,wait_procs 可以批量等待,并返回仍然存活的进程。下面是一个面向一般工具调用的清理函数:
python
import subprocess
import psutil
from subprocess import TimeoutExpired
def stop_process_tree(proc, grace=2):
root = psutil.Process(proc.pid)
descendants = root.children(recursive=True)
# 先结束后代,再结束根进程
targets = list(reversed(descendants))
targets.append(root)
for item in targets:
try:
item.terminate()
except psutil.NoSuchProcess:
pass
_, alive = psutil.wait_procs(targets, timeout=grace)
# 宽限期结束仍未退出的进程,再升级为 kill
for item in alive:
try:
item.kill()
except psutil.NoSuchProcess:
pass
_, alive = psutil.wait_procs(alive, timeout=grace)
if alive:
raise RuntimeError("process tree still alive")
def run_with_tree_timeout(args, timeout):
proc = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
return proc.communicate(timeout=timeout)
except TimeoutExpired:
stop_process_tree(proc)
proc.communicate()
raise
这里有几个不能省略的细节。第一,必须在根进程仍可访问时收集后代。不要先 proc.kill(),再尝试通过已经退出的根 PID 查找孙进程;父进程消失后,进程树的父子关系可能已经无法从原根进程恢复。psutil 文档也明确提醒:如果树中的中间进程先消失,它的后代可能不再通过原根进程的 children(recursive=True) 返回。
第二,终止后代后仍要等待。terminate 只是发出终止请求,不等于进程已经退出。wait_procs 返回的 alive 列表才是宽限期后仍未结束的对象;只对这部分升级为 kill,随后再次等待。若仍有进程存活,应将清理失败作为真实错误上报,而不是记录为"超时处理成功"。
第三,进程树快照不是绝对的强约束。如果根进程在枚举过程中继续派生新进程,刚刚得到的 descendants 列表可能漏掉新成员。psutil 的方案适合一般受控工具、测试任务和短生命周期 worker;对于不可信或会不断派生进程的程序,不能把一次快照当成完整的隔离边界。
五、Windows 要求强约束时,用 Job Object
如果业务要求"任务超时后,不能留下任何由它启动的进程",Windows 原生的 Job Object 更接近这个目标。Job Object 可以把一组进程作为一个单元管理。进程加入 Job 后,默认情况下它通过 CreateProcess 创建的子进程也会加入同一个 Job,除非配置了允许脱离的限制。
其中一个关键标志是 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE。设置后,当最后一个 Job 句柄关闭时,系统会终止仍关联在这个 Job 中的进程。这样,清理动作不再依赖程序逐个记住所有后代 PID,而是由系统维护这组进程的成员关系。
但它不是给任意 Popen 对象补一行参数就能自动启用的功能。程序需要创建 Job Object、设置扩展限制、把目标进程分配进去,并保证子进程没有通过 breakaway 机制脱离。为了避免目标进程在分配 Job 之前就派生后代,严格场景还要考虑创建进程时的挂起、分配 Job、再恢复执行这一顺序。现有进程也可能已经处在其他 Job 中,分配操作需要处理失败情况。
微软文档还说明,Job Object 的默认继承关系会受 JOB_OBJECT_LIMIT_BREAKAWAY_OK 和 JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK 等设置影响。因此不能只看到代码调用了 AssignProcessToJobObject,就假设整棵树永远不会脱离。对运行外部程序的服务、CI 执行器和任务沙箱,应该把 Job 的配置、分配时机和 breakaway 策略作为一个整体验收。
官方说明:learn.microsoft.com/zh-cn/windo...
六、POSIX 的进程组方案不能照搬到 Windows
Linux、macOS 等 POSIX 系统可以采用另一种思路:启动时让目标进程进入独立 session 或 process group,超时后对整个进程组发送信号。Python 的 Popen 支持 start_new_session=True;之后可以使用 os.killpg 对对应进程组发送 SIGTERM,宽限期后再发送 SIGKILL。
python
import os
import signal
import subprocess
from subprocess import TimeoutExpired
proc = subprocess.Popen(
["your-command", "--arg"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
proc.communicate(timeout=10)
except TimeoutExpired:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.communicate(timeout=2)
except TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.communicate()
raise
这条路径只适用于 POSIX,不要把它直接搬到 Windows。Windows 的 CREATE_NEW_PROCESS_GROUP 主要用于控制台事件,例如向进程组发送 CTRL_BREAK_EVENT;它不等同于 Windows Job Object,也不会让 Popen.kill 自动递归结束整棵进程树。
进程组也不是无条件的隔离。后代如果主动创建新 session 或切换进程组,就可能脱离原组。因而,选择进程组方案时,要确认被调用工具不会主动 daemonize 或重新分组;如果无法控制被调用程序的行为,仍应考虑系统级的进程容器机制。
七、生产代码里还要补上的三个边界
第一,管道句柄可能让你误判清理结果。若 stdout 或 stderr 使用 PIPE,孙进程可能继承这些句柄。即使直接子进程已经退出,孙进程仍持有管道写端,父程序的 communicate 也可能继续等待读取结束。因此,进程树清理完成后再做 communicate 收尾;如果输出量大或不需要捕获输出,优先考虑 DEVNULL 或写入文件,避免把大量输出长期缓存在内存里。
第二,PID 会复用,不能把一个整数 PID 当成永久身份。进程可能在两次检查之间退出,系统随后把同一个 PID 分配给另一个进程。应尽量使用 psutil.Process 对象,并在操作前处理 NoSuchProcess、AccessDenied 等异常;清理后还要验证目标进程是否真的消失。权限不足时不能把"无法访问"记成"已经结束"。
第三,超时与业务取消要分开设计。超时表示等待超过上限,取消表示业务不再需要这些任务。一个任务可能在超时前已经提交了外部副作用,直接杀进程并不能撤回已经写入数据库、发送出去的请求或创建的云资源。对于有副作用的工具调用,还要设计幂等键、取消协议和结果核对;进程退出只说明本地执行载体结束,不代表远端业务已经回滚。
还要留意清理顺序。通常先给进程一个短暂的优雅退出机会,再升级为强制终止;但如果任务本身不可信、可能继续创建后代,优雅等待不能无限延长。宽限时间、强制终止和最终存活检查都应成为明确的执行记录。最重要的是,不能在清理失败时吞掉异常后继续启动下一轮任务,否则残留进程会与新任务争抢端口、文件、显存或账号会话。
八、结论:超时是等待策略,不是进程树托管
这次实验最重要的结论不是"subprocess.run 有 bug",而是要准确理解它管理的对象。run 的 timeout 会处理它直接创建的进程;它不会自动替调用方接管所有后代。直接使用 Popen.communicate 时,超时后的终止和管道收尾也需要调用方明确完成。
如果外部命令确定不会派生其他进程,subprocess.run 加 timeout 通常足够。如果程序会启动 worker 或 shell,应该使用 Popen,在根进程仍然存在时收集后代并清理,再等待和验证。普通受控任务可以用 psutil;POSIX 可以评估独立进程组;Windows 上需要强约束时,Job Object 更适合做系统级生命周期边界。
我在本机复现了"直接子进程已退出、孙进程仍存活"的状态,也验证了按进程树终止后两者都退出。这个实验只证明了当前环境下的机制,不应被误写成所有程序的性能结论。真正上线前,还需要把持续派生、权限不足、管道继承、进程脱离和远端副作用纳入自己的测试矩阵。
如果你维护 Windows 上的任务执行器,遇到外部工具会派生多层进程的场景,你更倾向于用 psutil 做显式进程树清理,还是在启动时就把任务放进 Job Object?前者接入成本低,后者的系统级约束更强;你会把哪一种作为默认方案?