跳到主要内容

操作系统、命令行参数与子进程

本节目标

查询 Python 环境变量、命令行参数、退出状态、子进程和清理边界。

进程接口把程序接到宿主系统:环境变量和命令行参数是外部输入,标准流和退出状态是可观察合同,子进程则带来启动、等待、超时和回收责任。本章依次以 ossysargparsesubprocesssignal 的 Python 3.14 文档为准。持续时间和 deadline 的选择见数值、随机与日期时间,线程、任务和进程池的生命周期见并发与 asyncio

os、环境变量与平台边界

os 用相对统一的接口暴露操作系统能力,但统一名称不等于每个平台都有同一能力。只有先检查 API 的 availability 和目标平台合同,运行结果才能被解释为业务事实。os.name 只给出较粗的实现族,扩展接口、权限模型、进程能力以及 WebAssembly、移动平台上的可用性都可能不同,不能用一次本机运行替代平台声明。

环境变量是进程启动时获得、可被调用者控制的外部输入。os.environ["MODE"] 在缺失时抛出 KeyErroros.getenv("MODE") 则返回 None 或给定默认值;空字符串仍是“存在的值”,不能与缺失混为一谈。环境名和值在 Python 中通常表现为 str,但 Unix 上会经文件系统编码和 surrogateescape 转换,Windows 环境键还会规范为大写;需要字节接口时也必须先检查 os.supports_bytes_environ

不要把 HOMEPATH、locale、代理或凭据变量视为可信配置,也不要为了一个子进程直接修改全局 os.environ。在入口处验证允许值、区分缺失与空值,把秘密排除在诊断日志之外,并将平台特有分支集中在可测试的适配层。

sys.argv、标准流与退出状态

sys.argv 是交给用户程序的参数文本列表,argv[0] 通常是脚本名或调用形式,而解释器自己消费的选项在 sys.orig_argv 中。参数形状应交给 argparse,不应由依赖固定下标的脆弱解析器处理。Unix 原始参数是字节,Python 以文件系统编码和 surrogateescape 解码;确需恢复原始字节时可对每项使用 os.fsencode(),不能假定所有参数都来自 UTF-8 文本。

stdout 用于正常结果,stderr 用于诊断、用法和错误,两者必须分别设计。成功通常返回 0,业务失败使用预先约定的非零码;sys.exit("message") 会把消息写到 stderr 并以失败状态退出,而在深层函数直接调用它会让复用和测试困难,通常应抛出领域异常并在最外层映射为诊断与状态码。

管道消费者可能只读取 stdout,因此把诊断混进去会破坏机器接口。文本流的编码、错误处理、是否交互终端以及缓冲方式也随启动环境变化;稳定 CLI 应明确输出格式和换行,不依赖颜色、终端宽度或当前 locale。

argparse 与命令合同

ArgumentParser 可以声明位置参数、选项、类型、choices、必选项和子命令。CLI 是公开接口,帮助文本、默认值、错误流和退出码都应稳定且可测试。解析后的值仍需进行跨字段和资源层验证,例如 --limit 的类型正确不代表其范围适合业务。

parser = argparse.ArgumentParser(prog="reporter")
subcommands = parser.add_subparsers(dest="command", required=True)
report = subcommands.add_parser("report")
report.add_argument("--limit", type=int, required=True)
options = parser.parse_args()

argparse 默认把用法和错误写入 stderr,并以状态码 2 退出;不要把这种解析失败误写成子命令执行后的业务状态。需要库式调用时可选择 exit_on_error=False 并捕获相应错误,但帮助请求、未知参数及不同解析路径仍要按实际 API 验证,不能假定所有失败都会变成同一种异常。

参数数组与 shell 边界

启动外部程序时默认传递参数序列,让每个元素恰好对应一个参数。可执行文件和参数应分开构造,避免把外部输入拼接成命令字符串。shell=False 不解释 |*$ 等 shell 元字符,这些字符会原样成为目标程序的参数;因此它们不会自动形成管道、通配展开或变量替换。

completed = subprocess.run(
[sys.executable, "-I", "-c", "print('literal:*|$HOME')"],
check=False,
)

