标准输入输出怎么用?为什么重定向后日志就不见了?
简化版
每个进程启动时自带三个标准流:stdin(fd 0,输入)、stdout(fd 1,正常输出)、stderr(fd 2,错误输出)。分成两个输出流的意义在于「数据」和「诊断信息」可以被分别重定向——python app.py > data.txt 只重定向 stdout,错误信息仍然显示在终端上。Python 里对应 sys.stdin/sys.stdout/sys.stderr,它们是 TextIOWrapper(文本层)包着 BufferedWriter(缓冲层)包着 FileIO(原始 fd) 的三层结构,所以 sys.stdout.buffer 能拿到二进制流、sys.stdout.fileno() 能拿到 fd 号。最经典的坑是缓冲行为随「输出到哪里」而变:输出到终端(tty)时是行缓冲(每换行就刷出来),输出到管道或文件时是全缓冲(攒够 8KB 才写)——这就是「程序在终端跑得好好的,一旦 > log.txt 或在 Docker 里跑,日志就迟迟不出现」的原因;stderr 则从 Python 3.9 起始终是行缓冲(错误要及时可见)。解决办法:python -u、环境变量 PYTHONUNBUFFERED=1(容器里的标配)、print(..., flush=True),或 sys.stdout.reconfigure(line_buffering=True)。另外两个高频问题:Windows 控制台的编码(用 PYTHONIOENCODING=utf-8 或 reconfigure(encoding="utf-8"))和管道被提前关闭时的 BrokenPipeError(下游 head -5 读够就退出了)。核心记忆:数据走 stdout、诊断走 stderr;管道/文件是全缓冲,容器里记得 PYTHONUNBUFFERED=1。
详细版
三个标准流:
| 流 | fd | 用途 | 缓冲(Python 3.9+) |
|---|---|---|---|
stdin | 0 | 输入 | 交互时行缓冲 |
stdout | 1 | 正常数据(可被管道消费) | tty→行缓冲;管道/文件→全缓冲(8KB) |
stderr | 2 | 诊断/错误/进度(不该进管道) | 始终行缓冲 |
import sys, os
# ① 三层结构:文本层 → 缓冲层 → 原始 fd
print(type(sys.stdout)) # <class '_io.TextIOWrapper'>
print(type(sys.stdout.buffer)) # <class '_io.BufferedWriter'> ← ★写二进制用它★
print(sys.stdout.fileno()) # 1
print(sys.stdout.encoding) # 'utf-8'(取决于平台和环境变量)
# ② 数据 vs 诊断:分开输出,才能被分别重定向
print("结果行") # → stdout
print("处理中...", file=sys.stderr) # → stderr(★不会污染管道数据★)
# shell: python app.py > data.txt 只有"结果行"进文件,进度仍显示在终端
# shell: python app.py 2> err.log 只有诊断进 err.log
# shell: python app.py > all.txt 2>&1 ★两个都进同一个文件(顺序要对)★
# ③ ★缓冲坑:重定向后输出消失★
import time
for i in range(3):
print(f"第 {i} 行") # 终端里立刻可见;★重定向到文件时可能几分钟都不落盘★
time.sleep(60)
# 解决方案(任选):
print("立刻输出", flush=True) # 单次刷新
sys.stdout.reconfigure(line_buffering=True) # ★3.7+,整个流改行缓冲★
# python -u app.py # 命令行开关
# PYTHONUNBUFFERED=1 python app.py # ★环境变量,容器里的标配★
# ④ 读输入的三种方式
name = input("名字: ") # ★会去掉结尾换行;EOF 时抛 EOFError★
for line in sys.stdin: # ★流式逐行,处理大输入★(行尾带 \n)
process(line.rstrip("\n"))
data = sys.stdin.buffer.read() # ★二进制全读(处理非文本管道数据)★
import fileinput
for line in fileinput.input(): # ★有文件参数就读文件,没有就读 stdin(Unix 惯例)★
pass
# ⑤ 判断是不是交互式终端
if sys.stdout.isatty():
print("\033[32m彩色输出\033[0m") # ★只在终端里上色★
else:
print("纯文本") # 被重定向/管道时不加转义序列
# ★进度条、彩色、动画都应该先判断 isatty(),否则日志文件里全是乱码转义符
# ⑥ 捕获自己的输出(测试或抓第三方库的 print)
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
print("被抓住了")
print("捕获到:", buf.getvalue().strip())
# ⑦ BrokenPipeError:下游提前退出(python app.py | head -3)
try:
for i in range(10**6):
print(i)
except BrokenPipeError:
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, sys.stdout.fileno()) # ★避免退出时再次报错★
sys.exit(1)
# ⑧ 编码(Windows 控制台常见乱码)
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # 3.7+
# 或环境变量 PYTHONIOENCODING=utf-8
⚠️ 三个必须记住的点:① 缓冲策略取决于「输出到哪里」,不取决于你的代码。CPython 的规则是:
stdout连着终端(tty)时行缓冲、连着管道或文件时全缓冲(约 8KB);stderr从 Python 3.9 起始终行缓冲(更早的版本是无缓冲)。这就是「本地终端跑得好好的,一部署到容器/CI/nohup 就看不到日志」的根本原因——日志还在缓冲区里没刷出来,进程被kill -9时甚至会永久丢失。容器镜像里设ENV PYTHONUNBUFFERED=1是标准做法。②print()默认写 stdout,但日志和进度信息应该走 stderr——因为 stdout 是「程序的数据输出」,会被下游用管道消费;把进度条打进 stdout 会污染数据(python gen.py | wc -l的结果就错了)。判断标准很简单:这行内容是不是「程序的产出」?是就 stdout,否则 stderr。③sys.stdout是文本流,写二进制要用sys.stdout.buffer(TextIOWrapper→BufferedWriter→FileIO三层结构);同理读二进制管道数据用sys.stdin.buffer.read(),否则会因为编码解析失败或换行符转换而损坏数据。
完整版教学
一、为什么要有两个输出流
每个进程启动时内核给它三个已打开的 fd:
fd 0 = stdin (标准输入)
fd 1 = stdout (标准输出)
fd 2 = stderr (标准错误)
→ 这是 Unix "一切皆文件 + 管道组合" 哲学的基础设施
为什么要把输出分成两个:★为了能被分别重定向★
设想只有一个输出流:
python gen.py > data.csv
→ 进度信息、警告、错误全都混进了 data.csv → ★数据被污染★
有了 stderr:
python gen.py > data.csv 数据进文件,进度和错误仍在终端可见
python gen.py 2> err.log 只收集错误
python gen.py > /dev/null 丢弃数据,只看错误(调试时常用)
python gen.py 2>&1 | tee all 合并后一起看
★ 判断"该写哪个流"的准则:
这行内容是★程序的产出(数据)★吗?
是 → stdout(可以被下游管道消费、被重定向到文件)
否 → stderr(进度、日志、警告、错误、提示语)
典型错误:把进度条 print 到 stdout
python gen.py | wc -l → ★进度条的每一行都被算进去了★
shell 重定向的顺序(★经典面试题★):
cmd > f 2>&1 ✓ 先把 stdout 指向 f,再让 stderr 指向"stdout 现在指的地方"(=f)
cmd 2>&1 > f ✗ 先让 stderr 指向"stdout 现在指的地方"(=终端),再把 stdout 指向 f
→ ★结果:stdout 进文件、stderr 还在终端★
cmd &> f bash 的简写,等价于第一种
Python 里 logging 的默认行为(容易被问):
logging.StreamHandler() 默认写 ★stderr★(不是 stdout)
→ 符合"日志是诊断信息"的定位
→ 想让日志进 stdout(某些日志采集系统只收 stdout)要显式:
StreamHandler(sys.stdout)
三个标准流是 Unix「一切皆文件 + 管道组合」哲学的基础设施。把输出分成 stdout 和 stderr 的唯一理由是「能被分别重定向」:python gen.py > data.csv 时,数据进文件而进度、警告、错误仍然显示在终端——如果只有一个输出流,这些诊断信息就会污染数据文件。由此得出判断准则:这行内容是不是「程序的产出」?是就写 stdout,否则一律 stderr(进度、日志、警告、提示语)。典型错误是把进度条 print 到 stdout,结果 python gen.py | wc -l 把进度行也数进去了。顺带记住两个高频考点:cmd > f 2>&1 和 cmd 2>&1 > f 的效果完全不同(后者 stderr 仍在终端,因为重定向是从左到右依次生效的);以及 Python 的 logging.StreamHandler() 默认写 stderr——想让日志进 stdout(某些采集系统只收 stdout)必须显式传 sys.stdout。
二、三层结构:TextIOWrapper / BufferedWriter / FileIO
sys.stdout 的真实结构(自下而上):
┌─────────────────────────────────────────────┐
│ TextIOWrapper ← sys.stdout │ 文本层:编码、换行符转换、行缓冲
├─────────────────────────────────────────────┤
│ BufferedWriter ← sys.stdout.buffer │ 缓冲层:攒够一块再 write
├─────────────────────────────────────────────┤
│ FileIO(fd=1) ← sys.stdout.buffer.raw │ 原始层:真正的 write(2) 系统调用
└─────────────────────────────────────────────┘
sys.stdout.fileno() → 1
各层的用途:
sys.stdout.write("文本") 文本(会按 encoding 编码)
sys.stdout.buffer.write(b"bytes") ★二进制★(图片、压缩流、非 UTF-8 数据)
os.write(1, b"bytes") ★绕过所有缓冲★,直接系统调用(调试缓冲问题时有用)
★ 混用文本层和 buffer 层要小心顺序:
print("a"); sys.stdout.buffer.write(b"b")
→ "a" 可能还在文本层缓冲里,导致输出顺序变成 "b" 然后 "a"
✓ 混用前先 sys.stdout.flush()
TextIOWrapper 干的三件事(写文本时):
① 编码:str → bytes(按 sys.stdout.encoding)
② 换行符转换:newline=None 时把 "\n" 转成 os.linesep(★Windows 上变 \r\n★)
③ 行缓冲:line_buffering=True 时遇到 "\n" 就 flush
★ 由此产生的 Windows 经典坑:
往 stdout 输出二进制(比如生成的 PNG)时,
"\n"(0x0A) 会被转成 "\r\n"(0x0D 0x0A) → ★文件损坏★
✓ 必须用 sys.stdout.buffer.write(data)
三个可调参数(3.7+ 的 reconfigure):
sys.stdout.reconfigure(encoding="utf-8") 改编码
sys.stdout.reconfigure(errors="replace") 编码失败时的策略
sys.stdout.reconfigure(line_buffering=True) ★强制行缓冲★
sys.stdout.reconfigure(newline="\n") 禁用换行符转换
★ 3.7 之前只能重新包一层:
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
替换标准流(测试或重定向):
sys.stdout = io.StringIO() ✓ 简单,但★只影响 Python 层★
→ 子进程和 C 扩展的输出仍然走原来的 fd 1(它们不看 sys.stdout)
✓ 要连子进程一起重定向必须在 ★fd 层面★操作:os.dup2(new_fd, 1)
sys.stdout 不是一个简单对象,而是三层结构:TextIOWrapper(编码、换行符转换、行缓冲)包着 BufferedWriter(攒够一块再写)包着 FileIO(真正的 write 系统调用)。理解这个结构能解决一串实际问题:写二进制数据必须用 sys.stdout.buffer.write()——直接 print 二进制会因为编码而失败,而在 Windows 上文本层还会把 \n 转成 \r\n、直接损坏 PNG 之类的二进制输出;调试缓冲问题时可以用 os.write(1, b"...") 绕过所有缓冲层;混用文本层和 buffer 层前要先 flush(),否则输出顺序会乱。3.7 起可以用 sys.stdout.reconfigure() 就地调整编码、错误策略、行缓冲和换行符转换(更早只能重新包一层 TextIOWrapper)。最后一个关键区分:sys.stdout = io.StringIO() 只影响 Python 层,子进程和 C 扩展的输出仍然走 fd 1——要把它们也重定向必须在 fd 层面用 os.dup2()。
三、缓冲:为什么重定向后日志就不见了
CPython 的缓冲规则(★核心考点★):
┌──────────┬──────────────┬────────────────────────┐
│ 流 │ 连着 tty │ 连着管道/文件 │
├──────────┼──────────────┼────────────────────────┤
│ stdout │ ★行缓冲★ │ ★全缓冲(约 8KB)★ │
│ stderr │ 行缓冲 │ ★行缓冲★(3.9+ 起统一) │
│ stdin │ 行缓冲 │ 全缓冲 │
└──────────┴──────────────┴────────────────────────┘
★ 关键:缓冲策略由"输出到哪里"决定,代码一个字都没改
★ 为什么这样设计:写系统调用有开销,攒够一块再写效率高得多
(8KB 一次 vs 每行一次,对百万行输出是几十倍的差距)
而终端是给人看的,实时性优先于吞吐
由此产生的四个经典现象:
① python app.py 终端里日志实时滚动 ✓
② python app.py > log.txt ★log.txt 长时间是空的★ ← 攒在缓冲区
③ docker logs / kubectl logs 看不到输出 ← 容器里 stdout 是管道
④ 进程被 kill -9 ★缓冲区里的日志永久丢失★ ← 没机会 flush
★ 注意 ④:正常退出(sys.exit / 异常结束)时 Python 会 flush,
但 kill -9(SIGKILL)、段错误、os._exit() 都★不会★
五种解决办法(按推荐度):
① 环境变量 PYTHONUNBUFFERED=1 ★容器/CI 里的标配★,不改代码
Dockerfile: ENV PYTHONUNBUFFERED=1
② python -u app.py 命令行开关,等价效果
③ sys.stdout.reconfigure(line_buffering=True) ★3.7+,代码里改,最精确★
④ print(..., flush=True) 单次;循环里每次都刷会降低吞吐
⑤ 日志走 logging + StreamHandler(stderr) ← stderr 本来就是行缓冲
★ 注意 -u 和 PYTHONUNBUFFERED 是"无缓冲/行缓冲",会牺牲吞吐:
输出百万行时,全缓冲可能比无缓冲快数倍
→ 高吞吐场景应该保留缓冲,只在关键节点手动 flush
验证当前缓冲状态:
sys.stdout.isatty() True=终端(行缓冲),False=管道/文件(全缓冲)
sys.stdout.line_buffering True/False
sys.stdout.write_through 文本层是否直接写穿到缓冲层
一个易被忽略的场景:★print 到 stdout、logging 到 stderr,两者顺序会乱★
stdout 全缓冲、stderr 行缓冲 → 重定向到同一个文件时,
时间上先发生的 print 可能出现在后发生的日志之后
✓ 统一走一个流,或在关键处 flush
这是本题最有实战价值的一节。缓冲策略由「输出到哪里」决定,而不是由代码决定:stdout 连着终端时行缓冲、连着管道或文件时全缓冲(约 8KB);stderr 从 3.9 起始终行缓冲。这个设计是性能考量——写系统调用有开销,攒够一块再写对百万行输出能快几十倍,而终端是给人看的所以实时性优先。由此产生四个经典现象:终端里日志实时滚动、重定向到文件后长时间空白、Docker/K8s 里 logs 看不到输出(容器里 stdout 是管道)、以及最严重的——进程被 kill -9 时缓冲区里的日志永久丢失(正常退出会 flush,但 SIGKILL、段错误、os._exit() 都不会)。解决办法按推荐度是:容器里设 ENV PYTHONUNBUFFERED=1(标配、不改代码)、python -u、reconfigure(line_buffering=True)、print(flush=True)、或干脆让日志走 stderr。但要知道代价:无缓冲会牺牲吞吐,高吞吐场景应该保留缓冲、只在关键节点手动 flush。
四、重定向与捕获:Python 层 vs fd 层
两个层次,能力完全不同:
★ Python 层(只影响 Python 自己的 print/write):
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf): # 3.4+
print("被捕获")
some_library_that_prints() # ✓ 第三方库的 print 也能抓到
with contextlib.redirect_stderr(buf): ... # 3.5+
→ 原理:临时把 sys.stdout 指向别的对象
→ ✗ 抓不到:★子进程的输出★、★C 扩展直接往 fd 1 写的内容★
★ fd 层(连子进程和 C 扩展一起重定向):
with open("out.log", "w") as f:
os.dup2(f.fileno(), 1) # ★让 fd 1 指向文件★
os.dup2(f.fileno(), 2)
→ 之后所有往 fd 1/2 写的东西(包括子进程继承的)都进文件
→ 恢复需要先 saved = os.dup(1),用完 os.dup2(saved, 1)
捕获子进程的输出:
import subprocess
r = subprocess.run([...], capture_output=True, text=True)
r.stdout / r.stderr # ★分别拿到★
subprocess.run([...], stderr=subprocess.STDOUT, stdout=subprocess.PIPE) # 合并
subprocess.run([...], stdout=subprocess.DEVNULL) # 丢弃
★ 子进程也有自己的缓冲问题:
它的 stdout 是管道 → 全缓冲 → 你可能读不到实时输出
✓ 对 Python 子进程:传 env={"PYTHONUNBUFFERED": "1"} 或加 -u
✓ 通用方案:用 pty(伪终端)骗它以为连着 tty
流式读取子进程输出(不等它跑完):
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1)
for line in p.stdout: # ★逐行实时读★
print(line, end="")
p.wait()
★ 死锁警告:同时用 PIPE 接 stdout 和 stderr 又只读其中一个
→ 另一个管道缓冲区写满(通常 64KB)后子进程★阻塞★,你在等它结束 → 死锁
✓ 用 p.communicate()(内部用线程/select 同时读两个),或把 stderr 合并进 stdout
测试里捕获输出:
pytest 的 capsys fixture(★最省事★):
def test_x(capsys):
print("hi")
assert capsys.readouterr().out == "hi\n"
capfd fixture 则在 ★fd 层★捕获(能抓到子进程和 C 扩展)
重定向要分清两个层次。Python 层的 contextlib.redirect_stdout(buf) 只是临时把 sys.stdout 指向别的对象——能抓到自己和第三方库的 print,但抓不到子进程的输出和 C 扩展直接往 fd 1 写的内容。要连这些一起重定向必须在 fd 层用 os.dup2(f.fileno(), 1)(记得先 os.dup(1) 保存好以便恢复)。捕获子进程输出用 subprocess.run(capture_output=True),但要注意两个坑:子进程自己也有缓冲问题(它的 stdout 是管道→全缓冲,所以读不到实时输出,对 Python 子进程要传 PYTHONUNBUFFERED=1 或 -u);以及死锁——同时用 PIPE 接 stdout 和 stderr 却只读其中一个,另一个管道缓冲区写满(通常 64KB)后子进程会阻塞,而你在等它结束,正确做法是用 communicate() 或把 stderr 合并进 stdout。测试里最省事的是 pytest 的 capsys(Python 层)和 capfd(fd 层,能抓子进程)。
五、编码与跨平台
sys.stdout.encoding 从哪来(优先级从高到低):
① PYTHONIOENCODING 环境变量 PYTHONIOENCODING=utf-8
② Python 3.15 起 / 或开启 UTF-8 模式(PYTHONUTF8=1、-X utf8)→ 恒为 UTF-8
③ 平台默认:
Linux/macOS:由 locale 决定(通常 UTF-8;★但 LANG=C 时是 ASCII★)
Windows:控制台代码页(★中文系统常见 cp936/GBK★)
三类经典乱码/报错:
① UnicodeEncodeError: 'gbk' codec can't encode character '\U0001f600'
→ Windows 控制台是 GBK,打印 emoji/生僻字直接崩
✓ PYTHONIOENCODING=utf-8,或 sys.stdout.reconfigure(encoding="utf-8")
✓ Windows 10+ 可以 chcp 65001 切到 UTF-8 代码页
✓ Python 3.15 起默认 UTF-8 模式,这个问题会大幅缓解
② Docker 里 UnicodeEncodeError: 'ascii' codec ...
→ 容器没设 locale,LANG 未定义 → 编码退化成 ASCII
✓ Dockerfile: ENV LANG=C.UTF-8 PYTHONIOENCODING=utf-8
③ 日志文件里出现 中文
→ 是 json.dumps 的 ensure_ascii=True 干的(和标准流无关)
✓ json.dumps(obj, ensure_ascii=False)
处理"编不出来的字符"(不想让程序崩):
sys.stdout.reconfigure(errors="replace") # 编不出来的显示成 ?
sys.stdout.reconfigure(errors="backslashreplace") # 显示成 \uXXXX
★ 日志输出用 replace/backslashreplace 是合理的(宁可显示不全,不要崩溃)
★ 但★数据输出★不要这么干(会静默损坏数据)
换行符:
文本模式下 newline=None(默认)→ 写入时 "\n" 转成 os.linesep
Windows 上就是 "\r\n" → ★往 stdout 输出二进制会被损坏★
✓ 二进制一律走 sys.stdout.buffer.write()
✓ 或 sys.stdout.reconfigure(newline="\n") 禁用转换
Windows 的其他差异:
- 控制台不支持 ANSI 转义序列(Win10 之前)→ 彩色输出要用 colorama
- sys.stdout.isatty() 在某些终端模拟器里判断不准
- fd 号在 Windows 上是 CRT 层的抽象,os.dup2 行为略有不同
编码是跨平台的老大难。sys.stdout.encoding 的来源优先级是:PYTHONIOENCODING 环境变量 > UTF-8 模式 > 平台默认(Linux 看 locale、Windows 看控制台代码页,中文系统常见 GBK)。三类经典问题:Windows 上打印 emoji 直接 UnicodeEncodeError(解法是 PYTHONIOENCODING=utf-8 或 reconfigure(encoding="utf-8"));Docker 里因为没设 locale 而退化成 ASCII(解法是 ENV LANG=C.UTF-8);以及日志里出现 中文(那是 json.dumps 的 ensure_ascii 干的,和标准流无关)。想让程序在遇到编不出来的字符时不崩溃可以设 errors="replace"——日志输出这样做是合理的,但数据输出绝不能(会静默损坏数据)。最后再次强调换行符:Windows 文本模式会把 \n 转成 \r\n,所以往 stdout 输出二进制必须走 sys.stdout.buffer。
六、管道、BrokenPipeError 与命令行工具惯例
BrokenPipeError:下游提前关闭了管道
python gen.py | head -3
→ head 读够 3 行就退出并关闭读端
→ gen.py 继续 write → 内核发 SIGPIPE;Python 默认忽略 SIGPIPE,
转而让 write 返回 EPIPE → ★BrokenPipeError★
★ 标准处理方式(不是 bug,是正常的协作方式):
try:
main()
except BrokenPipeError:
# 把 stdout 重定向到 devnull,避免解释器退出时再次 flush 又报错
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, sys.stdout.fileno())
sys.exit(1) # ★或 141(128+SIGPIPE),遵循 shell 惯例★
★ 如果不处理:程序退出时 Python 会尝试 flush 缓冲区 → 再次 BrokenPipeError
→ 打印一堆 "Exception ignored in: <_io.TextIOWrapper ...>" 的丑陋信息
写 Unix 风格命令行工具的惯例(一并记住):
① 数据写 stdout,诊断写 stderr
② 没有文件参数时★从 stdin 读★(能被管道使用)→ 用 fileinput 或 sys.stdin
③ 退出码:0 成功、非 0 失败(★调用方靠它判断★)
④ isatty() 为 False 时:不上色、不显示进度条、不交互提问
⑤ 处理 BrokenPipeError(能和 head/less 配合)
⑥ 支持 "-" 表示 stdin/stdout(惯例:cat - 从标准输入读)
⑦ 大输入流式处理,不要 read() 全读进内存
模板:
def main(argv=None):
args = parse(argv)
src = sys.stdin if args.input == "-" else open(args.input)
try:
for line in src: # ★流式★
out = transform(line)
if out: print(out) # 数据 → stdout
except BrokenPipeError:
os.dup2(os.open(os.devnull, os.O_WRONLY), 1)
return 141
except Exception as e:
print(f"错误: {e}", file=sys.stderr) # 诊断 → stderr
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
交互式输入的注意点:
input() 在 EOF(管道读完 / Ctrl-D)时抛 ★EOFError★,不是返回空串
✓ try: s = input() except EOFError: 结束
密码输入用 getpass.getpass()(★不回显,且会直接读 tty 而不是 stdin★)
非交互环境(CI、容器)里 input() 会立刻 EOFError → ★脚本要能非交互运行★
管道场景下最常见的异常是 BrokenPipeError:python gen.py | head -3 时,head 读够就退出并关闭管道读端,上游继续写就会触发它——这不是 bug,而是 Unix 管道的正常协作方式。标准处理是捕获它、把 stdout 重定向到 devnull(避免解释器退出时再次 flush 又报一次错,否则会打印一堆 Exception ignored in: <_io.TextIOWrapper>),然后以 141(128+SIGPIPE)或 1 退出。写 Unix 风格命令行工具还有一串惯例值得一并记住:数据 stdout / 诊断 stderr、没有文件参数时从 stdin 读、退出码 0 表示成功、isatty() 为假时不上色不交互、支持 - 表示标准输入、大输入流式处理。最后两个交互细节:input() 在 EOF 时抛 EOFError 而不是返回空串(管道读完或 CI 里会立刻触发,所以脚本必须能非交互运行),密码输入要用 getpass.getpass()(不回显,而且它直接读 tty 而不是 stdin)。
记忆钩子:「三个标准流 stdin(0)/stdout(1)/stderr(2),★分成两个输出流的唯一理由是能被分别重定向★——判断准则是『这行是不是程序的产出』:是就 stdout(会被下游管道消费),否则一律 stderr(进度/日志/警告/错误);把进度条打进 stdout 会让
gen.py | wc -l算错。★本题核心考点:缓冲策略由『输出到哪里』决定而不是由代码决定★——stdout 连 tty 是行缓冲、连管道/文件是★全缓冲(8KB)★,stderr 从 3.9 起始终行缓冲。这就是『终端跑得好好的,一 > log.txt 或进 Docker 就看不到日志』的原因,而且 ★kill -9 时缓冲区里的日志会永久丢失★(正常退出才 flush)。解法按推荐度:容器里 ENV PYTHONUNBUFFERED=1、python -u、sys.stdout.reconfigure(line_buffering=True)、print(flush=True)、或让日志走 stderr(logging.StreamHandler ★默认就是 stderr★);代价是牺牲吞吐。★sys.stdout 是三层结构★:TextIOWrapper(编码+换行符转换+行缓冲)→ BufferedWriter → FileIO(fd=1),所以★写二进制必须用 sys.stdout.buffer★(Windows 文本层会把 \n 转成 \r\n 损坏数据),os.write(1,…) 可绕过所有缓冲。★重定向分两层★:contextlib.redirect_stdout 只改 sys.stdout(抓不到子进程和 C 扩展),要连它们一起重定向得用 os.dup2 在 fd 层做。管道里| head -3会触发 ★BrokenPipeError★(正常协作,不是 bug),要捕获并把 stdout 指向 devnull 再退出,否则解释器退出时 flush 会再报一次错。其他:subprocess 同时 PIPE 接 stdout 和 stderr 却只读一个会★死锁★(用 communicate);彩色/进度条前先 isatty();input() 遇 EOF 抛 EOFError;Windows 控制台是 GBK,用 PYTHONIOENCODING=utf-8 或 reconfigure(encoding=‘utf-8’)。」
七、常见误区与追问
- 误区:程序在终端里日志输出正常,说明重定向到文件也一样正常。 缓冲策略取决于「输出到哪里」,代码一个字都没改行为就变了:stdout 连着终端(tty)时是行缓冲(每个换行就刷出来),连着管道或文件时是全缓冲(攒够约 8KB 才真正写)。所以
python app.py > log.txt、nohup、Docker、CI 这些场景下日志会长时间不出现——它还在缓冲区里。最严重的后果是进程被kill -9(或段错误、os._exit())时缓冲区内容永久丢失,恰恰是出问题时最需要的那段日志没了(正常退出和未捕获异常时 Python 会 flush,所以容易产生「平时没事」的错觉)。标准解法是容器镜像里设ENV PYTHONUNBUFFERED=1,或用python -u、sys.stdout.reconfigure(line_buffering=True)。 - 误区:所有输出用
print()打到 stdout 就行,反正终端上都看得见。 终端上看着一样,一旦被管道或重定向使用就完全不同。stdout 是「程序的数据产出」,会被下游命令消费:把进度条、警告、调试信息打进 stdout,python gen.py | wc -l就会把它们一起数进去、python gen.py > data.csv生成的 CSV 里会混进「处理中…」这样的行——数据被污染。判断标准只有一句:这行内容是不是程序要产出的数据?是就 stdout,否则一律print(..., file=sys.stderr)。顺带记住 Pythonlogging的StreamHandler()默认写 stderr(符合「日志是诊断信息」的定位),如果你的日志采集系统只收 stdout,需要显式写StreamHandler(sys.stdout)。 - 误区:
sys.stdout = io.StringIO()或contextlib.redirect_stdout()能捕获所有输出。 它们只作用于 Python 层——原理是把sys.stdout这个名字临时指向别的对象,所以能抓到你自己和第三方 Python 库的print/sys.stdout.write,但抓不到两类输出:① 子进程的输出(子进程继承的是 fd 1,根本不看父进程的sys.stdout变量);② C 扩展直接往 fd 1 写的内容(很多原生库会绕过 Python 的 IO 层)。要把这些也一起重定向,必须在 fd 层操作:os.dup2(f.fileno(), 1)(记得先saved = os.dup(1)以便恢复)。pytest 里对应两个 fixture:capsys(Python 层)和capfd(fd 层,能抓子进程)。 - 误区:
python gen.py | head -3报BrokenPipeError是我的程序有 bug。 这是 Unix 管道的正常协作方式:head读够 3 行就退出并关闭管道读端,上游继续写时内核会发 SIGPIPE——C 程序默认因此被静默终止,而 Python 默认忽略 SIGPIPE,改为让write返回 EPIPE 并抛出BrokenPipeError。正确做法是捕获它并优雅退出:把 stdout 重定向到os.devnull再sys.exit(1)(或 141 = 128+SIGPIPE)——关键是那次重定向,否则解释器退出时会尝试 flush 缓冲区、再次触发BrokenPipeError,在终端上打印出一堆Exception ignored in: <_io.TextIOWrapper name='<stdout>' ...>的丑陋信息。凡是可能输出大量行、会被head/less/grep -q消费的命令行工具,都应该处理这个异常。 - 误区:
print(binary_data)或sys.stdout.write(data)可以往标准输出写二进制。sys.stdout是TextIOWrapper(文本流),只接受str:写bytes会TypeError,而把二进制先decode再写会因编码失败或字符替换而损坏数据。更隐蔽的是换行符转换——文本模式下newline=None会把\n转成os.linesep,在 Windows 上就是\r\n,于是输出的 PNG、gzip 流里每个0x0A都变成了0x0D 0x0A,文件彻底损坏(这是「Linux 上好好的、Windows 上文件打不开」的经典原因)。正确写法是走缓冲层:sys.stdout.buffer.write(data)(读二进制同理用sys.stdin.buffer.read())。如果需要在同一段代码里混用文本和二进制输出,切换前要先sys.stdout.flush(),否则两层各自的缓冲会让输出顺序错乱。 - 追问:
cmd > f 2>&1和cmd 2>&1 > f有什么区别? 效果完全不同,因为 shell 的重定向是从左到右依次生效的,2>&1的含义是「让 fd 2 指向 fd 1 此刻所指的地方」(复制的是当前指向,不是建立永久链接)。cmd > f 2>&1:先把 fd 1 指向文件 f,再让 fd 2 指向「fd 1 现在指的地方」= f,两个流都进文件 ✓。cmd 2>&1 > f:先让 fd 2 指向「fd 1 现在指的地方」= 终端,再把 fd 1 指向 f——结果是 stdout 进文件、stderr 仍在终端 ✗。bash 里&> f是第一种写法的简写。这个知识点在排查「日志怎么没收全」时非常实用,也是运维和后端面试的常客。对应到 Python 的subprocess,stderr=subprocess.STDOUT表达的就是第一种语义(stderr 并入 stdout 的目标)。 - 追问:为什么
subprocess同时用 PIPE 接 stdout 和 stderr 会死锁? 因为管道的缓冲区是有限的(Linux 上通常 64KB)。当你写成p = Popen(cmd, stdout=PIPE, stderr=PIPE)然后只p.stdout.read()时:如果子进程往 stderr 写的数据超过 64KB,stderr 管道就写满了,子进程在 write 上阻塞;而子进程被阻塞就不再往 stdout 写,你的read()也永远等不到 EOF——双方互相等待,死锁。同样的道理,p.wait()之前不读管道也会死锁。正确做法有三种:① 用p.communicate()(内部用线程或select同时读两个管道,这是官方推荐);② 把 stderr 合并进 stdout(stderr=subprocess.STDOUT),只读一个流;③ 用subprocess.run(capture_output=True)(内部就是communicate)。另外要注意子进程自己也有缓冲问题:它的 stdout 是管道所以是全缓冲,想要实时读取输出,对 Python 子进程要传-u或env={"PYTHONUNBUFFERED": "1"},通用方案是用伪终端(pty)骗它以为连着 tty。 - 追问:写命令行工具时,怎么判断该不该输出彩色和进度条? 用
sys.stdout.isatty():返回True说明输出连着交互式终端(人在看),可以用 ANSI 转义序列上色、显示进度条、做光标控制;返回False说明被重定向到文件或管道(机器在读),此时必须输出纯文本——否则日志文件里会塞满\033[32m这样的乱码,下游的grep、awk也会因为转义序列而匹配失败。同一判断还应该控制:是否交互提问(非交互环境里input()会立刻EOFError,脚本必须能全自动运行,改用命令行参数或--yes标志)、是否显示动画/刷新同一行、输出格式(终端给人看的表格 vs 管道用的 TSV/JSON)。成熟工具还会提供--color=auto|always|never让用户覆盖自动判断(CI 里常常需要always来保留颜色)。注意 stdout 和 stderr 要分别判断——常见情况是 stdout 被重定向到文件而 stderr 仍连着终端,此时进度条(走 stderr)该上色、数据(走 stdout)不该。
八、加强记忆
三个标准流 stdin(fd 0)、stdout(fd 1)、stderr(fd 2) 是 Unix 管道哲学的基础设施,分成两个输出流的唯一理由是「能被分别重定向」——判断准则只有一句:这行是不是程序的产出?是就 stdout(会被下游管道消费),否则一律 stderr(进度、日志、警告、错误);把进度条打进 stdout 会让 gen.py | wc -l 算错,而 logging.StreamHandler() 默认就写 stderr。本题核心考点是缓冲:策略由「输出到哪里」决定而不是由代码决定——stdout 连 tty 是行缓冲、连管道/文件是全缓冲(约 8KB),stderr 从 Python 3.9 起始终行缓冲。这就是「终端里跑得好好的,一 > log.txt 或进 Docker 就看不到日志」的根源,而且 kill -9 时缓冲区里的日志会永久丢失(只有正常退出才 flush)。解法按推荐度:容器里 ENV PYTHONUNBUFFERED=1、python -u、sys.stdout.reconfigure(line_buffering=True)、print(flush=True),代价是牺牲吞吐。sys.stdout 是三层结构:TextIOWrapper(编码 + 换行符转换 + 行缓冲)→ BufferedWriter → FileIO(fd=1),所以写二进制必须用 sys.stdout.buffer.write()(Windows 文本层会把 \n 转成 \r\n、损坏 PNG 之类的数据),os.write(1, ...) 可绕过所有缓冲层。重定向分两层:contextlib.redirect_stdout 只改 sys.stdout(抓不到子进程和 C 扩展),要连它们一起重定向必须用 os.dup2 在 fd 层做(pytest 对应 capsys 与 capfd)。管道场景里 | head -3 会触发 BrokenPipeError(这是正常协作不是 bug),要捕获、把 stdout 指向 devnull 再退出,否则解释器退出时 flush 会再报一次错。其余高频点:subprocess 同时用 PIPE 接 stdout 和 stderr 却只读一个会死锁(管道缓冲区 64KB 写满,用 communicate() 或合并流);彩色和进度条前先 isatty();input() 遇 EOF 抛 EOFError;Windows 控制台常是 GBK,用 PYTHONIOENCODING=utf-8 或 reconfigure(encoding="utf-8"),容器里还要 ENV LANG=C.UTF-8;cmd > f 2>&1 与 cmd 2>&1 > f 效果不同(重定向从左到右生效)。