← 返回题目列表

标准输入输出怎么用?为什么重定向后日志就不见了?

中等 第 16 / 27 题 更新于 2026/07/31
标准流缓冲stdout管道

简化版

每个进程启动时自带三个标准流: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-8reconfigure(encoding="utf-8"))和管道被提前关闭时的 BrokenPipeError(下游 head -5 读够就退出了)。核心记忆:数据走 stdout、诊断走 stderr;管道/文件是全缓冲,容器里记得 PYTHONUNBUFFERED=1

详细版

三个标准流

fd用途缓冲(Python 3.9+)
stdin0输入交互时行缓冲
stdout1正常数据(可被管道消费)tty→行缓冲;管道/文件→全缓冲(8KB)
stderr2诊断/错误/进度(不该进管道)始终行缓冲
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.bufferTextIOWrapperBufferedWriterFileIO 三层结构);同理读二进制管道数据用 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>&1cmd 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 -ureconfigure(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-8reconfigure(encoding="utf-8"));Docker 里因为没设 locale 而退化成 ASCII(解法是 ENV LANG=C.UTF-8);以及日志里出现 中文(那是 json.dumpsensure_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 → ★脚本要能非交互运行★

管道场景下最常见的异常是 BrokenPipeErrorpython 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.txtnohup、Docker、CI 这些场景下日志会长时间不出现——它还在缓冲区里。最严重的后果是进程被 kill -9(或段错误、os._exit())时缓冲区内容永久丢失,恰恰是出问题时最需要的那段日志没了(正常退出和未捕获异常时 Python 会 flush,所以容易产生「平时没事」的错觉)。标准解法是容器镜像里设 ENV PYTHONUNBUFFERED=1,或用 python -usys.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)。顺带记住 Python loggingStreamHandler() 默认写 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 -3BrokenPipeError 是我的程序有 bug。 这是 Unix 管道的正常协作方式head 读够 3 行就退出并关闭管道读端,上游继续写时内核会发 SIGPIPE——C 程序默认因此被静默终止,而 Python 默认忽略 SIGPIPE,改为让 write 返回 EPIPE 并抛出 BrokenPipeError。正确做法是捕获它并优雅退出:把 stdout 重定向到 os.devnullsys.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.stdoutTextIOWrapper(文本流),只接受 str:写 bytesTypeError,而把二进制先 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>&1cmd 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 的 subprocessstderr=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 合并进 stdoutstderr=subprocess.STDOUT),只读一个流;③ 用 subprocess.run(capture_output=True)(内部就是 communicate)。另外要注意子进程自己也有缓冲问题:它的 stdout 是管道所以是全缓冲,想要实时读取输出,对 Python 子进程要传 -uenv={"PYTHONUNBUFFERED": "1"},通用方案是用伪终端(pty)骗它以为连着 tty。
  • 追问:写命令行工具时,怎么判断该不该输出彩色和进度条?sys.stdout.isatty():返回 True 说明输出连着交互式终端(人在看),可以用 ANSI 转义序列上色、显示进度条、做光标控制;返回 False 说明被重定向到文件或管道(机器在读),此时必须输出纯文本——否则日志文件里会塞满 \033[32m 这样的乱码,下游的 grepawk 也会因为转义序列而匹配失败。同一判断还应该控制:是否交互提问(非交互环境里 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=1python -usys.stdout.reconfigure(line_buffering=True)print(flush=True),代价是牺牲吞吐。sys.stdout 是三层结构TextIOWrapper(编码 + 换行符转换 + 行缓冲)→ BufferedWriterFileIO(fd=1),所以写二进制必须用 sys.stdout.buffer.write()(Windows 文本层会把 \n 转成 \r\n、损坏 PNG 之类的数据),os.write(1, ...) 可绕过所有缓冲层。重定向分两层contextlib.redirect_stdout 只改 sys.stdout抓不到子进程和 C 扩展),要连它们一起重定向必须用 os.dup2 在 fd 层做(pytest 对应 capsyscapfd)。管道场景里 | head -3 会触发 BrokenPipeError(这是正常协作不是 bug),要捕获、把 stdout 指向 devnull 再退出,否则解释器退出时 flush 会再报一次错。其余高频点:subprocess 同时用 PIPE 接 stdout 和 stderr 却只读一个会死锁(管道缓冲区 64KB 写满,用 communicate() 或合并流);彩色和进度条前先 isatty()input() 遇 EOF 抛 EOFErrorWindows 控制台常是 GBK,用 PYTHONIOENCODING=utf-8reconfigure(encoding="utf-8"),容器里还要 ENV LANG=C.UTF-8cmd > f 2>&1cmd 2>&1 > f 效果不同(重定向从左到右生效)。