参数数组与 shell=False 是启动外部程序的常规规则;只有明确需要 shell 内建命令或 shell 语法时才考虑 shell=True,且调用者要承担引号和命令注入风险。Windows 上,.bat.cmd 仍可能由操作系统通过 shell 启动,且 Python 可能不会转义其参数;因此应优先避开 batch 包装器并调用可信绝对路径。必须给 batch 文件传入不可信参数时,Python 3.14 官方文档建议考虑 shell=True,让 Python 转义特殊字符;这不是 shell=True 一般安全的承诺,仍须针对目标平台测试并完成威胁建模。启动当前 Python 应使用 sys.executableshutil.which() 只负责解析搜索结果,不证明可执行文件可信或完整;可信绝对路径才是安全边界。

run、状态检查与超时

subprocess.run() 适合等待单个命令完成并取得 CompletedProcess,但启动异常、完成状态、信号终止与超时必须分别处理。启动失败在父进程抛出 OSError,不产生可检查的子进程退出状态;非零 returncode 表示子进程已经启动并结束。check=True 会把非零状态转换为 CalledProcessErrorcheck=False 则要求调用者显式检查状态。POSIX 上负返回码 -N 才表示由信号 N 终止;这一编码不是跨平台保证。

run(timeout=...) 会在超时后杀死并等待子进程,再抛出 TimeoutExpired;但许多平台的初始进程创建本身不可中断,所以 timeout 不是从函数调用开始的绝对硬实时上限。使用较低层 Popen 时责任不同:Popen.communicate(timeout=...) 超时不会替调用者终止子进程,必须在异常分支显式 kill() 后再次 communicate() 完成排空与回收。timeout 只约束直接子进程;它是否还能留下后代进程取决于进程组、会话与平台设计。

process_report.py
import argparse
import subprocess
import sys


parser = argparse.ArgumentParser(prog="process-report")
parser.add_argument("action", choices=["report"])
parser.add_argument("--limit", type=int, required=True)
arguments = parser.parse_args(["report", "--limit", "2"])

child = subprocess.run(
[
sys.executable,
"-I",
"-c",
'import sys; assert sys.flags.isolated == 1; print("value=42")',
],
capture_output=True,
text=True,
encoding="utf-8",
errors="strict",
timeout=2.0,
check=False,
)

print(f"parsed={arguments.action}:{arguments.limit}")
print(f"child={child.stdout.removesuffix(chr(10))}")
print(f"status={child.returncode}")
print(f"stderr-empty={child.stderr == ''}")
print(f"isolated={sys.flags.isolated == 1}")
parsed=report:2
child=value=42
status=0
stderr-empty=True
isolated=True

代表脚本只启动当前 sys.executable,以参数数组传入 -I -c,用两秒 timeout 等待并分别读取 stdout、stderr 和状态。外层也由共享运行器以 -I 启动,所以最后一行同时证明父进程处于隔离模式;输出不包含 PID、耗时、路径或调度先后。

子进程文本、字节与编码

未启用文本模式时,捕获的 stdin、stdout 和 stderr 是字节。跨进程协议必须先定义传输字节还是文本;若选择文本,还要明确字符编码和错误策略。text=True 会创建文本流;给出 encodingerrors 也会启用文本模式。代表脚本同时写出 text=Trueencoding="utf-8"errors="strict",使解码失败成为可观察错误,而不是依赖宿主 locale 或静默替换。

capture_output=True 等价于分别捕获 stdout 与 stderr,不能同时再给这两个参数传其他值。合并为 stderr=STDOUT 会丢失流边界,只有协议明确允许时才使用。不要无条件 strip() 子进程输出:它会删除有意义的首尾空白;若合同只允许一个终止换行,应精确移除该后缀并验证其余内容。

子进程选择什么编码是目标程序自己的合同,不能因为调用者是 Python 就推断为 UTF-8。调用任意系统工具时应查明其字节协议、locale 与错误输出;不能确定时保留 bytes,在协议层完成验证后再解码。

PIPEcommunicate 与死锁

