在运维脚本里跑外部命令,最常见的写法是 subprocess.run(cmd, shell=True, timeout=60)。命令正常结束当然没问题,一旦超时,subprocess.run 会抛 subprocess.TimeoutExpired,同时它内部只对直接子进程调用 kill(),然后 wait() 回收。问题就在这里:被 kill 的只是那个 shell,shell 派生出来的子孙进程(比如 ping、tail -f、ffmpeg)变成了孤儿进程,继续占着 CPU、端口、日志句柄。脚本本身是想通过超时兜底,结果兜底只兜了一半。
本文给出一个可以直接落地的方案:在 POSIX 上用 start_new_session=True 让子进程成为新会话(进程组)的组长,超时后对整组发 SIGTERM,宽限期后再补 SIGKILL,最后用 wait() 回收,确保不留下僵尸进程,也不留下在跑的孙进程。同时给出 Windows 下的替代做法,以及为什么不能无脑写 shell=True。
先看清楚 subprocess.run 在超时那一刻做了什么
Python 3.x 中 subprocess.run 的超时逻辑在 Popen.communicate(timeout=...) 里。超时发生时,它抛出 TimeoutExpired,而 run() 在 finally 块里做的是:
# 摘自 CPython Lib/subprocess.py,run() 的 except 分支(简化)
except TimeoutExpired as exc:
process.kill()
if _mswindows:
exc.stdout, exc.stderr = process.communicate()
else:
process.wait()
raise
process.kill() 在 POSIX 上等价于 send_signal(SIGKILL),目标仅仅是 process.pid 这一个进程。如果 shell=True,这个 pid 是 /bin/sh 的 pid;shell 自己收到 SIGKILL 立即消失,但它 fork 出来的子进程没有收到任何信号。这些子进程的父进程没了,被 init(或 PID 1,容器里是 tini/pause)收养,继续跑下去。
更隐蔽的问题:如果子进程自己又忽略 SIGTERM,或者是一个 setsid 出去的守护进程,那连进程组都抓不住。所以第一层修复是“超时不要只杀一个 pid”,第二层修复是“给子进程单独建一个进程组,让整组可以被一次信号覆盖”。
真实报错长这样:脚本明明设置了
timeout=5,跑完之后ps aux | grep sleep还能看到几条sleep 300,而TimeoutExpired已经被 except 捕获、日志也打印了“超时退出”。这就是只 kill 直接子进程的典型后果。
用 start_new_session 建立独立进程组
POSIX 的 setsid() 让调用进程成为新会话的首进程,同时成为新进程组的组长,进程组 ID 等于该进程 PID。subprocess.Popen(以及 run)提供了 start_new_session 参数,在 fork/exec 之间调用它。这样父进程就能用 os.killpg(pgid, sig) 把信号发给整组。
参数的真实语义(不是猜的,来自 help(subprocess.Popen)):
| 参数 | POSIX 行为 | Windows 行为 |
|---|---|---|
start_new_session=True |
子进程 fork 后调用 setsid(),成为新会话、新进程组组长 |
不支持,传 True 会被忽略(Python 3.7+ 起 CREATE_NEW_PROCESS_GROUP 要自己通过 creationflags 给) |
preexec_fn |
可在 exec 前执行任意函数,但多线程下不安全,官方文档明确建议改用 start_new_session |
不支持 |
process_group=0 |
Python 3.11 新增,把 setpgid(0, 0) 交给 C 层执行,比 preexec_fn 安全 |
不支持 |
creationflags |
无 | 可传 subprocess.CREATE_NEW_PROCESS_GROUP |
更稳的写法是用 Popen 手动控制,因为 run(timeout=...) 在超时后只 kill 单个 pid,拿不到“先 TERM 后 KILL”的宽限期,也无法选择按进程组发送。下面这个函数是本文的核心,可以直接复制进运维脚本:
import os
import signal
import subprocess
import time
def run_with_group_timeout(cmd, timeout=60, grace=5, cwd=None, env=None):
"""
运行命令,超时后终止整个进程组。
cmd: list[str],不要用 shell=True(理由见下文)。
返回 CompletedProcess 风格的 (returncode, stdout, stderr)。
"""
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=cwd,
env=env,
start_new_session=True, # POSIX: setsid(),子进程成为新进程组组长
text=True,
)
try:
out, err = proc.communicate(timeout=timeout)
return proc.returncode, out, err
except subprocess.TimeoutExpired:
# 1) 先礼后兵:向整组发 SIGTERM
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass # 整组已经自己退干净了
try:
out, err = proc.communicate(timeout=grace)
return proc.returncode, out, err
except subprocess.TimeoutExpired:
# 2) 宽限期过了还不退,整组 SIGKILL
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
# 3) 必须 wait,否则留下僵尸(Z 状态)
out, err = proc.communicate()
raise subprocess.TimeoutExpired(cmd, timeout, output=out, stderr=err)
几个细节值得抠一下。第一,os.killpg(proc.pid, ...) 之所以能用 proc.pid 当组 ID,是因为 setsid() 让组长 PID 等于 PGID;如果命令自身又调用了 setpgid 换了组,那这个假设就不成立,但这属于命令自己的自由,脚本层面已经尽力了。第二,ProcessLookupError 必须捕获:组长进程可能已经退出、组内最后一个进程也退出了,此时 killpg 会抛这个异常,不捕获就会把真正的超时异常盖掉。第三,最后的 communicate() 不能省,否则 proc 没有 wait,僵尸进程会挂在当前脚本的父进程(比如 cron 或 systemd 起的进程)下面,时间长了 ps 里一堆 <defunct>。
为什么要“先 TERM 后 KILL”,而不是直接 KILL
直接 SIGKILL 能让进程立刻死掉,但对运维场景有两个副作用:一是被 kill 的进程没机会写日志、flush 缓冲、删除临时文件、释放锁文件(PID file、flock 锁),下一个调度周期可能因为残留锁被卡住;二是像 mysqldump、rsync 这类工具被硬杀后可能留下不完整的目标文件。
SIGTERM 的默认行为是终止进程,但程序可以捕获它做清理。宽限期给 5 秒左右是常见折中:大部分命令行工具收到 TERM 后几十毫秒就退出了;而像 java 进程做 graceful shutdown 可能需要几秒。给 5 秒既不会让脚本卡太久,也给足清理时间。宽限期长度应该小于外层调度(systemd TimeoutStopSec、cron 重入间隔),避免两层超时互相打架。
验证方法很直接:写一个死循环脚本,让它 trap TERM 打印一行再退出,看看日志里有没有那行。
#!/bin/bash
# /tmp/trap_demo.sh
trap 'echo "got TERM at $(date +%T)"; exit 0' TERM
echo "started pid=$$"
while true; do sleep 1; done
import subprocess, time
from myrunner import run_with_group_timeout # 上面的函数
t0 = time.time()
try:
run_with_group_timeout(["bash", "/tmp/trap_demo.sh"], timeout=2, grace=5)
except subprocess.TimeoutExpired as e:
print("timeout, elapsed=%.2fs" % (time.time() - t0))
print("stderr:", e.stderr)
预期输出里能同时看到 started pid=... 和 got TERM at ...,且总耗时在 2 秒出头(不是 2+5 秒)。如果只看到 timeout、没看到 TERM 那行,说明信号没送到组里,通常是 start_new_session=True 漏了,或者 os.killpg 用的不是 proc.pid。
shell=True 会多出一层进程,超时回收更难
shell=True 时,Popen 实际执行的是 /bin/sh -c "你的命令"。sh 为了执行单条命令通常会 exec 优化(把自身替换成目标程序),这时 pid 不变,问题不大。但只要命令里出现管道、&&、重定向组合,shell 就必须 fork 子进程,于是 pid 树变成:脚本 → sh(组长)→ 多个实际命令。有了 setsid 之后 killpg 能覆盖到这一整棵树,这确实是 shell=True 唯一安全的用法。
但更推荐把命令拆成 list,把 shell 语法交给 Python 处理。原因不是洁癖,而是可观测性:参数里的空格、引号、通配符不再经过一次 shell 解析,出错时你能确定是哪个参数错了。管道可以用 Popen 串联,或者干脆两步跑。
# 不推荐:命令字符串拼接,注入与引号问题同时存在
subprocess.run(f"grep -r '{pattern}' {path} | head -n 50", shell=True, timeout=30)
# 推荐:参数列表 + Python 侧截断
r = run_with_group_timeout(["grep", "-r", pattern, path], timeout=30)
lines = r[1].splitlines()[:50]
如果确实要用管道,比如 tar czf - dir | ssh host 'cat > /backup/x.tgz',那就保留 shell=True 并一定带上 start_new_session=True,同时把命令写成显式 /bin/bash -c 的形式,避免不同发行版 sh(dash 与 bash)对语法处理不一致:
proc = subprocess.Popen(
["/bin/bash", "-o", "pipefail", "-c",
"tar czf - /var/log/app | ssh -o BatchMode=yes backup@10.0.0.5 'cat > /backup/app.tgz'"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True, text=True,
)
-o pipefail 很关键:没有它,管道里 tar 失败而 ssh 成功时,返回码是 0,脚本会误判成功。这个坑和超时无关,但在备份类脚本里几乎必踩。
Windows 与跨平台兜底
Windows 没有 POSIX 进程组和 killpg,start_new_session 会被忽略。可行的做法是给 Popen 传 creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,然后 proc.send_signal(signal.CTRL_BREAK_EVENT)。注意 CTRL_BREAK_EVENT 只能发给新进程组,且目标进程必须自己处理该事件;cmd.exe 下的子进程不一定响应。更彻底的办法是调用系统工具:
import os, subprocess, sys
def kill_tree_windows(pid: int) -> None:
# /T 递归终止子进程树,/F 强制
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False)
def run_cross_platform(cmd, timeout=60):
kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True}
if os.name == "posix":
kwargs["start_new_session"] = True
else:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
proc = subprocess.Popen(cmd, **kwargs)
try:
return proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
if os.name == "posix":
os.killpg(proc.pid, signal.SIGKILL)
else:
kill_tree_windows(proc.pid)
return proc.communicate()
还有一个很容易被忽略的运行环境:容器。容器里 PID 1 如果是 bash 脚本或应用本身,它不会像 init 那样回收孤儿进程,被收养的孙进程会长期占着资源,而且 docker stop 只对 PID 1 发 SIGTERM。于是又绕回同一个结论:在被调命令外层加 tini 或 docker run --init,脚本这一层的 killpg 只解决“本次调用超时”的回收,解决不了容器生命周期结束时的进程树清理。
一个完整的运维用例:带超时的批量健康检查
下面这段可以直接当巡检脚本用:并发跑 n 个命令,每个都有独立超时,超时的进程组被清理干净,最后汇总结果。
import os, signal, subprocess, sys
from concurrent.futures import ThreadPoolExecutor, as_completed
def run_group(cmd, timeout):
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, start_new_session=True)
try:
out, err = p.communicate(timeout=timeout)
return {"cmd": cmd, "rc": p.returncode, "out": out.strip(), "timeout": False}
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGTERM)
try:
out, err = p.communicate(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL)
out, err = p.communicate()
return {"cmd": cmd, "rc": None, "out": (out or "").strip(), "timeout": True}
TASKS = [
(["curl", "-sS", "-m", "3", "http://127.0.0.1:8080/healthz"], 5),
(["bash", "-c", "for i in $(seq 1 20); do echo tick $i; sleep 1; done"], 4),
(["df", "-h", "/"], 5),
]
with ThreadPoolExecutor(max_workers=8) as ex:
futs = {ex.submit(run_group, c, t): (c, t) for c, t in TASKS}
for f in as_completed(futs):
r = f.result()
flag = "TIMEOUT" if r["timeout"] else f"rc={r['rc']}"
print(f"[{flag}] {' '.join(r['cmd'])} -> {r['out'][:120]!r}")
第二条任务故意睡眠 20 秒而超时 4 秒,跑完以后执行 ps -ef | grep 'sleep 1' 应该是空的。如果有残留,按顺序检查三件事:start_new_session 是否真的生效(ps -o pid,pgid,sess,cmd -p <pid> 看 PGID 是否等于 PID)、os.killpg 是否在 try 里被 except PermissionError 之类吞掉、子进程是否自己 setsid 跑了。
常见问题 FAQ
捕获 TimeoutExpired 之后脚本还是报 ResourceWarning: subprocess is still running
说明 proc.wait() 或 communicate() 没被调用到。异常路径里只在 kill 之后直接 return,Popen 对象被 GC 时 Python 会打印这条警告。修法是在 finally 里统一做 proc.wait(timeout=...),或者保证每条分支最后都调用 communicate()。打开 python -W error::ResourceWarning 能把它变成硬错误,便于 CI 阶段抓出来。
os.killpg 抛 PermissionError: [Errno 1] Operation not permitted
目标进程组不是当前用户创建的,或脚本被 setuid 降权过。运维脚本一般以 root 或同一服务用户运行,跨用户杀进程会失败。此时只能退化为 proc.kill() 并记录日志,别让异常冒泡盖掉原始超时信息。
异常信息里 output 和 stderr 是 None
TimeoutExpired.output 只有在 communicate() 之前已经读到部分输出时才有值。若命令超时前什么都没输出,这里就是 None。想拿到“超时前已经产生的那部分 stdout”,需要在超时处理里再调用一次 proc.communicate() 拿到完整结果(本文函数就是这么写的,并在 TimeoutExpired 里带上了 output/stderr)。
Python 3.11 起 preexec_fn 被标为不安全,应该换成什么
换成 start_new_session=True 或 process_group=0。官方文档明确说 preexec_fn 在多线程程序中可能死锁(fork 后子进程只应调用 async-signal-safe 的函数,而 Python 层会拿 GIL 和锁)。本文的 start_new_session 走的是 C 层 setsid(),没有这个问题。
子进程是 daemon 化的(自己 fork 两次再 setsid)
脚本层面杀不到。这类进程必须由启动它的一方通过 PID 文件或 systemd KillMode=control-group 管理。写巡检脚本时,如果某条命令是 nohup xxx & 或 systemctl start,不要指望 timeout 能兜住它的子孙,直接改成同步命令或查 systemd 状态。
timeout 参数单位是什么,是秒还是毫秒
subprocess.run/Popen.communicate 的 timeout 是秒,类型 float。可以写 timeout=0.5。不要传 datetime.timedelta,会抛 TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta',因为内部直接与 time.monotonic() 返回值做算术。
小结
超时控制这件事,subprocess.run(timeout=...) 只完成了“父进程不再等待”,没有完成“进程树被回收”。要补齐后半段,记住三条:给子进程 start_new_session=True 建立独立进程组;超时后用 os.killpg(proc.pid, SIGTERM) 整组发信号,宽限期后 SIGKILL;无论成败都要 wait()/communicate() 把僵尸收掉。另外尽量不用 shell=True,非要管道就用 /bin/bash -o pipefail -c 并保留进程组设置。容器里再加 --init 或 tini 兜底,整条链才算闭环。