编程语言研究书库

文件 I/O:缓冲为什么值 170 倍,以及亲手实现 wc(1)

文件 I/O:缓冲为什么值 170 倍,以及亲手实现 wc(1)

这一章回答一个问题:让程序和磁盘高效地交换字节,要注意什么? 答案是全章最反直觉也最有数字支撑的一条:慢的不是磁盘,是系统调用本身—— 每读一个字节就进一次内核,1 MB 要进 100 万次;加一层 4 KB 的内存缓冲, 进内核的次数降到约 250 次,速度差约 176 倍。 读完你会拿到读写的完整工具箱,外加一个自己实现的 wc(1)。

1. 这一章在原书里的位置

原书第二部分「系统编程 essentials」从本章开始。第一部分教你语言, 第二部分开始,Go 只是那把刀,主角是操作系统——文件系统、信号、网络。 本章的骨架就一句话:文件 I/O 的所有决策,最后都归结到「减少系统调用次数」。

作者开篇还给了个学习忠告,值得当成本书的元规则:学语言最好的方式是尽早 动手做一个自己需要的工具——他选的是重写 Unix 工具(wc、cp), 你也可以选游戏、REST 客户端、cron,关键是「需要或喜欢」1。

2. 顶层全景

  读                     写
  ├─ 整个读:os.ReadFile     ├─ os.Create(可截断旧文件)
  ├─ 随机读:Open + Seek     ├─ O_CREATE|O_EXCL(只在不存在时建)
  ├─ 流式读:Scanner 行/词   ├─ O_APPEND(追加,日志的基础)
  │   /Reader 字符          ├─ 缓冲写:bufio.Writer + Flush
  └─ 缓冲与否:差 ~176×      └─ 缓冲大小:512B→256KB 再快 57%,之后饱和

图说:左列四种读法按「数据量 vs 控制粒度」选;右列的打开标志决定你什么时候会 意外毁掉数据。缓冲是全章的隐形主线。

3. 先立地基:三个永远打开的文件

每个 Unix 进程一出生就带着三个打开的文件(文件描述符 0、1、2): 标准输入(stdin,程序读用户输入)、标准输出(stdout,正常结果)、 标准错误(stderr,错误信息)。Go 里对应 os.Stdin、os.Stdout、os.Stderr。 书里给了条可移植性建议:别写 /dev/stdout 这种路径,用 os.Stdout, 跨平台不出事2。

这个设计在第 1 章就露过面(which 和 basename 把错误写 stderr、结果写 stdout), 本章的 wc(1) 会把它的威力用足:count() 函数收 io.Reader—— 文件能传、标准输入能传、网络流也能传,这就是接口抽象在 I/O 上的兑现3。

4. 读写之前:先确认路径存在

os.Stat(path) 问文件系统「这个路径在不在、是什么」: err 为 nil → 在;errors.Is(err, fs.ErrNotExist) → 不在; 其他错误(权限等)→ 报 stderr、退 14。这个「三岔判断」模式后面会反复出现 ——文件系统是不可信的外部依赖,任何操作前都要预设它会失败。

5. 读的四种姿势

姿势一:整个读。 data, err := os.ReadFile(filename)——打开、读全部、关闭, 三步一体,返回值是 []byte。中小文件的默认选择;大文件会把内存撑爆5。

姿势二:随机读。 os.Open 返回 *os.File(文件描述符),配 Seek 移动读写指针: f.Seek(100, io.SeekStart) 把指针钉到第 100 字节,再 Read 读 N 个。 io.SeekCurrent(相对当前)和 io.SeekEnd(相对末尾,如 -10 = 倒数第 10 字节) 是另外两个锚点。书上实测:读 offset=100 的 10 字节,输出 e, and eff6。 断点续传、翻大文件的特定段落,都靠这个。

姿势三:无缓冲读。 file.Read(buffer) 直接把缓冲区塞满,每次调用都是一次系统调用。 buffer := make([]byte, 1) 时,每读一字节进一次内核——这是最笨也最诚实的基准线。

姿势四:缓冲读。 bufio.NewReader(file) 默认带 4 KB 内部缓冲 (正好对齐多数 OS 的磁盘块,NewReaderSize 可改),ReadByte() 从内存里取字节, 缓冲空了才触发一次系统调用补货7。

主线例子:同一文件,两种读法,pprof 把账算清

