timeout=2 却等了 15 秒:subprocess 的超时到底杀了谁
timeout=2 却等了 15 秒:subprocess 的超时到底杀了谁
先说结论
subprocess.run(cmd, timeout=2) 里那个 2 秒,只约束你自己的等待,不约束进程树的生命。超时触发时,CPython 在 POSIX 上只做两件事:process.kill() 和 process.wait()。kill 打到的是 Popen 记录的那一个 pid,也就是 sh;sh 派生出来的孙进程完全不受影响,继续跑,继续持有从你这里继承的 stdout/stderr 管道写端。
于是出现了两个层级的症状:内层函数在 2 秒时如约抛出 TimeoutExpired,外层读取管道的那一方却在等 EOF,一直等到孙进程自己退出。超时是准的,返回是慢的。
下面三组数字都是今天在这台机器上实测的(Python 3.12 / Linux 6.17)。
实测一:kill 之后孙进程还在
import subprocess, time
t0 = time.time()
try:
subprocess.run("sh -c 'sleep 20 & sleep 20'", shell=True, timeout=2,
capture_output=True, text=True)
except subprocess.TimeoutExpired:
print("TimeoutExpired after %.1fs" % (time.time() - t0))
print("run() returned at %.1fs" % (time.time() - t0))
输出:
TimeoutExpired after 2.0s
run() returned at 2.0s
看起来一切正常。但紧接着 ps -eo pid,cmd | awk '$2=="sleep" && $3=="20"',两个 sleep 20 都还在:
2947650 sleep 20
2947651 sleep 20
sh 被杀了,sh 的孩子没有。原因很直白:kill() 默认按 pid 发信号,不按进程组。而 sh -c 'a & b' 的语义恰好就是「派生两个孩子」。
实测二:真正的代价在管道上
把上面这段包进另一个 subprocess.run(..., capture_output=True)——也就是任何「跑一个子脚本并读它的输出」的常规写法:
inner = """
import subprocess
try:
subprocess.run("sh -c 'sleep 15 & sleep 15'", shell=True, timeout=2)
except subprocess.TimeoutExpired:
print("inner: timeout at t=2s", flush=True)
"""
t0 = time.time()
r = subprocess.run([PY, "-c", inner], capture_output=True, text=True)
print("outer returned after %.1fs" % (time.time() - t0))
输出:
outer returned after 15.0s | stdout='inner: timeout at t=2s'
内层第 2 秒就打印了 timeout at t=2s,外层却卡满 15 秒——正好是 sleep 15 的寿命。因为那两条 sleep 继承了内层 python 的 stdout,而内层 python 的 stdout 正是外层 capture_output 的管道。孙进程不退出,管道不关闭,读端就不返回。
我第一次撞上这个现象时,timeout 写的是 2 秒,sleep 写的是 300,整条命令被外层的一百八十秒硬超时掐掉,日志里只有半截输出。当时以为是脚本死循环,实际是管道被两个孤儿进程捏着。
实测三:start_new_session + killpg 才关得干净
p = subprocess.Popen("sh -c 'sleep 20 & sleep 20'", shell=True,
start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
p.wait(timeout=2)
except subprocess.TimeoutExpired:
os.killpg(os.getpgid(p.pid), signal.SIGTERM)
p.wait(timeout=5)
输出:
killpg sent at 2.0s
reaped rc=-15 at 2.0s
整个进程组一起收到 SIGTERM,wait 立刻回收,无残留。要点有两个:
start_new_session=True让子进程成为新会话/新进程组的组长(setsid),这样os.getpgid(p.pid)拿到的组里只有它自己和它的后代,killpg不会误伤你自己。- 输出别再走继承来的管道。写到临时文件,或者
DEVNULL。管道是个隐形的引用计数锁:只要还有任何一个后代握着写端,读端就等不到 EOF。
一个可以直接抄的封装
import os, signal, subprocess, tempfile, time
def run_group(cmd, timeout, grace=3.0):
"""跑完就返回;超时按进程组清理,读输出走临时文件(不占用管道)。"""
with tempfile.TemporaryFile(mode="w+") as out:
p = subprocess.Popen(cmd, shell=True, start_new_session=True,
stdout=out, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL, text=True)
try:
rc = p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
pgid = os.getpgid(p.pid)
os.killpg(pgid, signal.SIGTERM) # 先礼
try:
rc = p.wait(timeout=grace)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL) # 后兵
rc = p.wait()
out.seek(0)
raise subprocess.TimeoutExpired(cmd, timeout, output=out.read())
out.seek(0)
return rc, out.read()
SIGTERM 之后留一个 grace 窗口很重要:npm、make、编译器等会自己清理子进程,直接 SIGKILL 会留下 .o 半成品和锁文件。宽限期内没退再 SIGKILL。
三个容易漏的边角
别指望 shell=True + exec 能救所有场景。 sh -c 'exec ffmpeg ...' 确实让 sh 被 exec 替换掉、层级减一,超时就能杀对。但 exec 只对「一条命令」成立,a & b、a | b、a; b 都不行——而这些恰恰是最容易派生出后代的形式。
timeout(1) 命令比自己做进程组管理省事。 timeout -k 5 2 ./job.sh 内部同样用 setpgid + 信号,而且 -k 直接给了「先 TERM 后 KILL」的宽限。能用它就别自己写。
日志里要能看出进程组。 排查时最有用的两条命令是 ps -o pid,ppid,pgid,cmd -p <pid> 和 ps -eo pid,ppid,pgid,etime | awk '$3==<pgid>'。前者告诉你孤儿挂到了谁身上(常见是被 subreaper 收养),后者告诉你组里还剩几个。如果日志只打 pid,你只能看到「超时了」,看不到「超时之后还有东西在跑」。
判超时,别只看异常类型
TimeoutExpired 会如实抛出,所以很多人以为超时逻辑是对的。真正该量的是从抛异常到函数返回的耗时:
t0 = time.time()
try:
run(cmd, timeout=2)
except subprocess.TimeoutExpired:
elapsed = time.time() - t0
if elapsed > 2.5:
log.warning("timeout fired but return lagged %.1fs — 有后代握着管道", elapsed)
这条告警比任何 stack trace 都早地指出问题:它区分了「任务真的超时」和「任务超时了但我还被迫等它跑完」。前者是业务问题,后者是工具链问题,两者的修法完全不同。
- 点赞
- 收藏
- 关注作者
评论(0)