← 返回首页

Python subprocess 踩坑复盘:退出码 0 却拿不到输出的 7 个原因

Pythonsubprocess排障

调子进程这件事,真正的麻烦不是它报错,是它不报错。

下面这七个场景我全部在本机跑了一遍:没有一处抛异常到调用方,退出码清一色是 0(或者一个看起来很正常的非零值),而你要么拿不到输出,要么拿到的是空值,要么进程根本没死干净。

TL;DR:subprocess 的失败模式大多是「静默」的 —— 管道不排空会卡死、解码异常被吞进内部线程、check_output 默认不带 stderr、timeout 只杀直系子进程。七条里没有一条需要你改业务逻辑,全部是调用姿势问题,且每条都有可复制的修法。

本文不含任何联盟推广链接,所有数字均为本机实测或引自官方文档与 CPython 源码。

环境口径与复现方式

项值
系统Windows 11(sys.platform = win32,os.name = nt)
解释器CPython 3.13.14(安装目录名 3.13.12)
标准库Lib/subprocess.py,本机路径 C:\Users\xuany\.workbuddy\binaries\python\versions\3.13.12\Lib\subprocess.py
官方文档docs.python.org/3/library/subprocess.html(2026-10-29 访问,页面标注版本为 3.15.2)
Windows APIMicrosoft Learn GenerateConsoleCtrlEvent(2026-10-29 访问)

本文所有结论分两类标注:实测表示我在上面这套环境里跑出来的结果;官方文档或源码表示逐字引用的原文。凡是两边都对不上的地方,我会明确写出来,不会替它圆。

顺手记一组本机编码口径,后面坑二要用:

>>> import locale
>>> locale.getencoding()                    # 'cp936'
>>> locale.getpreferredencoding(False)      # 'utf-8'

这两个函数在同一台机器上给的是两个不同的答案,这一点本身就是坑。

坑一:`wait()` 会卡死,而 `kill()` 会把还没读走的输出一起带走

官方文档对 Popen.wait() 的警告是逐字的:

> This will deadlock when using stdout=PIPE or stderr=PIPE and the child process generates enough output to a pipe such that it blocks waiting for the OS pipe buffer to accept more data. Use Popen.communicate() when using pipes to avoid that.

实测(子进程写 100000 字节到管道):

p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*100000)"],
                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.wait(timeout=6)
# subprocess.TimeoutExpired: Command '[...]' timed out after 6 seconds

更糟的是超时之后的处理。我试了四种读法,read() / peek()+read() / read1() / communicate(),拿到的都是 0 字节:

no-wait        -> kill 后 read() = 0 字节
wait(timeout)  -> kill 后 read() = 0 字节

为了确认这不是「本来就没数据」,我在 kill 之前先读了一次:

kill 之前 read1(4096) = 4096 字节   <-- 数据确实在管道里
kill 之后 read()      = 0 字节

也就是说:TerminateProcess() 之后,匿名管道里没被读走的那些字节在本机实测中拿不回来了。我在官方文档里没有找到对这一现象的直接说明,所以这里只陈述实测结果,不下普适结论。

修法有两个,任选其一:

一是别用 wait(),用 communicate()。同样是超时路径,它把 100000 字节原样拿回:

p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*100000)"],
                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = p.communicate(timeout=20)
# 实测:len(out) == 100000,returncode == 0

二是把输出重定向到文件,wait() 就能安全用:

with open("out.txt", "wb") as f:
    p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*300000)"],
                         stdout=f, stderr=subprocess.DEVNULL)
    rc = p.wait(timeout=30)
# 实测:rc == 0,文件大小 300000 字节

要记住的一点:kill() 不只是「结束进程」,它同时销毁你还没来得及读的诊断证据。用它处理超时之前,先确认你不需要那些输出。

坑二:`text=True` 的默认编码不是你想的那个,解码失败会被吞掉

这是七个坑里最隐蔽的一个,因为它不抛异常。

在 Windows 上,cmd.exe 的输出用的是控制台代码页,不是 UTF-8。实测同一条命令:

r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True)
r.stdout
# b'\xd6\xd0\xce\xc4\xb2\xe2\xca\xd4"\r\n'      <- GBK 字节
r.stdout.decode("utf-8")
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte

那加上 text=True 会怎样?按常理应该抛出上面那个 UnicodeDecodeError。实测:

r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True, text=True)
r.returncode   # 0
r.stdout       # None
r.stderr       # ''

退出码是 0,stdout 是 None,一个异常都没有。 真正的报错被刷到了 stderr 上,来自一个内部线程:

Exception in thread Thread-1 (_readerthread):
  File "...\Lib\subprocess.py", line 1615, in _readerthread
    buffer.append(fh.read())
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte

原因在源码里。本机 subprocess.py 第 1614–1616 行:

def _readerthread(self, fh, buffer):
    buffer.append(fh.read())
    fh.close()

这个线程里的异常不会传播回 communicate(),run() 也就不知道出过事,只能看见 buffer 是空的。而报错里的 codec 是 utf-8 —— 对应 locale.getpreferredencoding(False)(本机返回 utf-8),不是 locale.getencoding()(本机返回 cp936)。文档里的说法与实测一致:

> If *encoding* or *errors* are specified, or *text* (also known as *universal_newlines*) is true, the file objects *stdin*, *stdout* and *stderr* will be opened in text mode using the *encoding* and *errors* specified in the call or the defaults for io.TextIOWrapper.

修法:永远显式写 encoding 和 errors,并且对 stdout 做空值兜底。

r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True,
                   text=True, encoding="gbk")
r.stdout            # '中文测试"\n'

# 不确定子进程用什么编码时,宁可要替换字符也不要 None
r = subprocess.run(cmd, shell=True, capture_output=True,
                   text=True, encoding="utf-8", errors="replace")
text = (r.stdout or "")

errors="replace" 的代价是拿到 '���IJ���',但至少你知道出事了;None 会让你以为一切正常。

坑三:`check_output()` 的错误信息根本不在异常里

try:
    subprocess.check_output([sys.executable, "-c",
        "import sys;sys.stderr.write('FATAL: db down\\n');sys.exit(1)"])
except subprocess.CalledProcessError as e:
    print(e.output, e.stderr, e.stdout)
# 实测:b'' None b''

output 是空的,stderr 是 None。子进程那句 FATAL: db down 去哪了?它顺着文件描述符直接漏进了父进程的 stderr —— 也就是说,contextlib.redirect_stderr() 抓不到它(实测捕获结果为空字符串),日志框架也收不到它。

官方文档只给了一句提示:要连 stderr 一起拿,得显式合并:

try:
    subprocess.check_output(cmd, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
    print(e.output)
# 实测:b'FATAL: db down\r\n'

坑四:`CalledProcessError` 的字符串里没有子进程说了什么

接上一条,就算你捕获了异常,直接打印也看不到原因。本机源码第 145–155 行写得很清楚:

def __str__(self):
    if self.returncode and self.returncode < 0:
        ...
    else:
        return "Command '%s' returned non-zero exit status %d." % (
            self.cmd, self.returncode)

实测输出:

Command '['C:\\...\\python.exe', '-c', "import sys;sys.stderr.write('真实原因:端口 5432 被占用\\n');sys.exit(3)"]' returned non-zero exit status 3.

完整的命令行、一个退出码,没有子进程的一句话。原因只能在 e.stderr 里:

except subprocess.CalledProcessError as e:
    raise RuntimeError(f"{e.cmd[:1]} 失败(rc={e.returncode}):{(e.stderr or b'').decode('utf-8','replace')}")

顺便一提:源码第 148 行的 died with %r 分支走的是 signal.Signals(-self.returncode),只在 returncode < 0 时才进 —— 而在 Windows 上这个分支永远进不去,原因见坑七。

坑五:`shell=True` 会吞掉「程序不存在」,而且 Windows 上的 shell 是 cmd.exe

同一个命令,两种写法的结果完全不同:

subprocess.run(["definitely-not-a-real-command-xyz"], check=True, capture_output=True)
# FileNotFoundError: [WinError 2] 系统找不到指定的文件。

r = subprocess.run("definitely-not-a-real-command-xyz", shell=True,
                   capture_output=True, text=True, encoding="gbk", errors="replace")
r.returncode   # 1
r.stderr       # "'definitely-not-a-real-command-xyz' 不是内部或外部命令,也不是可运行的程序\n或批处理文件。"

加了 shell=True,「程序不存在」从异常变成了退出码 1。你不再能看到栈,只能看到一个数字,而 check=True 之外的调用连这个数字都不会有人看。

第二个问题:Windows 上那个 shell 是 cmd.exe,不是 bash。官方文档原文:

> On Windows with shell=True, the COMSPEC environment variable specifies the default shell. The only time you need to specify shell=True on Windows is when the command you wish to execute is built into the shell (e.g. dir or copy).

实测三条:

echo $(echo hi)    -> rc=0 stdout='$(echo hi)'      <- 不做命令替换
echo `echo hi`     -> rc=0 stdout='`echo hi`'       <- 不做反引号替换
echo a; echo b     -> rc=0 stdout='a; echo b'       <- 分号不是分隔符
echo a && echo b   -> rc=0 stdout='a \nb'           <- && 是分隔符

第三条尤其危险:它不报错,只是把 echo b 当成了 echo 的参数。你以为跑了两条命令,实际只跑了一条半。

另外注意 shell=True 时退出码来自 shell 本身,文档原话:

> When shell=True, the return code reflects the exit status of the shell itself (e.g. /bin/sh), which may map signals to codes such as 128+N.

修法:能用列表就用列表,别用字符串;能用 shell=False 就别开 shell。这条同时也是安全建议,官方文档把它单独列成一节:

> If the shell is invoked explicitly, via shell=True, it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities.

坑六:`timeout` 只杀直系子进程,孙进程照活

subprocess.run(timeout=...) 超时之后,被杀掉的是你启动的那个进程。它自己拉起来的进程不在名单上。

实测:父进程启动一个孙进程后进入 sleep(120),父进程被 timeout=3 杀掉,孙进程状态如下:

孙进程 PID = 12224 | marker 已写出 = True
kill 父进程前:父 alive = True  | 孙 alive = True
kill 父进程后:父 alive = False | 孙 alive = True

官方文档对 Popen.communicate() 的超时也是同样的口径:

The child process is not killed if the timeout expires, so in order to cleanup properly a well-behaved application should kill the child process and finish communication:

配套的推荐写法是 proc.kill() 之后再 communicate() 一次。注意 run() 与 communicate() 在这里行为不同 —— 文档原话:

> If the timeout expires, the child process will be killed and waited for. The TimeoutExpired exception will be re-raised after the child process has terminated.

Windows 上的两种修法:

一是外部命令整树清理:

subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
# 实测输出:成功: 已终止 PID 12224 (属于 PID 25320 子进程)的进程。

二是启动时就建进程组,然后发 CTRL+BREAK:

p = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1,
                     creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
...
p.send_signal(subprocess.signal.CTRL_BREAK_EVENT)
# 实测:父 alive = False | 孙 alive = False

这里有个容易踩反的细节。Microsoft Learn 对 GenerateConsoleCtrlEvent 的原文:

> CTRL_C_EVENT 0 ... This signal cannot be limited to a specific process group. If *dwProcessGroupId* is nonzero, this function will succeed, but the CTRL+C signal will not be received by processes within the specified process group.

也就是说,发 CTRL_C_EVENT 时函数返回成功,但组内进程一个都收不到。要整组退出必须用 CTRL_BREAK_EVENT。

顺带一个同族的静默失效:start_new_session 与 process_group 在文档里都标着 (POSIX only)(本机源码第 795、797 行原文如此),但在 Windows 上既不报错也不生效:

p = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(2)"],
                     start_new_session=True)