匿名管道有有限缓冲区;父进程若先等待、或只同步读取一条流,子进程可能因另一条管道写满而阻塞。常规有限输出可由 run(capture_output=True) 收集;需要 Popen 时,则用 communicate() 同时处理输入和输出。communicate() 会同时排空 stdout 与 stderr,避免单侧管道填满导致死锁;不要组合 wait() 与未排空的 PIPE,也不要在没有完整协议时逐个直接读写管道。

避免死锁不等于避免资源耗尽。捕获数据会缓存在内存中,输出可能很大或无界时必须改用文件、流式消费或协议上限;即便设置 timeout,子进程也可能在到期前快速填满内存。应同时限制允许的输出量、执行时长和输入规模,并决定超限时如何终止、排空和记录截断事实。

多段管道还要关闭父进程不再使用的描述符,否则 EOF 或 SIGPIPE 传播可能被父进程持有的副本延迟。涉及多个子进程时要为每一个保存句柄、检查状态并在异常路径回收,不能只等待最后一段就假定前面的进程已成功结束。

envcwd 与执行隔离

省略 env 时子进程继承当前进程环境;传入 env 会替换继承环境,而不是在父环境上只增加给出的键。需要保留其余变量时先复制 os.environ 再修改副本;若目标是最小白名单环境,则显式构造完整映射,并补齐目标平台启动所需的键。两种策略不能混称“隔离”。

child_env = os.environ.copy()
child_env["APP_MODE"] = "report"
subprocess.run(command, env=child_env, cwd=workspace, check=True)

cwd 只改变子进程工作目录,不提供文件系统沙箱、权限隔离或路径可信性。相对可执行文件如何搜索受平台、cwdPATH 影响,环境副本还可能携带代理、动态加载器、Python 启动和凭据相关变量;高信任边界应使用绝对路径、受控目录、最小权限和操作系统级隔离,而不是只传 cwd

-I 是 Python 的隔离模式,可忽略若干影响模块搜索和启动的环境/用户 site 输入,但它不是通用进程沙箱。代表脚本用它缩小 Python 导入环境,同时仍为子进程的参数、状态、输出和 timeout 分别建立合同。

信号、终止与清理

由于终止只是请求和平台动作,并不表示资源已经回收,发出终止请求后仍要 wait()communicate() 回收子进程,并检查最终 returncode。terminate()kill() 与信号编号的含义存在 POSIX/Windows 差异:POSIX 上通常分别发送 SIGTERM 与 SIGKILL,Windows 则使用不同的进程终止机制,不能把 Unix 的优雅关闭语义写成跨平台保证。

超时清理应遵循“等待 → 请求终止 → 必要时强制终止 → 再等待并排空流”的有界策略,并在每一步处理进程可能已经退出的竞态。子进程若创建了自己的后代,终止直接 child 不保证整棵进程树结束;需要整组管理时要在启动阶段设计新会话或进程组,并使用目标平台对应设施。

Python 信号处理器总在主解释器的主线程执行,且只有主解释器的主线程可以安装 Python 信号处理器;信号不能代替线程间同步。处理器还可能在任意后续字节码点抛出异常,所以关键资源应由 try/finally、上下文管理器和幂等清理保护,而不是依赖某条语句必然执行完。

CLI 与子进程的安全和可移植性

安全边界首先要回答“谁控制可执行文件、参数、环境、工作目录和输入”。外部输入只能选择允许的操作和独立参数,不能拼接到 shell 命令;可执行文件使用可信绝对路径,环境采用明确继承或白名单策略,日志不记录令牌、完整环境和敏感参数。PATH 劫持、命令注入、秘密泄漏和不受限输出是不同威胁,需要分别控制。

可移植性检查至少覆盖参数编码、路径搜索、退出状态、信号、进程组、batch/shell 行为与 API availability。不要把 POSIX 的负 returncode、fork 语义、/bin/sh 或文件描述符模型写成 Windows 合同,也不要假定移动端和 WebAssembly 提供完整进程接口。

最后把失败分类保留下来:spawn error 说明程序未启动,非零 status 是已运行程序的结果,signal 是平台相关终止信息,timeout 是调用者等待合同被突破,而 stderr 只是独立诊断流,不能单凭“有内容”推断失败。每条路径都必须有有界等待和确定性清理;只有状态、信号、stdout、stderr 与资源归属都被观察后,调用者才能安全进入下一步。