Python subprocess 踩坑复盘:退出码 0 却拿不到输出的 7 个原因
调子进程这件事,真正的麻烦不是它报错,是它不报错。
下面这七个场景我全部在本机跑了一遍:没有一处抛异常到调用方,退出码清一色是 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 API | Microsoft 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。
相关阅读
本站还有几篇同属「工具静默失效」这一层的复盘,可以对照着看:
- Python tarfile 解压安全:五种「写到目录外」与 5 个真实报错 —— 同样是「没异常、没日志、退出码 0」的一类失效。
- 重试与退避:requests 2.34.2 与 tenacity 9.2.1 的静默失效 —— 失败恢复层的另一半,讲重试为什么没发生。
- Python logging 实测:6 个真实报错与丢失的日志 —— 输出去哪了的姊妹篇,这次丢的是日志而不是子进程输出。
subprocess 这一层的问题,几乎都不是「它坏了」,而是「它按文档工作,而文档说的和你想的不是一回事」。把上面三条不变式固化进代码,这七个坑就基本不会再来第二次。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):