使用golang语言去读取一个文件默认会有多种方式,这里主要介绍以下几种。

ioutil.ReadFile

1
2
//读取文件内容,并返回[]byte数据和错误信息。err == nil时,读取成功
func ReadFile(filename string) ([]byte, error)

读取文件示例:

1
2
3
4
func read4(path string){
    _,err := ioutil.ReadFile(path)
    if err != nil{panic(err)}
}

os.Open和ioutil.ReadAll

由于os.Open是打开一个文件并返回一个文件对象,因此其实可以结合ioutil.ReadAll(r io.Reader)来进行读取。

io.Reader其实是一个包含Read方法的接口类型,而文件对象本身是实现了了Read方法的。

1
2
3
4
//打开一个需要被读取的文件,如果成功读取,返回的文件对象将可用被读取,该函数默认的权限为O_RDONLY,也就是只对文件有只读权限。如果有错误,将返回*PathError类型
func Open(name string) (*File, error)
//大部分用户会选择该函数来代替Open or Create函数。该函数主要用来指定参数(os.O_APPEND|os.O_CREATE|os.O_WRONLY)以及文件权限(0666)来打开文件,如果打开成功返回的文件对象将被用作I/O操作
func OpenFile(name string, flag int, perm FileMode) (*File, error)

使用os.Open家族函数和ioutil.ReadAll()读取文件示例:

1
2
3
4
5
6
func read3(path string){
    fi,err := os.Open(path)
    if err != nil{panic(err)}
    defer fi.Close()
    _,err = ioutil.ReadAll(fi)
}

os.Open和os.Read

不论是上边说的os.Open还是os.OpenFile他们最终都返回了一个*File文件对象,而该文件对象默认是有很多方法的,其中读取文件的方法有如下几种:

1
2
3
4
5
//从文件对象中读取长度为b的字节,返回当前读到的字节数以及错误信息。因此使用该方法需要先初始化一个符合内容大小的空的字节列表。读取到文件的末尾时,该方法返回0,io.EOF
func (f *File) Read(b []byte) (n int, err error)

//从文件的off偏移量开始读取长度为b的字节。返回读取到字节数以及错误信息。当读取到的字节数n小于想要读取字节的长度len(b)的时候,该方法将返回非空的error。当读到文件末尾时,err返回io.EOF
func (f *File) ReadAt(b []byte, off int64) (n int, err error)

使用文件对象的Read方法读取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func read1(path string,blocksize int){
    fi,err := os.Open(path)
    if err != nil{
        panic(err)
    }
    defer fi.Close()
    block := make([]byte,blocksize)
    for{
        n,err := fi.Read(block)
        if err != nil && err != io.EOF{panic(err)}
        if 0 ==n {break}
    }
}

os.Open和bufio.Reader

bufio包实现了缓存IO,它本身包装了io.Reader和io.Writer对象,创建了另外的Reader和Writer对象,不过该种方式是带有缓存的,因此对于文本I/O来说,该包是提供了一些便利的。

先看下bufio模块下的相关的Reader函数方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//首先定义了一个用来缓冲io.Reader对象的结构体,同时该结构体拥有以下相关的方法
type Reader struct {
}

//NewReader函数用来返回一个默认大小buffer的Reader对象(默认大小好像是4096) 等同于NewReaderSize(rd,4096)
func NewReader(rd io.Reader) *Reader

//该函数返回一个指定大小buffer(size最小为16)的Reader对象,如果 io.Reader参数已经是一个足够大的Reader,它将返回该Reader
func NewReaderSize(rd io.Reader, size int) *Reader


//该方法返回从当前buffer中能被读到的字节数
func (b *Reader) Buffered() int

//Discard方法跳过后续的 n 个字节的数据,返回跳过的字节数。如果0 <= n <= b.Buffered(),该方法将不会从io.Reader中成功读取数据。
func (b *Reader) Discard(n int) (discarded int, err error)

//Peekf方法返回缓存的一个切片,该切片只包含缓存中的前n个字节的数据
func (b *Reader) Peek(n int) ([]byte, error)

//把Reader缓存对象中的数据读入到[]byte类型的p中,并返回读取的字节数。读取成功,err将返回空值
func (b *Reader) Read(p []byte) (n int, err error)

//返回单个字节,如果没有数据返回err
func (b *Reader) ReadByte() (byte, error)

//该方法在b中读取delimz之前的所有数据,返回的切片是已读出的数据的引用,切片中的数据在下一次的读取操作之前是有效的。如果未找到delim,将返回查找结果并返回nil空值。因为缓存的数据可能被下一次的读写操作修改,因此一般使用ReadBytes或者ReadString,他们返回的都是数据拷贝
func (b *Reader) ReadSlice(delim byte) (line []byte, err error)

//功能同ReadSlice,返回数据的拷贝
func (b *Reader) ReadBytes(delim byte) ([]byte, error)

//功能同ReadBytes,返回字符串
func (b *Reader) ReadString(delim byte) (string, error)

//该方法是一个低水平的读取方式,一般建议使用ReadBytes('\n') 或 ReadString('\n'),或者使用一个 Scanner来代替。ReadLine 通过调用 ReadSlice 方法实现,返回的也是缓存的切片,用于读取一行数据,不包括行尾标记(\n 或 \r\n),注意如果遇到了io.EOF,数据中保存了遇到了EOF之前的数据,可以直接使用

func (b *Reader) ReadLine() (line []byte, isPrefix bool, err error)

//读取单个UTF-8字符并返回一个rune和字节大小
func (b *Reader) ReadRune() (r rune, size int, err error)

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func read2(path string,blocksize int){
    fi,err := os.Open(path)
    if err != nil{panic(err)}
    defer fi.Close()
    r := bufio.NewReader(fi)
    block := make([]byte,blocksize)
    for{
        n,err := r.Read(block)
        if err != nil && err != io.EOF{panic(err)}
        if 0 ==n {break}
    }
}

