Go-27 文件IO与读写性能对比
1. 三种读文件的姿势
Go 里读一个文件,主流有三种写法,它们的差异不在「能不能读」,而在一次读多少、要不要缓冲、内存占用多大:
os包直接读:os.Open拿到*os.File,自己开 buffer 循环Read,每次都是一次系统调用;bufio缓冲读:在*os.File外再包一层内存缓冲,减少系统调用次数;- 一次性读入内存:
os.ReadFile把整个文件读成[]byte,最省心但吃内存。
搞清楚它们的取舍,需要先理解 bufio 的缓冲原理。
2. bufio 缓冲原理
IO 操作本身并不慢,慢的是频繁地陷入内核访问磁盘——每次 Read/Write 系统调用都要在用户态和内核态之间切换,切换本身有成本。bufio 的思路是在中间加一块内存缓冲区:读的时候一次多读一些囤在缓冲里,之后从缓冲取;写的时候先攒在缓冲里,攒够了再一次性落盘。这样就把「多次小 IO」合并成了「少数几次大 IO」。
2.1 Reader 对象
bufio.Reader 是对 io.Reader 的封装:
type Reader struct {
buf []byte
rd io.Reader // 底层 reader
r, w int // buf 的读、写位置
err error
lastByte int
lastRuneSize int
}
Read(p []byte) 的逻辑:
- 缓冲区有内容时,直接把缓冲内容拷进
p并清空缓冲; - 缓冲区空且
len(p) >= len(buf)(要读的比缓冲还大)时,跳过缓冲直接从底层读进p——因为走缓冲反而多一次拷贝; - 缓冲区空且
len(p) < len(buf)时,先从底层读满缓冲区,再从缓冲填p(缓冲留有余量供下次用)。
2.2 Writer 对象
bufio.Writer 是对 io.Writer 的封装:
type Writer struct {
err error
buf []byte
n int // 缓冲中已有字节数
wr io.Writer // 底层 writer
}
Write(p []byte) 的逻辑核心是「能攒就攒」:缓冲放得下就把 p 拼进去;放不下且缓冲为空则直接写底层;放不下且缓冲有内容则先把缓冲填满、刷盘、清空,再处理 p 剩余部分。
务必记得
Flush:bufio.Writer的数据先攒在缓冲里,程序退出前必须调用w.Flush()把残留数据刷到底层,否则最后一段没写满缓冲的数据会丢失。这是新手最常踩的坑。
Reader 和 Writer 都有一个 buf 字节切片,默认大小 4096 字节,也可用 bufio.NewReaderSize / NewWriterSize 自定义。
3. 重要:告别 ioutil
早年 Go 教程里满是 ioutil.ReadFile、ioutil.ReadAll,但 io/ioutil 包从 Go 1.16(2021 年)起已被整体废弃(deprecated)。它的功能被拆分并迁移到了 os 和 io 两个包,语义完全等价,只是位置和名字变了。新代码必须用新 API,迁移对照如下:
旧 API(io/ioutil,已废弃) |
新 API | 所属包 |
|---|---|---|
ioutil.ReadFile |
os.ReadFile |
os |
ioutil.WriteFile |
os.WriteFile |
os |
ioutil.ReadAll |
io.ReadAll |
io |
ioutil.ReadDir |
os.ReadDir |
os |
ioutil.TempFile |
os.CreateTemp |
os |
ioutil.TempDir |
os.MkdirTemp |
os |
ioutil.NopCloser |
io.NopCloser |
io |
ioutil.Discard |
io.Discard |
io |
注意
ioutil.ReadDir到os.ReadDir不只是搬家:返回值从[]os.FileInfo变成了更轻量的[]os.DirEntry(避免对每个条目都stat,需要详情时再entry.Info()),这是一处需要留意的行为差异。
下文的示例统一使用新 API。
4. 三种读法的代码
4.1 os 包:手动循环 Read
自己开缓冲、循环读,控制力最强,也最啰嗦:
func readByOS(filename string) (int, error) {
fi, err := os.Open(filename)
if err != nil {
return 0, err
}
defer fi.Close()
buf := make([]byte, 4096)
var nbytes int
for {
n, err := fi.Read(buf)
nbytes += n
if err == io.EOF {
break
}
if err != nil {
return nbytes, err
}
}
return nbytes, nil
}
每次 fi.Read 都是一次系统调用。
4.2 bufio 包:带缓冲的 Read
只需把 *os.File 用 bufio.NewReader 包一层,系统调用次数骤降:
func readByBufio(filename string) (int, error) {
fi, err := os.Open(filename)
if err != nil {
return 0, err
}
defer fi.Close()
rd := bufio.NewReader(fi) // 默认 4096 缓冲
buf := make([]byte, 4096)
var nbytes int
for {
n, err := rd.Read(buf)
nbytes += n
if err == io.EOF {
break
}
if err != nil {
return nbytes, err
}
}
return nbytes, nil
}
4.3 一次性读入内存
最简单的写法,os.ReadFile 内部会先 Stat 拿到大小、一次性分配好切片再读满:
func readByReadFile(filename string) (int, error) {
// 新 API:等价于旧的 ioutil.ReadFile
data, err := os.ReadFile(filename)
if err != nil {
return 0, err
}
return len(data), nil
}
若手上是一个 io.Reader(如 HTTP body),对应用 io.ReadAll(旧 ioutil.ReadAll)。
5. 基准测试
空口无凭,用标准库 testing 的基准测试来量化。把上面三个函数放在一起跑 Benchmark:
// file_bench_test.go
func BenchmarkReadByOS(b *testing.B) {
for i := 0; i < b.N; i++ {
readByOS("testdata/sample.dat")
}
}
func BenchmarkReadByBufio(b *testing.B) {
for i := 0; i < b.N; i++ {
readByBufio("testdata/sample.dat")
}
}
func BenchmarkReadByReadFile(b *testing.B) {
for i := 0; i < b.N; i++ {
readByReadFile("testdata/sample.dat")
}
}
用不同大小的样本文件分别跑(-benchmem 一并看内存分配):
go test -bench=. -benchmem
6. 比拼结果与原因分析
多次实测下来,不同文件量级的排序规律稳定如下(> 表示更快):
| 文件规模 | 速度排序 | 关键原因 |
|---|---|---|
| 小文件(KB 级) | ReadFile > bufio > os |
一次系统调用读完,最省 |
| 常规(MB 级) | 三者接近,bufio 略优 |
缓冲摊薄了系统调用成本 |
| 大文件(GB 级) | bufio > os > ReadFile |
一次性读入内存反而拖垮 |
逐条拆解:
-
小文件为什么
ReadFile最快:它直接把全文一次性读进内存,只有一次 IO 系统调用;而os和bufio都在循环里多次读取。文件小,一把梭自然最快。 -
大文件为什么
ReadFile反而最慢甚至崩掉:os.ReadFile要开辟一块与文件等大的内存来装全部数据。GB 级文件就要 GB 级内存,分配和 GC 压力骤增;文件再大到超过可用内存,直接 OOM,这种读法彻底失效。所以永远不要用os.ReadFile读不可控大小的文件。 -
大文件为什么
bufio快过os:bufio默认带 4096 字节缓冲,每次系统调用读 4096 字节到缓冲,之后rd.Read从缓冲这块用户态内存取数据;而裸os每次Read都触发系统调用,导致内核频繁在用户态/内核态之间切换,这种上下文切换的累积开销让它慢于bufio。
7. 选型建议
综合下来给出实用准则:
- 小配置/小文本文件:直接
os.ReadFile/os.WriteFile,一行搞定,别过度设计。 - 大文件、日志、流式数据:用
bufio.Reader(读)/bufio.Writer(写),逐行bufio.Scanner或分块处理,内存占用恒定,不随文件增大而膨胀——这是处理不可控大小数据的默认选择。 - 网络流、
io.Reader抽象:小响应体io.ReadAll;大流量务必配合bufio或io.Copy流式转发,避免全量入内存。 - 写文件记得
Flush:用bufio.Writer时,收尾一定defer w.Flush()。 - 一律使用新 API:
os.ReadFile/os.WriteFile/io.ReadAll/os.ReadDir,彻底告别ioutil。
一句话总结:能一次读完的小文件图省事,大小不可控的数据靠缓冲——把内存占用控制住,才是文件 IO 的核心。
8. 小结
bufio通过内存缓冲把「多次小 IO」合并为「少数大 IO」,默认缓冲 4096 字节,写完必须Flush。io/ioutil自 Go 1.16 废弃,功能迁移到os与io;新代码用os.ReadFile/os.WriteFile/io.ReadAll/os.ReadDir。- 性能没有银弹:小文件
ReadFile最快,大文件bufio最稳,ReadFile读大文件会 OOM。 - 选型看数据规模是否可控——可控且小就一把梭,不可控就上缓冲流式处理。
xingliuhua