Python 调外部命令,少量输出时正常,日志一多却卡在 wait(),CPU 还不高。这不一定是命令死循环,也可能是父进程没读管道,子进程写满后动不了。下面以 Linux 与 Python 3 为例,复现这类等待,再按输出规模选择 communicate、run 或日志文件,并补齐超时与退出码处理。
一、先分清:谁在等谁
把 stdout=subprocess.PIPE 写上,只是接了一根管道,不会自动启动一个后台读日志的人。管道容量有限,子进程持续写入、父进程却不消费时,写操作可能阻塞。父进程若又在 wait() 等子进程退出,就形成了互相等待。
这里讨论的是阻塞管道的背压,不是所有“程序没输出”的万能解释。命令等标准输入、网络阻塞、输出缓冲和业务死循环也会表现为卡住。先记录命令参数和退出状态,再看用了哪些 PIPE。

二、用一个受控反例确认问题
下面只启动一个临时 Python 子进程,不调用生产任务。它向标准输出写入四百万字节,父进程故意不读。保存为 demo.py,使用 python3 demo.py 运行。finally 负责终止测试子进程并收尾。
import subprocess, sys
命令 = [sys.executable, "-c",
"import sys; sys.stdout.buffer.write(b'x' * 4000000)"]
进程 = subprocess.Popen(命令, stdout=subprocess.PIPE)
try:
进程.wait(timeout=1)
except subprocess.TimeoutExpired:
print("等待超时:先等待退出,却没有读取管道")
finally:
进程.kill()
进程.communicate()
本次隔离环境使用 Python 3.14.3,实际出现了等待超时。这不意味着所有系统的管道容量固定。不要把 PIPE_BUF 当成总容量:它主要关联多写入者场景中的原子写入边界,不是“日志能装多少”的刻度尺。
三、有限输出:优先使用 run
短任务且输出能放进内存时,用 subprocess.run。capture_output 同时捕获 stdout 和 stderr,内部通过 communicate 处理输入输出并等待结束。下面使用参数列表,不启用 shell。
import subprocess, sys
命令 = [sys.executable, "-c",
"import sys; print('ok'); print('提示', file=sys.stderr)"]
try:
结果 = subprocess.run(命令, capture_output=True,
text=True, encoding="utf-8",
timeout=5, check=True)
print(结果.stdout)
except subprocess.CalledProcessError as 异常:
print("执行失败,退出码:", 异常.returncode)
except subprocess.TimeoutExpired:
print("超时,直接子进程已被终止并等待结束")
check=True 使非零退出码变成 CalledProcessError;没有它,run 返回不等于业务成功。示例把异常翻译成提示,生产封装还应向上层重新抛出或返回明确失败状态,不能打印一句就让调度器误报成功。真实命令的输出编码也要确认。
四、已有 Popen:同时处理两路输出
如果必须分阶段控制进程,用 communicate(timeout=秒数) 读取 stdout、stderr 并等待结束。不要先 wait 再 read,也不要先把 stdout.read() 读到 EOF 才去读 stderr:后一种写法中,子进程可能正堵在已写满的 stderr,根本来不及关闭 stdout。
本次隔离实验让 stdout 和 stderr 各输出四百万字节,run 完整取回两路数据,退出码为零;另一个退出码为 23 的子进程被 check=True 正确识别为失败。测试结果不外推到所有命令。
也别把 communicate 当成实时日志接口。它通常在返回时才交付收集结果,而且数据缓存在内存中。要一边执行一边展示,需要对所有已接管的输出流并发消费;仅给其中一路加读线程,另一条管道仍可能堵住。
五、大量输出:直接落盘更合适
构建、转码或导出任务可能产生大量日志,完整捕获进内存会把管道问题换成内存问题。若不需要实时解析,将两路输出直接交给文件;下面新建独占目录并以 xb 打开文件,不覆盖已有日志。
import subprocess, sys, tempfile
from pathlib import Path
# 新建独占日志目录,不覆盖已有文件
目录 = Path(tempfile.mkdtemp(prefix="child-log-"))
with (目录 / "output.log").open("xb") as 日志:
结果 = subprocess.run(
[sys.executable, "-c", "print('任务输出')"],
stdout=日志, stderr=subprocess.STDOUT,
timeout=30, check=True)
print("日志位置:", 目录 / "output.log")
这里把 stderr 合并到 stdout,换来简单的单文件记录,但会失去两路输出的独立身份。隔离测试将两路共八百万字节写入文件,核对长度一致、命令正常退出。落盘也不是无限容量方案:要设置保留周期、磁盘配额和满盘告警,业务产物仍需单独验收。
六、超时并不等于整棵进程树消失
run 的 timeout 到期后会终止并等待直接子进程,再抛 TimeoutExpired;而 Popen.communicate 超时不会自动终止子进程。对于本文这种不派生后代的受控程序,可以在捕获超时后调用 kill,再调用 communicate 完成管道读取与进程回收。不要只接住异常就丢下进程不管。
真实命令若启动了后台后代,后代可能继续持有管道写端,导致迟迟收不到 EOF。杀死直接子进程不保证清理所有后代,timeout 也不是整个调用严格的墙钟截止时间。进程树治理应另用受控的进程组或服务管理机制,并验证清理范围;不要对不明 PID 或进程组盲目发送信号。
七、按现象走排查分支
| 现象 | 优先核对 | 处理方向 |
|---|---|---|
| 少量日志正常,大量日志卡住 | 是否先 wait,PIPE 是否有人读 | 有限输出用 run/communicate |
| stdout 没动静,任务未退出 | stderr 是否被单独接管 | 两路并发消费或合并落盘 |
| 捕获后内存持续上涨 | 输出是否无上限 | 文件或有背压的流式处理 |
| 任务结束却没生成结果 | 退出码与业务产物 | 启用 check,再验收产物 |
| 超时后仍有进程或不见 EOF | 后代与继承的写端 | 核对进程树生命周期 |
八、上线前的验收清单
至少准备正常退出、非零退出、大量双路输出、主动超时四类夹具;分别记录退出码、日志长度、耗时和进程回收结果。保留失败日志前先脱敏,不要记录认证参数或完整环境变量。
最后核对三件事:每条 PIPE 都有消费路径;输出规模与存储方式匹配;命令结束后业务产物确实可用。扩大缓冲只能推迟写满,不能修复无人读取。
参考:Python 官方 subprocess 文档(run、wait、communicate 与超时);Linux man-pages:pipe(7)(容量、阻塞、EOF 与 PIPE_BUF)。
🔕 评论已关闭