影响性能的因素

  1. Read()每次读取的块的大小对结果也是有影响的
  2. 连续测试同一个文件,会从系统缓存或是SSD缓存加载文件,后面的测试结果会被加速

测试代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
    "math/rand"
    "fmt"
    "flag"
    "strconv"
    "io/ioutil"
)

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
// https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang
func RandStringBytes(n int) []byte {
    b := make([]byte, n)
    for i := range b {
        b[i] = letterBytes[rand.Intn(len(letterBytes))]
    }
    return b
}
func RandFile(path string,filesizeMB int) {
    b:=RandStringBytes(filesizeMB * 1024)    //生成1-500KB大小的随机字符串
    bb := make([]byte, filesizeMB * 1024 * 1024)
    for i:=0;i<1024;i++ {    //复制1024遍
        copy(bb[len(b)*i:len(b)*(i+1)],b)
    }
    //fmt.Printf("%s",b)
    ioutil.WriteFile(path,bb,0666)
}
func main() {
    flag.Parse()
    filesizeMB,err :=strconv.Atoi(flag.Arg(0))        //1-500MB大小的文件
    if err != nil{panic(err)}
    if filesizeMB > 500 {panic("too large file,>500MB")}
    RandFile("./random1.txt",filesizeMB)
    RandFile("./random2.txt",filesizeMB)
    RandFile("./random3.txt",filesizeMB)
    RandFile("./random4.txt",filesizeMB)
    fmt.Printf("Created 4 files, each file size is %d MB.",filesizeMB)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package main

import(
    "fmt"
    "os"
    "flag"
    "io"
    "io/ioutil"
    "bufio"
    "time"
    "strconv"
)

func read1(path string,blocksize int){
    fi,err := os.Open(path)
    if err != nil{
        panic(err)
    }
    defer fi.Close()
    block := make([]byte,blocksize)
    for{
        n,err := fi.Read(block)
        if err != nil && err != io.EOF{panic(err)}
        if 0 ==n {break}
    }
}

func read2(path string,blocksize int){
    fi,err := os.Open(path)
    if err != nil{panic(err)}
    defer fi.Close()
    r := bufio.NewReader(fi)
    block := make([]byte,blocksize)
    for{
        n,err := r.Read(block)
        if err != nil && err != io.EOF{panic(err)}
        if 0 ==n {break}
    }
}

func read3(path string){
    fi,err := os.Open(path)
    if err != nil{panic(err)}
    defer fi.Close()
    _,err = ioutil.ReadAll(fi)
}

func read4(path string){
    _,err := ioutil.ReadFile(path)
    if err != nil{panic(err)}
}

func main(){
    flag.Parse()
    file1 := "./random1.txt"
    file2 := "./random2.txt"
    file3 := "./random3.txt"
    file4 := "./random4.txt"
    blocksize,_ :=strconv.Atoi(flag.Arg(0))
    var start,end time.Time
    start = time.Now()
    read1(file1,blocksize)
    end = time.Now()
    fmt.Printf("file/Read() cost time %v\n",end.Sub(start))
    start = time.Now()
    read2(file2,blocksize)
    end = time.Now()
    fmt.Printf("bufio/Read() cost time %v\n",end.Sub(start))
    start = time.Now()
    read3(file3)
    end = time.Now()
    fmt.Printf("ioutil.ReadAll() cost time %v\n",end.Sub(start))
    start = time.Now()
    read4(file4)
    end = time.Now()
    fmt.Printf("ioutil.ReadFile() cost time %v\n",end.Sub(start))
}
  1. 测试1:块大小为4KB,这是个常见的大小,出人意料ioutil.ReadAll()最慢
  2. 测试2:块大小为1KB,bufio > ioutil.ReadAll > os自带Read
  3. 测试3:块大小为32KB,在大块的情况下,调用Read()次数更少,bufio已经没有优势,但前两者却远快于ioutil包的两个函数
  4. 测试4:块大小为16字节,在小块的情况下,没有缓存的文件普通Read成绩惨不忍睹

原因探讨

在查阅golang标准库的源代码后,之所以有不同的结果是与每个方法的实现相关的,最大的因素就是内部buffer的大小,这个直接决定了读取的快慢:

f.Read()底层实现是系统调用syscall.Read(),没有深究

bufio.NewReader(f)实际调用NewReaderSize(f, defaultBufSize),而defaultBufSize=4096,可以直接用bufio.NewReaderSize(f,32768)来预分配更大的缓存,缓存的实质是make([]byte, size)

ioutil.ReadAll(f)实际调用readAll(r, bytes.MinRead),而bytes.MinRead=512,缓存的实质是bytes.NewBuffer(make([]byte, 0, 512),虽然bytes.Buffer会根据情况自动增大,但每次重新分配都会影响性能

ioutil.ReadFile(path)是调用readAll(f, n+bytes.MinRead),这个n取决于文件大小,文件小于10^9字节(0.93GB),n=文件大小,就是NewBuffer一个略大于文件大小的缓存,非常慷慨;大于则n=0,好惨,也就是说大于1G的文件就跟ioutil.ReadAll(f)一个样子了。

但全量缓存的ReadFile为什么不如大块读取的前两者呢?我猜测是NewBuffer包装的字节数组性能当然不如裸奔的字符数组。

结论

  1. 当每次读取块的大小小于4KB,建议使用bufio.NewReader(f), 大于4KB用bufio.NewReaderSize(f,缓存大小)

  2. 要读Reader, 图方便用ioutil.ReadAll()

  3. 一次性读取文件,使用ioutil.ReadFile()