书上用同一个 1 MB 文件跑两种读法,先用 benchmark 计时8:

  BenchmarkUnbufferedRead-14     1 次   3571846500 ns/op   ← 3.57 秒,只跑完 1 次
  BenchmarkBufferedRead-14      58 次    20271705 ns/op   ← 20.3 毫秒,跑了 58 次

约 176 倍。 为什么?数字自己会说话:1 MB ÷ 1 字节 = 约 100 万次系统调用; 1 MB ÷ 4 KB = 约 250 次。每次系统调用都要从用户空间切进内核再切回来 (上下文切换 + 内核文件系统开销),跟磁盘快慢无关9。

再用 runtime/pprof 抓 CPU 剖面,铁证出现:

  syscall.syscall            27.62s  flat 99.75%   ← 全部时间都在系统调用里
  main.readUnbuffered        27.50s  cum           ← 无缓冲路径吃掉 99.31%
  main.readBuffered           0.14s  cum           ← 缓冲路径只占 0.51%
  (30.76 秒总采样里,readBuffered 一次只需要 0.14s——约 196×)

图说:pprof 的 top 命令把「时间花在哪一函数」列成表; flat 是本函数自身耗时,cum 是含子调用的累计。 这张表是全书最有说服力的一张:瓶颈不是 I/O 本身,是调用的次数10。

顺带一个 pprof 使用要点:CPU 剖面要 StartCPUProfile/StopCPUProfile 包一圈; 内存剖面常开,什么时候想存盘就 runtime.GC() 清一遍再 WriteHeapProfile11。

6. 行、词、字符:Scanner 的三个分身

bufio.Scanner 是流式读文本的瑞士刀,换「切分函数」就换粒度12:

粒度切分函数适用
行ScanLines(默认,无需设置)日志处理、逐行配置——任意大小文件常量内存
词ScanWords词频统计、token 解析
字符改用 bufio.Reader.ReadRune()词法分析、语法解析——按 rune 读,多字节字符不会切烂(回看 01 号的 ♥ 教训)

三者的骨架是同一个循环:for scanner.Scan() { 处理 scanner.Text() }, 出错查 scanner.Err()。注意行/词最终都装进切片,文件太大时同样要考虑内存—— 真·常量内存的处理应该边扫边处理,不落切片。

7. 写的四种模式

模式一:无条件创建/截断。 os.Create(fn) = OpenFile(fn, O_RDWR|O_CREATE|O_TRUNC), 已存在就清零重写(inode 不变)。危险但常用。

模式二:只在不存在时创建。 O_CREATE|O_EXCL|O_WRONLY: 文件已存在就直接报错(os.IsExist),不存在才建并写入——防手滑的防御式写法。 书上跑两次:第一次成功、第二次「already exists」13。 这个「检查+创建合成一个原子操作」是重点:自己先 Stat 再 Create 有竞态窗口, O_EXCL 把窗口封死。

模式三:追加。 O_APPEND|O_CREATE|O_WRONLY——写指针永远停在文件尾, 不存在就建。日志就是这个模式长大的。

模式四:缓冲写。 bufio.NewWriter(file) 攒内存,Flush() 一次性推给 OS, file.Sync() 再逼 OS 把它的缓存落盘。忘 Flush = 数据留在内存里丢了, 所以惯例 defer writer.Flush()14。

写 benchmark 与读同构:1 MB 逐字节写 901 毫秒 vs 缓冲 5.3 毫秒(约 170×); 10 MB 是 9.3 秒 vs 54 毫秒(约 172×)15。 缓冲大小的 sweep 更有意思(1 MB 文件):512 B → 7.26 ms,4 KB → 5.39 ms, 32 KB → 5.10 ms,256 KB → 4.61 ms——从 512 B 到 256 KB 快 57%, 但 32 KB 之后收益明显饱和:系统调用次数已经少到不是瓶颈,内存占用却在涨16。 经验区间书里给了:32–256 KB,按负载量,别迷信更大。

8. 日志:slog 与 multiWriter

写的一个真身是日志。两个包的定位:log(老式,字符串拼接,sync.Mutex 保并发安全)、 log/slog(Go 1.21 起,结构化日志)——key-value 对、可插拔 Handler、 禁用的日志级别零开销(编译期把 Disabled 级别的调用整个删掉,不分配、不格式化)17。

