bufio — 缓冲 I/O
bufio 在 io.Reader / io.Writer 之上加一层内存缓冲,把"很多次小读写"合并成"很少几次大读写",从而摊薄系统调用开销。它还提供了三个日常最高层的工具:Reader 的按分隔符读取(ReadString/ReadBytes/Peek)、Scanner 的按 token 迭代、Writer 的攒批写入。
这一篇重点是三件在真实代码里反复出事的事:Scanner 的 64KB 上限(默认配置下读长行会静默失败)、Flush/Reset 的丢数据语义、以及 ReadSlice 返回的切片是内部缓冲区的别名。性能部分给的是 go1.27.0 / Windows 实测数字,不是抄来的"5x 提升"。
1. 为什么需要缓冲:一组实测
同一个进程里向临时文件写 100,000 行(每行 81 字节):
// 直接写 os.File:每次 Write 都是一次系统调用
for i := 0; i < 100000; i++ {
f.Write(line)
}
// 经 bufio.Writer:默认 4096 字节缓冲,攒满才写一次
w := bufio.NewWriter(f)
for i := 0; i < 100000; i++ {
w.WriteString(line)
}
w.Flush()实测:直接写 373ms,加缓冲 25ms(约 14.7 倍),两份文件内容完全一致(8,100,000 字节)。读取方向同理——Read 一个字节一个字节地调 os.File,每次都要穿越 Go 运行时到内核的边界;bufio 把这个边界成本按缓冲大小摊掉。
两个推论值得记住:
- 缓冲的收益来自调用次数,不是带宽。大块读写(比如
io.Copy之间传 1MB 的[]byte)本身次数就少,再加一层bufio只是多一次内存拷贝。 - 反过来说,“我读的是本地 SSD,慢不了"不成立——上面 373ms 里时间几乎全花在 10 万次调用开销上,磁盘本身没那么慢(操作系统页缓存兜着)。
2. bufio.Reader
reader := bufio.NewReader(file) // 默认 4096 字节
reader := bufio.NewReaderSize(file, 64*1024) // 64KB
line, err := reader.ReadString('\n') // 读到分隔符为止,返回值包含分隔符
buf, err := reader.ReadBytes('\n') // 同上,返回 []byte
head, err := reader.Peek(4) // 预览前 4 字节,不消费
reader.Discard(1024) // 跳过 1024 字节
reader.Size() // 缓冲区容量
reader.Buffered() // 当前已缓冲、尚未消费的字节数实测细节:
- 缓冲区有下限:
NewReaderSize(strings.NewReader("hello world"), 4)得到的Size()是 16,不是你传的 4——小于minReadBufferSize(16)会抬到 16。Peek(2)返回"he",同时Buffered()已经是 11(整个输入一次就读完了,因为源比缓冲区还短)。 Peek(n)超过缓冲能力会失败:n大于缓冲区大小时返回bufio.ErrBufferFull。想稳定预览超长前缀,自己保证缓冲区够大。ReadString/ReadBytes在 EOF 时会"给数据也给错”:读到'\n'之前输入结束,实测ReadString('\n')返回剩余内容 和io.EOF(errors.Is(err, io.EOF)为 true)。所以标准处理姿势是先拿n/数据再判错,而不是if err != nil { break }把最后一行丢掉——这正好和io.Reader的契约一致(见io-interfaces.md)。ReadByte+UnreadByte可以回退一个字节:实测读走'a'后UnreadByte,下一个Read拿到的字符串以'b'开头(回退的是刚读的那个字节,不是回到调用前)。
2.1 ReadSlice / ReadBytes / ReadString 的别名陷阱
| 方法 | 返回值 | 关键差异 |
|---|---|---|
ReadSlice(delim) |
[]byte,指向内部缓冲区 |
下一次任何读取操作后数据可能被覆盖;找不到分隔符时报 ErrBufferFull |
ReadBytes(delim) |
[]byte,独立拷贝 |
语义 = ReadSlice + 复制,找不到分隔符也能跨缓冲区拼出完整结果 |
ReadString(delim) |
string,独立拷贝 |
等价于 string(ReadBytes(delim)) |
ReadSlice 不是"更快版的 ReadBytes",而是把内部切片的有效期交给了你:任何对同一个 Reader 的后续操作都可能覆写它。文档给的合法用法只有一种——在下一次读取之前把这个切片用完(典型如 http.Request 读 header 时逐行解析就内部用 ReadSlice 立即处理完)。90% 的业务代码应该直接用 ReadString 或 ReadBytes;真正需要零拷贝时,更稳的方案是 bufio.Scanner(它的 Bytes() 也提示拷贝,见 §4)或自管 ReadFull。
3. bufio.Writer
w := bufio.NewWriter(f) // 默认 4096
w := bufio.NewWriterSize(f, 64*1024)
w.WriteString("hello\n")
w.Write([]byte("world\n"))
w.WriteByte('!')
if err := w.Flush(); err != nil { ... } // ← 不落盘就是没写三条必须刻进肌肉记忆的语义:
- 忘
Flush= 丢数据。这是bufio.Writer的头号事故来源,尤其当 writer 包着gzip.Writer/json.Encoder时——外层Close只关 gzip footer,不会替你 Flush 内层,反过来也一样。defer w.Flush()加显式 Flush 是常见保险写法(Close会隐式 Flush 的类型如gzip.Writer另算,见compress-archive.md)。 Reset丢弃缓冲区里尚未 Flush 的内容。实测:NewWriterSize(&buf, 8)写 10 字节(自动溢出落盘),Flush,再写"abc",然后Reset(&buf)——结果"abc"没了。Reset是给复用准备的,不是"切换目标并保留数据"。- 写超长数据会自动触发 Flush 旁路:实测向 8 字节缓冲的 Writer
WriteString一个 10 字节的串,返回n=10, err=nil,且底层bytes.Buffer当场就有全部内容——不是失败也不是截断。但别依赖"溢出时一定会刷干净"来做一致性保证,正常收尾还是得 Flush。
Available()(剩余空间)和 Buffered()(已缓存量)偶尔用于"攒够一批再发"的场景,日常日志代码用不上。
4. bufio.Scanner:按行读取的默认选择
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text() // 不含行尾换行符
process(line)
}
if err := scanner.Err(); err != nil { // ← 不检查就是静默截断
log.Fatal(err)
}Scan() 返回 false 有两种原因:正常到 EOF,或者出错——它不把错误塞进循环,必须查 Err()。这是与 for line, err := reader.ReadString('\n'); err == nil {} 最大的写法差异。
4.1 64KB token 上限(最重要的默认值)
实测一条 70KB 的行喂给默认配置的 Scanner:
sc.Scan() // false
sc.Err() // bufio.Scanner: token too long
errors.Is(sc.Err(), bufio.ErrTooLong) // true
errors.Is(sc.Err(), io.EOF) // false —— 别用 ==io.EOF 判结束
// bufio.MaxScanTokenSize = 65536报错发生在跨过上限的那一刻,之前已经产出的 token 仍然有效,所以现象经常是"日志文件处理到一半停了"。处理不受控内容(用户上传、别人的日志、含 base64 图片的行)时提高上限:
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 初始 64KB,最大 10MB实测两点:上限提到 1MB 后同一条 71680 字节的行正常返回;Buffer(nil, 1<<20) 也可以用(initial 传 nil 不会 panic,Scanner 会自己分配)。注意 max 是硬上限,超过仍然报 ErrTooLong,不是"自动扩到 10MB"。如果连 10MB 都不够(比如整文件就是一行 JSON),Scanner 就不是合适的工具,换 io.ReadAll(小文件)或 json.Decoder(流式)。
4.2 内置 Split 函数的实测差异
对同一份输入 "hi 你\t好"(含中文和制表符),四种内置策略的输出:
ScanLines(默认)→ 1 个 token:"hi 你\t好"(只按\n切,\r\n会去掉\r)ScanRunes→ 6 个:"h" "i" " " "你" "\t" "好"—— 按有效 UTF-8 码点切,中文是一个 tokenScanWords→ 3 个:"hi" "你" "好"—— 按unicode.IsSpace切,空格/\t/\n都算ScanBytes→ 10 个:"h" "i" " " "\xe4" "\xbd" "\xa0" ...—— “你” 的 3 个 UTF-8 字节各成一体
选型提示:ScanWords 适合词频统计(天然跳空白),ScanRunes 适合按字符处理,ScanBytes 适合二进制协议里逐字节扫描(这时通常还不如自己 ReadByte)。
4.3 自定义 SplitFunc
签名:func(data []byte, atEOF bool) (advance int, token []byte, err error)
| 返回值 | 含义 |
|---|---|
advance |
消费掉 data 前多少字节;返回 0 且 err=nil 表示"数据不够,请再读" |
token |
本次产出的内容(给 Bytes()/Text()),可以为 nil(“跳过这段,不产出”) |
err |
非 nil 立即终止;返回 bufio.ErrFinalToken 可以在不报错的情况下提前结束迭代 |
data 是缓冲区里未处理的部分,不是整个输入,可能不含完整 token——这就是为什么"没找到分隔符"时要区分 atEOF。写自定义 SplitFunc 最容易犯的错:atEOF == false 且没找到分隔符时返回 (len(data), nil, nil),等于把还没拼完的数据扔了;正确做法是 (0, nil, nil) 等更多数据。
两个完整例子:
// 按空行分段(段落迭代器)
func ScanParagraph(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, []byte("\n\n")); i >= 0 {
return i + 2, bytes.TrimRight(data[:i], "\r"), nil
}
if atEOF {
return len(data), bytes.TrimRight(data, "\r"), nil
}
return 0, nil, nil
}
// 提取 <tag>...</tag> 内容:注意跨缓冲区边界的"半个标记"
func ScanTag(data []byte, atEOF bool) (advance int, token []byte, err error) {
start, end := []byte("<tag>"), []byte("</tag>")
si := bytes.Index(data, start)
if si < 0 {
if atEOF {
return len(data), nil, nil // 放弃剩余
}
// 保留末尾 len(start)-1 字节,防止标记被截断在缓冲区边界
if keep := len(data) - (len(start) - 1); keep > 0 {
return keep, nil, nil
}
return 0, nil, nil
}
if ei := bytes.Index(data[si+len(start):], end); ei >= 0 {
s := si + len(start)
return s + ei + len(end), data[s : s+ei], nil
}
if atEOF {
return len(data), nil, nil
}
return 0, nil, nil // 等结束标记
}“半个标记跨缓冲区"是自定义 SplitFunc 的经典 bug:如果简单地在"没找到起始标记"时返回 (0, nil, nil),而输入里根本没有起始标记,Scanner 会永远读下去直到 ErrTooLong;上面用 advance = len(data)-len(start)+1 的方式把几乎不可能成为标记前缀的数据丢掉,才能推进。
Bytes() 返回的 token 同样只在下一次 Scan() 前有效(它也可能是内部缓冲的别名);要留存就 Text()(返回 string,独立拷贝)或自己 copy。
5. 与标准库的粘合层
bufio 经常不是目的,而是把两个东西接起来:
// 1) 手写协议:Reader 侧用 bufio.Reader,Writer 侧按需
br := bufio.NewReader(conn)
for {
line, err := br.ReadString('\n')
if len(line) > 0 { handle(strings.TrimRight(line, "\r\n")) }
if err != nil { return err } // 含 EOF
}
// 2) 文件 + JSON Lines / CSV 的逐行处理
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var rec Record
if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil { ... }
}
// 3) 换行符结尾的计数:io.Copy 到 bufio.Writer 中间插计数bufio.Scanner 与外部 bufio.Reader 不要混用同一个源:Scanner 内部会预读并消费它看不到的数据,两边各自的状态一交叉就漏行。需要"Scanner 处理完剩下的交给别人"时,把剩下的字节用 scanner.Bytes() 之外再补一个 io.MultiReader(bytes.NewReader(rest), src) 交出去,或者干脆全程用同一个抽象。
6. 陷阱汇总
| # | 现象 | 原因与对策 |
|---|---|---|
| 1 | 日志处理到一半"没输出"就结束 | 单行超 64KB,Err() 是 ErrTooLong。scanner.Buffer 提上限,或换读取方式(§4.1) |
| 2 | 循环正常退出但数据少了 | 忘了 scanner.Err(),把错误当 EOF(§4) |
| 3 | 程序结束发现文件没内容/内容截断 | bufio.Writer 没 Flush(§3.1) |
| 4 | Reset 复用 Writer 后少了尾巴数据 |
Reset 丢弃未 Flush 内容(实测 §3.2) |
| 5 | 保存的 ReadSlice 结果变成乱码 |
它是内部缓冲别名,下一次读就覆盖;改 ReadBytes/ReadString(§2.1) |
| 6 | ReadString('\n') 丢掉最后一行 |
EOF 时"数据 + io.EOF"同时返回,先处理数据再判错(§2) |
| 7 | Peek(1MB) 报错 |
Peek 超过缓冲能力返回 ErrBufferFull(§2) |
| 8 | 中文被切成乱码字节 | 用了 ScanBytes;按字符用 ScanRunes(§4.2) |
| 9 | 自定义 SplitFunc 死循环/ErrTooLong | 没找到标记且 !atEOF 时返回了 advance>0,或反之没保留"半个标记”(§4.3) |
| 10 | Scanner 和 Reader 混着用同一个流 | Scanner 预读会消费掉 Reader 看不到的数据(§5) |
| 11 | 小文件每块都 NewReader,开销大 |
缓冲收益来自调用次数,大块顺序拷贝加缓冲反而多一次拷贝(§1) |
7. 实战建议
按行处理文件,默认就写 Scanner + Err() 检查 + Buffer 提上限三件套。上限给多少取决于内容来源:自己生成的日志 1MB 够了;处理用户上传或第三方系统输出的,10MB 起步并准备好 ErrTooLong 的降级路径(跳过该行而不是终止整个任务)。
bufio.Writer 要么配 defer Flush,要么不用。 包着 os.File 时尤其注意 Flush 错误要检查——Flush 返回 error,Close 才真正落盘,两层都要处理(见 os-file.md 的 Close 错误检查)。HTTP ResponseWriter 上包 bufio.Writer 是个常见错误:响应已经发给客户端了,缓冲区里的内容永远追不上去。
手写文本协议用 bufio.Reader,二进制协议别硬用 bufio。 ReadString('\n') 对 HTTP/SMTP/Redis 这类行协议刚好;长度前缀二进制协议直接 io.ReadFull 更贴合语义(bufio 帮不上忙还多一层拷贝)。
性能调优时先测再动。 本文的 14.7 倍是"小写入高频"场景的实测;如果你的瓶颈本来就不是 syscall 次数(比如 CPU 在解析上),加缓冲只会增加内存占用。把 bufio 当成默认正确姿势(写文件、读网络)而不是优化手段。
相关阅读:io-interfaces.md(Reader/Writer 契约与 io.Copy 短路——解释为什么 bufio.Reader 的 ReadFrom 会替换掉你预期的拷贝路径)、os-file.md(Close/Flush 错误处理与 os.Root)、compress-archive.md(gzip.Writer 的 Close 必须调用的原因)、../04-编码与数据处理/encoding-json.md(json.Encoder/Decoder 与 bufio 的配合)、../07-附录/常见陷阱汇总.md。