文件 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. 可带走的
- 中小文件
os.ReadFile;大文件/随机访问os.Open+Seek;文本流bufio.Scanner。 - 缓冲是第一优化:读 176×、写 170× 的差距全在系统调用次数;pprof 的 top 一眼定位。
- 缓冲大小 4 KB 起步,32–256 KB 是经验甜点区,再大收益饱和、内存白涨。
- 打开标志三选:Create(可截断)、O_EXCL(防覆盖)、O_APPEND(日志)。
- 缓冲写
defer writer.Flush(),要 durability 再file.Sync()。 count(r io.Reader)模式:函数收接口,文件和 stdin 通吃,工具天然可进管道。- 行/词/字符三档读法:ScanLines / ScanWords / ReadRune;字符档按 rune,不按 byte。
- 日志用 slog + JSON Handler;需要多路输出先想 multiWriter(或第 14 章的官方方案)。
- 一个工具一趟扫描把能数的都数出来(wc 的 lesson),别为三组统计读三遍文件。
14. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 「实现你需要的工具」学习法 | When learning, implement what you need or like | text/58-fm-when-learning-implement-what-you-need-or-like.txt:11(搜「one of the best ways」) |
| stdin/stdout/stderr | When learning, implement what you need or like | text/58-fm-when-learning-implement-what-you-need-or-like.txt:26(搜「three files open all the time」) |
| 路径存在检查三岔判断 | Checking whether a file path exists | text/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 与 Seek | Reading files | text/60-fm-reading-files.txt:35(搜「os.ReadFile」) · :85(搜「seekAndRead」) · :130(搜「e, and eff」) |
| 缓冲读机制与 4 KB 默认值 | Reading files | text/60-fm-reading-files.txt:212(搜「defaults to an internal 4 KB buffer」) |
| 读 benchmark 176× | Reading files | text/60-fm-reading-files.txt:271(搜「3571846500」) · :282(搜「58 iterations」) |
| pprof 采集与 99.75% 铁证 | Reading files | text/60-fm-reading-files.txt:308(搜「StartCPUProfile」) · :456(搜「syscall.syscall」) · :489(搜「196x faster」) |
| 行/词/字符三档读法 | Reading files | text/60-fm-reading-files.txt:507(搜「readLines」) · :548(搜「readWords」) · :588(搜「readChars」) |
| 创建/独占创建/追加三模式 | Writing files | text/61-fm-writing-files.txt:69(搜「os.Create」) · :134(搜「O_EXCL」) · :199(搜「O_APPEND」) · :169(搜「already exists」) |
| slog 与 multiWriter | Writing files | text/61-fm-writing-files.txt:258(搜「log/slog」) · :271(搜「type multiWriter struct」) · :398(搜「Application started」) |
| 写 benchmark 与缓冲 sweep | Writing files | text/61-fm-writing-files.txt:523(搜「901584062」) · :559(搜「BenchmarkBufferedWrite_1MB_512」) · text/62-fm-benchmarking-file-copying.txt:9(搜「57% performance improvement」) |
| 拷贝策略对比 | Benchmarking file copying | text/63-fm-converting-tabs-to-spaces-and-vice-versa.txt:8(搜「6405221」) · :20(搜「consistently fast」) |
| tab/空格互转 parseMode | Converting tabs to spaces and vice versa | text/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
-
出处:「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」。 ↩ -
出处:「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」)。 ↩ -
出处:「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」。 ↩ -
出处:「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」)。 ↩ -
出处:「Reading files」第 29–51 段(
text/60-fm-reading-files.txt:35,搜「os.ReadFile」;:48,搜「a streaming approach may be preferable」)。 ↩ -
出处:「File seeking」(
text/60-fm-reading-files.txt:85,搜「seekAndRead」;:130,搜「e, and eff」)。whence 三锚点见第 76–79 行。 ↩ -
出处:「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」。 ↩ -
出处:「Benchmarking buffered and unbuffered reading」(
text/60-fm-reading-files.txt:271,搜「3571846500」;:272,搜「20271705」)。 ↩ ↩2 -
出处:「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」)。 ↩ -
出处:「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 -
出处:「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」)。 ↩ -
出处:「Reading a file line by line」等三节(
text/60-fm-reading-files.txt:502,搜「ScanLines」;:556,搜「ScanWords」;:593,搜「ReadRune」)。 ↩ -
出处:「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 -
出处:「Buffered writing」(
text/61-fm-writing-files.txt:470,搜「writer.Flush()」;:471,搜「file.Sync()」)。 ↩ -
出处:「Benchmarking buffered and unbuffered writing」(
text/61-fm-writing-files.txt:523,搜「901584062」;:524,搜「5323956」;:525,搜「9318937125」;:526,搜「54047833」)。 ↩ ↩2 -
出处:「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 -
出处:「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 -
出处:「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」。 ↩ -
出处:「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」。 ↩ -
出处:「Converting tabs to spaces and vice versa」(
text/63-fm-converting-tabs-to-spaces-and-vice-versa.txt:47,搜「parseMode」;:72,搜「4tabs」)。 ↩ -
出处:「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 边界。 ↩