用 subprocess.run 的 timeout 处理超时子进程并回收其子孙进程组

在运维脚本里跑外部命令,最常见的写法是 subprocess.run(cmd, shell=True, timeout=60)。命令正常结束当然没问题,一旦超时,subprocess.run 会抛 subprocess.TimeoutExpired,同时它内部只对直接子进程调用 kill(),然后 wait() 回收。问题就在这里:被 kill 的只是那个

在运维脚本里跑外部命令,最常见的写法是 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 兜底,整条链才算闭环。