本章的巧思是自搓 multiWriter:一个聚合 []io.Writer 的结构, 它的 Write(p) 把同一份字节广播给所有目的地,一个失败不影响其他18。 配上 slog:

w := &multiWriter{writers: []io.Writer{file, os.Stdout, syslogWriter}}
logger := slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{}))
logger.Info("Application started")
// → /tmp/app.log、终端、syslog 各收到一份:
// {"time":"2025-09-29T07:29:25.397365+03:00","level":"INFO","msg":"Application started"}

JSON 行格式是刻意的:机器可解析、可检索,现代日志栈(ELK/Loki 类)直接吃。 (书里预告第 14 章有「更好的做法」——Go 1.21 内置的 slog.MultiHandler 路线。)

9. 主线例子(二):wc(1),一次扫描四个数

wc 数行、词、字节(我们还要字符)。书上的设计判断很直接: 写一个 count() 一趟全数,而不是三个函数各扫一遍文件——I/O 是这工具的成本大头19:

func count(r io.Reader) (lines, words, bytes, runes int) {
    buf := bufio.NewReader(r)                    // 接口参数:文件、stdin、网络流通吃
    for {
        line, err := buf.ReadString('\n')        // 按行切
        if err != nil && err != io.EOF { break } // 真错误
        if len(line) > 0 {
            lines++
            trimmed := strings.TrimRight(line, "\r\n")   // 兼容 Windows 的 \r\n
            words += countWordsInLine(trimmed)           // 空白分词
            bytes += len(line)
            runes += utf8.RuneCountInString(trimmed) + 1 // 字符数(不是字节数!)
        }
        if err == io.EOF { break }
    }
    return
}

图说:命名返回值(第 2 号拆解讲过)在这里让「一个函数四个结果」干净俐落; io.Reader 参数让 wc file.txt 和 cat file.txt | wc 是同一个程序—— 这就是 01 号拆解「Unix 工具的两条纪律」的完整兑现。 charByChar 一节还演示了「无参数时读 stdin」的惯例(input = os.Stdin), 你的工具天然可以进管道。

10. 边角例子:tab 与空格互转

t2s.go 的 parseMode 用一个字符串同时编码方向和宽度:"4tabs" = 每 4 空格合成 1 tab, "tabs4" = 每 1 tab 换成 4 空格——数字在 "tabs" 前还是后,决定方向20。 值得学的不是转换本身(一行 ReplaceAll 的事),而是「把两个维度(方向、宽度) 压进一个参数」的 CLI 设计取舍:省了 flag,但用户得读文档。

11. 作者的判断与证据

判断性质依据
缓冲 I/O 是默认正确选择benchmark 实证176×(读)/ 170×(写)两组数字815
瓶颈是系统调用次数,不是 I/O 速度pprof 实证syscall.syscall 99.75% flat10
缓冲 32–256 KB 后收益饱和buffer sweep 实证512B→256KB 快 57%,尾段趋平16
大文件用 io.Copy 最省心benchmark 对比io.Copy 6.4ms vs 手写 4KB 缓冲 9.5ms(1 MB)21
O_EXCL 把「检查+创建」变原子语言事实 + 防御式编程立场两次运行的输出对比13
结构化 JSON 日志优先作者立场(行业共识)slog 设计说明17

12. 边界与局限

  • Scanner 有行长上限(默认 64 KB,超了 ErrTooLong),大行长场景要调 Buffer 或退回 Reader——本章没提,是常见踩坑点。
  • copy benchmark 的一行疑似转录错误:「manual read-and-write…1 MB,at only 295 毫秒」与「10 MB…taking 2.1 毫秒」大小关系颠倒(2.1 ms 的 10 MB 比 295 ms 的 1 MB 还快,不合逻辑);按原始 benchmark 行(4150 次 295µs vs 486 次 2.1ms) 应为微秒。结论方向(手写适合小文件)不受影响,数字引用时留意。
  • file.Sync 的代价没展开:每次写后都 Sync 会把缓冲的收益吃掉一截, 「Flush 后要不要 Sync」是个 durability/throughput 的取舍题,书中默认全都要。
  • 日志一节的 syslog 是 Unix 专属,Windows 上要换后端——可移植性一笔带过。
  • 并发写文件没讲(slog 的 Handler 自己加锁;自己写文件要多 goroutine 写,得自己同步)。

