DEBUG.WIKI
丽萨主机;为跨境业务选好 IP;原生 / 住宅 IP · 按套餐选择;查看 IP →
阿里云;从建站到云上业务;云服务器 · 数据库 · 对象存储;查看产品 →
Evoxt;最高 6.0GHz CPU;高频云服务器 · 免费每周备份;查看配置 →

Windows subprocess 输出为 None,后台报 UnicodeDecodeError

Windows 捕获 subprocess 文本输出时,后台 _readerthread 报 UnicodeDecodeError,结果却为 None。检查捕获方式与编码约定,改为收集字节后显式解码。

在 Windows 上用 subprocess.run() 捕获文本输出,程序明明向管道写了内容,结果中的 stdout 或 stderr 却是 None;控制台同时出现 Exception in thread、_readerthread 和 UnicodeDecodeError。主线程外面的 try/except UnicodeDecodeError 没有捕获到它,有时进程退出码还是 0。

这组症状需要检查后台读取线程的解码过程。先按二进制模式完整收集输出,再由调用代码按已确认的编码解码,可以让解码失败在主线程中正常抛出。不要先把 None 改成空字符串,否则会掩盖没有取到输出的问题。

哪些 None 属于正常结果

没有设置 stdout=subprocess.PIPE,也没有使用 capture_output=True 时,CompletedProcess.stdout 为 None 是正常行为。使用 stderr=subprocess.STDOUT 合并输出时,stderr 也会是 None,内容在 stdout 中。

如果两个流分别捕获、进程已经完成,正常的空输出应当是 b"" 或 ""。此时某个结果为 None,并伴随后台线程的解码异常,才符合本文处理的场景。只有 None、没有这些异常线索时,还需要检查捕获参数和调用路径。

为什么主线程没有接到 UnicodeDecodeError

Windows 上的 Popen.communicate() 在有超时参数、或需要处理多个管道等情况下,会使用后台线程读取输出。文本模式下,读取动作也负责解码。若读到不能按选定编码解码的字节,异常可能在 _readerthread 内发生,线程尚未把结果放入缓冲列表就结束了;主线程随后把空列表转换成 None,而没有重新抛出该异常。

这个问题不局限于 Python 3.15。CPython 3.12.13、3.13.13 和 3.14.5 的源码中都存在这条线程读取路径;截至 2026 年 10 月 11 日核对的正式版本 3.15.0,也保留了上述读取和返回路径,不能把升级到 3.15 当作修复办法。

单个管道且未设置超时的调用,可能走直接读取的路径,使解码异常直接出现在调用线程。因此,增添 capture_output=True 或 timeout 后异常表现改变,并不代表子程序突然停止输出。保留业务需要的 stderr 捕获和超时设置,调整读取方式即可。

check=True 只检查子进程是否以非零状态退出。子进程正常退出、父进程解码失败时,它不能证明输出已经完整读取。

先收集 bytes,再按输出约定解码

从调用参数里去掉 text=True、universal_newlines=True、encoding 和 errors,让管道保持二进制模式。仅写 text=False,同时保留 encoding="utf-8" 或 errors=...,仍然会启用文本模式。

下面是一个独立示例:子进程明确用 UTF-8 写出 stdout,父进程先捕获 bytes,再进行严格解码。接入自己的程序时,替换 command,并确认它两个输出流各自使用的编码;示例的 UTF-8 约定不能直接套到任意 Windows 命令。

import subprocess
import sys

command = [
    sys.executable,
    "-c",
    "import sys; "
    "sys.stdout.buffer.write('状态=正常\\n'.encode('utf-8'))",
]

result = subprocess.run(
    command,
    capture_output=True,
    timeout=30,
    check=False,
)

if result.stdout is None or result.stderr is None:
    raise RuntimeError("已分别捕获两个流,但没有取得读取结果")

stdout = result.stdout.decode("utf-8", errors="strict")
stderr = result.stderr.decode("utf-8", errors="strict")

if result.returncode != 0:
    raise subprocess.CalledProcessError(
        result.returncode,
        command,
        output=stdout,
        stderr=stderr,
    )

print(stdout, end="")

这里的解码在调用线程中执行,失败时会抛出 UnicodeDecodeError,原始字节仍保存在 result.stdout 或 result.stderr 中。两个输出流没有合并,退出码也单独检查,方便区分执行失败和解码失败。示例中的 timeout=30 保留等待限制;二进制捕获改变的是解码位置,不会修复其他 Windows 子进程超时或管道未关闭问题。

如果原调用还带有 input="...",切换到二进制模式后也需要按子程序的输入约定传入 bytes。不要只修改输出模式而留下字符串输入。二进制捕获也不再自动把输出换行转换为 \n;需要解析文本时,按实际格式处理。捕获全部输出会把内容放入内存,这种写法适合输出量可控的命令;持续输出的进程需要另行设计逐段读取。

Python 3.15 的默认 UTF-8 需要单独核对

Python 3.15 默认启用 UTF-8 模式。未指定 encoding 的文本捕获,默认编码可能与此前依赖 Windows 本地编码的行为不同;但父进程改用 UTF-8,不会自动让任意子程序按 UTF-8 输出。

先检查工具文档、输出格式选项或子程序自身的写出代码。某些工具可选择 UTF-8,某些使用本地代码页,也有程序对 stdout 和 stderr 使用不同编码。chcp 的结果、系统语言或报错中的一个字节,都不足以确定两个流的编码。

确认输出是某种编码后,可以在二进制捕获结果上显式 .decode(),或在需要文本模式的调用里设置匹配的 encoding。如果后者仍存在无法解码的字节,上述后台异常风险依然存在;需要可靠捕获这类错误时,继续把解码放在调用线程中。

不要用替换字符掩盖机器输出损坏

errors="ignore" 会丢弃无法解码的字节,errors="replace" 会改变文本。对文件路径、JSON、标识符或需要继续解析的输出,这些变化可能造成新的错误。只有输出用于人工查看、允许展示转义字符时,才考虑在保留原始 bytes 的前提下使用 errors="backslashreplace"。

调整后,已捕获的空流应返回空 bytes;有内容时,应能按约定解码;异常字节应在解码位置明确报错。不要通过删掉超时、减少捕获的流,或修改安装目录中的 subprocess.py 来隐藏症状。

如果输出已经取得,下一步调用 split() 才报 a bytes-like object is required,可以继续检查 check_output 返回值中的 bytes 与 str 混用。那是取得输出后的类型处理问题。