timeout=2 却等了 15 秒:subprocess 的超时到底杀了谁

举报
yd_276135645 发表于 2026/11/09 06:13:58 2026/11/09
【摘要】 subprocess.run(timeout=2) 里那个 2 秒只约束你的等待,不约束进程树的生命。本文用三组实测数字说明:超时只 kill 直接子进程、孙进程握着 stdout 管道让读端一直等到它退出,以及 start_new_session + killpg + 临时文件的正确封装写法,附三个容易漏的边角(sh -c exec 的局限、timeout -k、日志里要打 pgid)。

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 立刻回收,无残留。要点有两个:

  1. start_new_session=True 让子进程成为新会话/新进程组的组长(setsid),这样 os.getpgid(p.pid) 拿到的组里只有它自己和它的后代,killpg 不会误伤你自己。
  2. 输出别再走继承来的管道。写到临时文件,或者 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 都早地指出问题:它区分了「任务真的超时」和「任务超时了但我还被迫等它跑完」。前者是业务问题,后者是工具链问题,两者的修法完全不同。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。