13. 可带走的

  1. 中小文件 os.ReadFile;大文件/随机访问 os.Open + Seek;文本流 bufio.Scanner。
  2. 缓冲是第一优化:读 176×、写 170× 的差距全在系统调用次数;pprof 的 top 一眼定位。
  3. 缓冲大小 4 KB 起步,32–256 KB 是经验甜点区,再大收益饱和、内存白涨。
  4. 打开标志三选:Create(可截断)、O_EXCL(防覆盖)、O_APPEND(日志)。
  5. 缓冲写 defer writer.Flush(),要 durability 再 file.Sync()。
  6. count(r io.Reader) 模式:函数收接口,文件和 stdin 通吃,工具天然可进管道。
  7. 行/词/字符三档读法:ScanLines / ScanWords / ReadRune;字符档按 rune,不按 byte。
  8. 日志用 slog + JSON Handler;需要多路输出先想 multiWriter(或第 14 章的官方方案)。
  9. 一个工具一趟扫描把能数的都数出来(wc 的 lesson),别为三组统计读三遍文件。

14. 原文地图

主题原书章原文位置
「实现你需要的工具」学习法When learning, implement what you need or liketext/58-fm-when-learning-implement-what-you-need-or-like.txt:11(搜「one of the best ways」)
stdin/stdout/stderrWhen learning, implement what you need or liketext/58-fm-when-learning-implement-what-you-need-or-like.txt:26(搜「three files open all the time」)
路径存在检查三岔判断Checking whether a file path existstext/59-fm-checking-whether-a-file-path-exists.txt:23(搜「os.Stat」) · text/60-fm-reading-files.txt:7(搜「Path /bin/ls exists」)
os.ReadFile 与 SeekReading filestext/60-fm-reading-files.txt:35(搜「os.ReadFile」) · :85(搜「seekAndRead」) · :130(搜「e, and eff」)
缓冲读机制与 4 KB 默认值Reading filestext/60-fm-reading-files.txt:212(搜「defaults to an internal 4 KB buffer」)
读 benchmark 176×Reading filestext/60-fm-reading-files.txt:271(搜「3571846500」) · :282(搜「58 iterations」)
pprof 采集与 99.75% 铁证Reading filestext/60-fm-reading-files.txt:308(搜「StartCPUProfile」) · :456(搜「syscall.syscall」) · :489(搜「196x faster」)
行/词/字符三档读法Reading filestext/60-fm-reading-files.txt:507(搜「readLines」) · :548(搜「readWords」) · :588(搜「readChars」)
创建/独占创建/追加三模式Writing filestext/61-fm-writing-files.txt:69(搜「os.Create」) · :134(搜「O_EXCL」) · :199(搜「O_APPEND」) · :169(搜「already exists」)
slog 与 multiWriterWriting filestext/61-fm-writing-files.txt:258(搜「log/slog」) · :271(搜「type multiWriter struct」) · :398(搜「Application started」)
写 benchmark 与缓冲 sweepWriting filestext/61-fm-writing-files.txt:523(搜「901584062」) · :559(搜「BenchmarkBufferedWrite_1MB_512」) · text/62-fm-benchmarking-file-copying.txt:9(搜「57% performance improvement」)
拷贝策略对比Benchmarking file copyingtext/63-fm-converting-tabs-to-spaces-and-vice-versa.txt:8(搜「6405221」) · :20(搜「consistently fast」)
tab/空格互转 parseModeConverting tabs to spaces and vice versatext/63-fm-converting-tabs-to-spaces-and-vice-versa.txt:47(搜「parseMode」) · :72(搜「4tabs」)
wc(1) count()Developing wc(1)text/64-fm-developing-wc-1.txt:15(搜「func count」) · :2(搜「counts the number of lines」)