# 实测:没报错,returncode = 0

你以为自己做了进程组隔离,实际什么都没发生。

坑七:Windows 上没有「被信号杀死」这回事,`-1` 是 4294967295

subprocess.run([sys.executable, "-c", "import sys;sys.exit(-1)"]).returncode
# 实测:4294967295
subprocess.run("cmd /c exit -1", shell=True).returncode
# 实测:4294967295

文档对负值的定义带了一个限定:

> A negative value -N indicates that the child was terminated by signal N (POSIX only).

所以在 Windows 上,任何 if rc < 0: 的判断永远不会成立,任何 if rc == -9: 的判断也永远不会命中。而 check=True 只判 != 0,所以这类代码看起来一切正常,只是你永远读不到「被信号杀死」这个语义。

顺带两个实测边界值:sys.exit(300) → returncode = 300(原样返回,没有被截成低 8 位);sys.exit(0) → 当然是 0。

排错:六个真实报错与可复制修法

下面六条全部是本机复现,命令可直接复制。

报错一:`subprocess.TimeoutExpired: Command '[...]' timed out after 6 seconds`

p = subprocess.Popen([sys.executable, "-c", "import sys;sys.stdout.write('x'*100000)"],
                     stdout=subprocess.PIPE, stderr=subprocess.PIPE)
p.wait(timeout=6)

原因:子进程把管道写满后阻塞,父进程从不排空,双方互等。文档对该方法的警告是逐字的(见坑一)。

解决:把 wait() 换成 communicate(),或把 stdout 重定向到文件。

报错二:`UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte`

r = subprocess.run("cmd /c echo 中文测试", shell=True, capture_output=True, text=True)
# returncode = 0,stdout = None,异常只出现在 stderr 的 _readerthread 回溯里

原因:text=True 未指定 encoding 时按 io.TextIOWrapper 默认编码解码(本机 locale.getpreferredencoding(False) = utf-8),而 cmd.exe 输出的是 GBK 字节;解码发生在 subprocess.py:1615 的读取线程里,异常不向上传播。

解决:显式传 encoding=(与子进程一致)加上 errors="replace",并把 r.stdout or "" 作为兜底。

报错三:`subprocess.CalledProcessError: Command '[...]' returned non-zero exit status 1.`

subprocess.check_output([sys.executable, "-c",
    "import sys;sys.stderr.write('FATAL: db down\\n');sys.exit(1)"])
