Skip to content
Go
bufio — 缓冲 I/O

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 { ... }          // ← 不落盘就是没写

三条必须刻进肌肉记忆的语义:

  1. 忘 Flush = 丢数据。这是 bufio.Writer 的头号事故来源,尤其当 writer 包着 gzip.Writer/json.Encoder 时——外层 Close 只关 gzip footer,不会替你 Flush 内层,反过来也一样。defer w.Flush() 加显式 Flush 是常见保险写法(Close 会隐式 Flush 的类型如 gzip.Writer 另算,见 compress-archive.md)。
  2. Reset 丢弃缓冲区里尚未 Flush 的内容。实测:NewWriterSize(&buf, 8) 写 10 字节(自动溢出落盘),Flush,再写 "abc",然后 Reset(&buf) ——结果 "abc" 没了。Reset 是给复用准备的,不是"切换目标并保留数据"。
  3. 写超长数据会自动触发 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 码点切,中文是一个 token
  • ScanWords → 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。