Python subprocess 子进程
subprocess 全解:run/Popen、管道串联、超时与重定向、shell 注入风险与跨平台注意。
什么时候需要子进程
Python 不是万能胶水:调用 ffmpeg 转码、git 取提交历史、psql 执行导出,重写一遍不如直接调命令行。subprocess 模块就是标准库给出的”启动另一个程序并收发数据”的官方答案——os.system 与废弃的 os.popen 的现代替代品。核心心智模型只有一句话:新开一个进程,给它命令行参数与标准流(stdin/stdout/stderr),拿回退出码。优先用 subprocess.run()(一次性任务),需要流式交互再用 Popen。
注意:echo、dir 在 Windows 上是 shell 内建命令,列表形式直调可能失败,示例中的 sleep/cat/ls 为 Unix 命令;跨平台代码请先确认目标命令在两端都存在。
subprocess.run 推荐用法
基本写法:执行命令
subprocess.run([<命令>, <参数1>, <参数2>])
# 以列表形式执行命令(推荐,避免注入)
import subprocess
result = subprocess.run(["echo", "hello"], capture_output=True, text=True)
print(result.stdout) # hello
基本写法:捕获输出
subprocess.run(<命令>, capture_output=True, text=True)
# 捕获标准输出与错误输出
r = subprocess.run(["python", "-V"], capture_output=True, text=True)
print(r.stdout, r.stderr)
基本写法:字符串命令(shell 模式)
subprocess.run(<命令字符串>, shell=True)
# 使用 shell 解析管道与通配符
r = subprocess.run("dir | findstr py", shell=True, capture_output=True, text=True)
基本写法:检查返回码
subprocess.run(<命令>, check=True)
# 非零返回码抛出 CalledProcessError
try:
subprocess.run(["false"], check=True)
except subprocess.CalledProcessError as e:
print(f"命令失败: {e.returncode}")
基本写法:设置工作目录
subprocess.run(<命令>, cwd=<目录>)
# 指定子进程工作目录
subprocess.run(["ls"], cwd="/tmp", capture_output=True, text=True)
基本写法:设置环境变量
subprocess.run(<命令>, env=<环境字典>)
# 自定义子进程环境变量
import os
env = {**os.environ, "DEBUG": "1"}
subprocess.run(["python", "main.py"], env=env)
基本写法:设置超时
subprocess.run(<命令>, timeout=<秒数>)
# 超时抛出 TimeoutExpired
try:
subprocess.run(["sleep", "10"], timeout=3)
except subprocess.TimeoutExpired:
print("执行超时")
基本写法:传入输入
subprocess.run(<命令>, input=<字符串>, text=True)
# 通过 stdin 传入输入
r = subprocess.run(["python", "-c", "print(input()*2)"], input="ab", text=True, capture_output=True)
print(r.stdout) # abab
基本写法:输入输出编码
subprocess.run(<命令>, encoding=<编码>)
# 指定编码替代 text=True
r = subprocess.run(["echo", "中文"], encoding="utf-8", capture_output=True)
Popen 进程对象
基本写法:创建子进程
subprocess.Popen([<命令>, <参数>])
# 获取进程对象进行交互
p = subprocess.Popen(["python", "-u", "task.py"], stdout=subprocess.PIPE, text=True)
out = p.communicate()[0]
print(out)
基本写法:管道通信
<进程>.communicate([input=<输入>])
# 一次性读取全部输出并等待结束
p = subprocess.Popen(["cat"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
out, err = p.communicate(input="hello")
print(out) # hello
基本写法:等待进程结束
<进程>.wait([timeout=<秒>])
# 阻塞等待子进程退出
p = subprocess.Popen(["sleep", "2"])
p.wait()
print("进程已结束")
基本写法:轮询状态
<进程>.poll()
# 非阻塞检查是否结束
p = subprocess.Popen(["sleep", "2"])
while p.poll() is None:
print("运行中")
基本写法:终止进程
<进程>.terminate() / <进程>.kill()
# terminate 发送 SIGTERM,kill 发送 SIGKILL
p = subprocess.Popen(["sleep", "100"])
p.terminate()
基本写法:获取进程号
<进程>.pid
# 获取子进程 PID
p = subprocess.Popen(["sleep", "1"])
print(p.pid)
管道串联
基本写法:命令管道串联
Popen(stdout=Popen.stdin)
# 模拟 shell 管道:ps | grep python
p1 = subprocess.Popen(["ps", "aux"], stdout=subprocess.PIPE, text=True)
p2 = subprocess.Popen(["grep", "python"], stdin=p1.stdout, stdout=subprocess.PIPE, text=True)
p1.stdout.close()
out = p2.communicate()[0]
print(out)
check_output / call / check_call
基本写法:获取标准输出
subprocess.check_output(<命令>)
# 直接返回标准输出,失败抛异常
out = subprocess.check_output(["python", "-V"], text=True, stderr=subprocess.STDOUT)
print(out)
基本写法:仅执行并取返回码
subprocess.call(<命令>)
# 返回退出码,不抛异常
code = subprocess.call(["ls", "-l"])
基本写法:执行并校验
subprocess.check_call(<命令>)
# 返回码非零抛 CalledProcessError
subprocess.check_call(["echo", "ok"])
输入输出重定向
基本写法:输出重定向到文件
subprocess.run(<命令>, stdout=<文件对象>)
# 将输出写入文件
with open("out.log", "w", encoding="utf-8") as f:
subprocess.run(["python", "-V"], stdout=f)
基本写法:合并标准错误到标准输出
subprocess.run(<命令>, stderr=subprocess.STDOUT)
# 合并 stderr 到 stdout 一起捕获
r = subprocess.run(["python", "err.py"], capture_output=True, stderr=subprocess.STDOUT, text=True)
print(r.stdout)
基本写法:从文件输入
subprocess.run(<命令>, stdin=<文件对象>)
# 从文件读取 stdin
with open("input.txt", encoding="utf-8") as f:
subprocess.run(["python", "process.py"], stdin=f)
常见陷阱与最佳实践
- shell=True 拼接用户输入是命令注入:字符串命令交给 shell 解释,
; rm -rf /一类的输入会被原样执行。参数含任何外部输入时,一律用列表形式传参,让特殊字符退化为普通文本。
# 危险:filename 可能包含 shell 元字符
# subprocess.run(f"gzip {filename}", shell=True)
# 安全:列表形式不经过 shell 解析
subprocess.run(["gzip", filename])
- Windows 下 text=True 默认编码是本地编码(GBK/cp936):捕获 UTF-8 输出会乱码或抛 UnicodeDecodeError。跨平台代码显式传
encoding="utf-8",必要时补errors="replace"。 - PIPE 满了会死锁:用
Popen时如果只读 stdout 而 stderr 也在膨胀,管道缓冲区塞满后子进程会卡住。一次性拿结果用p.communicate()(内部并发读两个管道),不要手写p.stdout.read()再p.wait()。 - timeout 只在等待时生效:
run(timeout=...)超时会 kill 子进程并抛TimeoutExpired;但被 shell 包装的孙进程(shell=True下启动的)可能成为孤儿。生产环境终止进程树时,Unix 下用start_new_session=True+os.killpg。 - run() 返回 CompletedProcess,不抛错:忘记
check=True时命令失败也会静默继续。脚本类代码默认写check=True,让失败尽早爆炸。
本篇小结
- 心智模型:子进程 = 新进程 + 三条标准流 + 退出码;
run()覆盖 90% 场景,Popen服务流式交互。 - 默认姿势:列表传参(免注入)、
capture_output=True收输出、text=True/encoding="utf-8"转文本、check=True防静默失败、timeout防挂死。 - 管道串联遵循”上游 stdout 接下游 stdin,然后立刻 close 上游 stdout”的三行模板;更复杂的组合优先
shell=True交给真正的 shell(输入可信时)。 call/check_call/check_output是历史 API,行为都能用run()表达,新代码不必再分心记忆。
动手实践
- 写一个函数
git_last_tag(),用subprocess.run执行git describe --tags --abbrev=0并返回 tag 名,处理”仓库没有 tag”的异常路径。 - 用两个
Popen串联实现type big.log | findstr ERROR(Windows)或cat big.log | grep ERROR(Unix),统计匹配行数。 - 给一个外部命令加上
timeout=5与重试三次的逻辑,验证超时进程确实被终止、退出码被正确捕获。