目录

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 剩余部分。

务必记得 Flushbufio.Writer 的数据先攒在缓冲里,程序退出前必须调用 w.Flush() 把残留数据刷到底层,否则最后一段没写满缓冲的数据会丢失。这是新手最常踩的坑。

ReaderWriter 都有一个 buf 字节切片,默认大小 4096 字节,也可用 bufio.NewReaderSize / NewWriterSize 自定义。

3. 重要:告别 ioutil

早年 Go 教程里满是 ioutil.ReadFileioutil.ReadAll,但 io/ioutil 包从 Go 1.16(2021 年)起已被整体废弃(deprecated)。它的功能被拆分并迁移到了 osio 两个包,语义完全等价,只是位置和名字变了。新代码必须用新 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.ReadDiros.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.Filebufio.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 系统调用;而 osbufio 都在循环里多次读取。文件小,一把梭自然最快。

  • 大文件为什么 ReadFile 反而最慢甚至崩掉os.ReadFile 要开辟一块与文件等大的内存来装全部数据。GB 级文件就要 GB 级内存,分配和 GC 压力骤增;文件再大到超过可用内存,直接 OOM,这种读法彻底失效。所以永远不要用 os.ReadFile 读不可控大小的文件

  • 大文件为什么 bufio 快过 osbufio 默认带 4096 字节缓冲,每次系统调用读 4096 字节到缓冲,之后 rd.Read 从缓冲这块用户态内存取数据;而裸 os 每次 Read 都触发系统调用,导致内核频繁在用户态/内核态之间切换,这种上下文切换的累积开销让它慢于 bufio

7. 选型建议

综合下来给出实用准则:

  • 小配置/小文本文件:直接 os.ReadFile / os.WriteFile,一行搞定,别过度设计。
  • 大文件、日志、流式数据:用 bufio.Reader(读)/ bufio.Writer(写),逐行 bufio.Scanner 或分块处理,内存占用恒定,不随文件增大而膨胀——这是处理不可控大小数据的默认选择。
  • 网络流、io.Reader 抽象:小响应体 io.ReadAll;大流量务必配合 bufioio.Copy 流式转发,避免全量入内存。
  • 写文件记得 Flush:用 bufio.Writer 时,收尾一定 defer w.Flush()
  • 一律使用新 APIos.ReadFile/os.WriteFile/io.ReadAll/os.ReadDir,彻底告别 ioutil

一句话总结:能一次读完的小文件图省事,大小不可控的数据靠缓冲——把内存占用控制住,才是文件 IO 的核心。

8. 小结

  • bufio 通过内存缓冲把「多次小 IO」合并为「少数大 IO」,默认缓冲 4096 字节,写完必须 Flush
  • io/ioutil 自 Go 1.16 废弃,功能迁移到 osio;新代码用 os.ReadFile/os.WriteFile/io.ReadAll/os.ReadDir
  • 性能没有银弹:小文件 ReadFile 最快,大文件 bufio 最稳,ReadFile 读大文件会 OOM
  • 选型看数据规模是否可控——可控且小就一把梭,不可控就上缓冲流式处理。