# e.output = b'',e.stderr = None

原因:check_output() 默认只捕 stdout;子进程的 stderr 顺着 fd 2 漏进了父进程的 stderr。

解决:加 stderr=subprocess.STDOUT,错误信息才会出现在 e.output 里。

报错四:`FileNotFoundError: [WinError 2] 系统找不到指定的文件。`

subprocess.run(["definitely-not-a-real-command-xyz"], check=True, capture_output=True)

原因:shell=False 时第一个元素被当作可执行文件路径去找。

解决:这其实是正确行为,别用 shell=True 把它消掉 —— 那只会让异常变成退出码 1(实测见坑五),排查成本更高。

报错五:超时之后进程仍在跑(`tasklist` 可见)

kill 父进程后:父 alive = False | 孙 alive = True

原因:timeout 只终止你直接启动的那个进程。

解决:taskkill /F /T /PID ,或 CREATE_NEW_PROCESS_GROUP + send_signal(CTRL_BREAK_EVENT)。

报错六:`returncode = 4294967295`

subprocess.run([sys.executable, "-c", "import sys;sys.exit(-1)"]).returncode
# 4294967295

原因:Windows 退出码是无符号 32 位;文档对负值 -N 的定义标注为 (POSIX only)。

解决:不要写 if rc < 0 判断「被信号杀死」;跨平台代码里改用 rc != 0 加显式枚举。

一份可以直接抄的安全调用模板

把上面七条合起来,日常调用可以固定成这个形状:

import subprocess, sys

def run_safe(cmd, *, timeout=30, encoding="utf-8"):
    """返回 (ok, text)。cmd 用列表;需要 shell 时显式传入字符串并自己负责转义。"""
    try:
        r = subprocess.run(
            cmd,
            stdin=subprocess.DEVNULL,      # 别让子进程等你输入
            capture_output=True,           # 走 communicate(),不走 wait()
            timeout=timeout,               # 超时会被 run() 杀掉并重新抛出
            text=True,
            encoding=encoding,
            errors="replace",              # 宁可要替换字符,也不要 None
        )
    except subprocess.TimeoutExpired as e:
        # 文档:run() 超时会杀掉子进程并重抛;孙进程需另行清理
        return False, f"超时 {e.timeout}s,部分输出:{(e.output or b'')[:200]!r}"
    except FileNotFoundError:
        return False, f"程序不存在:{cmd[0] if isinstance(cmd, list) else cmd}"
    out = (r.stdout or "").strip()
    err = (r.stderr or "").strip()
    if r.returncode != 0:
        return False, f"rc={r.returncode} stderr={err or '(空)'}"
    return True, out

ok, info = run_safe([sys.executable, "-c", "print('ok')"])
print(ok, info)

三条不变式,建议当代码评审的检查项:

1. 列表传参,shell=False;确实要 shell 时,字符串与转义责任你自己承担。

2. 显式 encoding + errors="replace",别裸用 text=True。

3. 永不 wait() 配 PIPE,只用 communicate() 或文件重定向;需要超时清理进程树时另写一层。

FAQ

Q:communicate() 超时之后为什么不能直接 wait()?

官方文档的原文是:「After a call to communicate() raises TimeoutExpired, do not call wait(). Use an additional communicate() call to finish handling pipes and populate the returncode attribute.」管道还没排空,wait() 会重新回到互等的局面。

Q:TimeoutExpired.output 在 text=True 时是 str 还是 bytes?

bytes。文档原话:「This is always bytes when any output was captured regardless of the text=True setting. It may remain None instead of b'' when no output was observed.」实测确认:text=True 下 e.output 为 b'partial-1'。

Q:run() 超时后子进程到底有没有被杀?

被杀了。文档原话:「the child process will be killed and waited for. The TimeoutExpired exception will be re-raised after the child process has terminated.」但它的子进程不会(坑六)。

Q:为什么我加了 capture_output=True,拿到的 stdout 还是 None?

两个可能:一是文本模式解码失败,异常被吞进 _readerthread(坑二);二是管道数据在超时后被 kill() 带走(坑一)。两种情况下退出码都可能是 0。

Q:Windows 上想让子进程彻底断开关联,start_new_session 能用吗?

不能。文档标 (POSIX only),实测在 Windows 上不报错也不生效(坑六)。要整组控制进程,用 CREATE_NEW_PROCESS_GROUP 配 CTRL_BREAK_EVENT。

相关阅读

本站还有几篇同属「工具静默失效」这一层的复盘,可以对照着看:

subprocess 这一层的问题,几乎都不是「它坏了」,而是「它按文档工作,而文档说的和你想的不是一回事」。把上面三条不变式固化进代码,这七个坑就基本不会再来第二次。

👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务

👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速

📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页