Footnotes

  1. 出处:「When learning, implement what you need or like」第 10–23 段(text/58-fm-when-learning-implement-what-you-need-or-like.txt:11,搜「one of the best ways」)。「implement something as soon as possible, but not sooner」。 ↩

  2. 出处:「When learning, implement what you need or like」第 25–38 段(text/58-fm-when-learning-implement-what-you-need-or-like.txt:26,搜「three files open all the time」;:37,搜「safer and more portable」)。 ↩

  3. 出处:「Working with File I/O」第 22–28 段(text/57-ch05.txt:23,搜「io.Writer and io.Reader」)。「a single function can accept any io.Reader or io.Writer interface without knowing the underlying type」。 ↩

  4. 出处:「Checking whether a file path exists」(text/59-fm-checking-whether-a-file-path-exists.txt:23,搜「os.Stat」;:29,搜「fs.ErrNotExist」;text/60-fm-reading-files.txt:7,搜「Path /bin/ls exists」)。 ↩

  5. 出处:「Reading files」第 29–51 段(text/60-fm-reading-files.txt:35,搜「os.ReadFile」;:48,搜「a streaming approach may be preferable」)。 ↩

  6. 出处:「File seeking」(text/60-fm-reading-files.txt:85,搜「seekAndRead」;:130,搜「e, and eff」)。whence 三锚点见第 76–79 行。 ↩

  7. 出处:「Buffered reading」第 200–218 段(text/60-fm-reading-files.txt:212,搜「4 KB buffer」)。「aligning the buffer size with this block size reduces wasted reads」。 ↩

  8. 出处:「Benchmarking buffered and unbuffered reading」(text/60-fm-reading-files.txt:271,搜「3571846500」;:272,搜「20271705」)。 ↩ ↩2

  9. 出处:「Profiling buffered and unbuffered reading」(text/60-fm-reading-files.txt:392,搜「1 million separate system calls」;:399,搜「around 250 system calls」;:393,搜「context switching」)。 ↩

  10. 出处:「Profiling buffered and unbuffered reading」(text/60-fm-reading-files.txt:456,搜「syscall.syscall」;:471,搜「99.75%」;:489,搜「196x faster」)。「it is the overhead of making thousands or millions of individual system calls that creates the bottleneck」。 ↩ ↩2

  11. 出处:「Profiling buffered and unbuffered reading」第 296–339 段(text/60-fm-reading-files.txt:308,搜「StartCPUProfile」;:333,搜「always tracking memory allocations」;:336,搜「runtime.GC() just before」)。 ↩

  12. 出处:「Reading a file line by line」等三节(text/60-fm-reading-files.txt:502,搜「ScanLines」;:556,搜「ScanWords」;:593,搜「ReadRune」)。 ↩

  13. 出处:「Creating a file if it does not exist」(text/61-fm-writing-files.txt:134,搜「O_EXCL」;:169,搜「already exists」)。「ensures that the process of checking for a file and creating it happens as a single operation」。 ↩ ↩2

  14. 出处:「Buffered writing」(text/61-fm-writing-files.txt:470,搜「writer.Flush()」;:471,搜「file.Sync()」)。 ↩

  15. 出处:「Benchmarking buffered and unbuffered writing」(text/61-fm-writing-files.txt:523,搜「901584062」;:524,搜「5323956」;:525,搜「9318937125」;:526,搜「54047833」)。 ↩ ↩2

  16. 出处:「Benchmarking buffer size in buffered writing」(text/61-fm-writing-files.txt:559,搜「BenchmarkBufferedWrite_1MB_512」)与「Benchmarking file copying」开头(text/62-fm-benchmarking-file-copying.txt:9,搜「57% performance improvement」;:25,搜「optimal buffer size range」)。 ↩ ↩2

  17. 出处:「System logging and logging to files」第 246–267 段(text/61-fm-writing-files.txt:258,搜「log/slog」;:263,搜「skip the logging call entirely」;:266,搜「virtually zero performance cost」)。 ↩ ↩2

  18. 出处:「System logging and logging to files」(text/61-fm-writing-files.txt:271,搜「type multiWriter struct」;:284,搜「func (m *multiWriter) Write」)。「a failure in one destination does not prevent the remaining sinks」。 ↩

  19. 出处:「Developing wc(1)」(text/64-fm-developing-wc-1.txt:2,搜「counts the number of lines」;:15,搜「func count」)。「Creating three separate functions…would require opening and reading the same file three times」。 ↩

  20. 出处:「Converting tabs to spaces and vice versa」(text/63-fm-converting-tabs-to-spaces-and-vice-versa.txt:47,搜「parseMode」;:72,搜「4tabs」)。 ↩

  21. 出处:「Benchmarking file copying」(text/63-fm-converting-tabs-to-spaces-and-vice-versa.txt:8,搜「6405221」;:10,搜「9519117」;:20,搜「consistently fast」)。1 MB 手动循环的原始 benchmark 行为 4150 295479 ns/op 与 486 2139259 ns/op,正文叙述疑似把 µs 误写为 ms,见 §12 边界。 ↩

On this page