likes
comments
collection
share

缓存存在的作用

作者站长头像
站长
· 阅读数 20
Preface

不管是在什么编程语言中,在执行I/O相关的操作时,通常会为其添加一层缓存,如在golang中执行写文件操作这样:

func main() {
    filename := "test.txt"
    content := "Hello World!"

    fileObj, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0664)
    if err != nil {
        return
    }
    defer fileObj.Close()

    bufWr := bufio.NewWriter(fileObj)
    if _, err := bufWr.Write([]byte(content)); err != nil {
        return
    }
    bufWr.Flush()
}

我们将文件句柄传递给一个缓存,创建一个bufio.Writer,然后通过这个bufio.Writer来完成写操作;有时候我们其实并不知道为什么要这样做,也不知道这样做究竟能带来什么好处,下面就来具体的分析一下

Why

首先我们看一下在golangI/O的结构:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

I/O最基本的只需要两个方法:Read,WriteRead是将文件中的内容读取出来放在p字节数组当中,然后返回给消费者使用,也就是执行一次读盘操作(此处也不一定必须是磁盘);Write是将p字节数组当中的内容写入磁盘当中,即执行一次写盘操作

我们知道磁盘和内存的读写速度相差很大,所以在写程序时对于性能要求比较高的代码就需要减少读盘写盘的操作;而如果现在有一个程序要求频繁的读写盘,那应该如何提高程序性能呢?答案就是使用缓存,下面就说说缓存到底是如何提升读写盘效率的

How

缓存的本质就是在内存开辟一块空间,当执行读盘操作的时候,先将数据读到这块内存当中然后再从这块内存中返回指定大小的字节数给消费者(此处的重点就是返回指定大小的字节数),就如下图所示:

缓存存在的作用

上面的图中,先是将数据从文件读取到buffer当中,然后再返回给消费者所需要的字节数;下面就是一个例子:

func main() {
    filename := "test.txt"
    f, err := os.Open(filename)
    if err != nil {
        panic(err)
    }
    defer f.Close()

    bufReader := bufio.NewReader(f)

    buf := make([]byte, 64)
    for i := 0; i < 10; i++ {
        _, err = bufReader.Read(buf)
        if err != nil {
            return
        }
        fmt.Printf("Content:%s\n", string(buf))
    }
}

上面的代码就是从文件中读取十次数据,每次只读取64个字节,我们通过bufio.NewReader创建一个缓存,这个缓存默认大小是4096个字节,这样我们只需要执行一次读盘操作就可以了

When

看完上的例子,感觉这个缓存有点多此一举的意思;如果一次直接读取4096个字节,那还需要什么缓存呢?

事实确实如此,当你读取的数据超过缓存的大小,那确实就没缓存啥事了,而bufio.Reader中的Read也确实是这么实现的,只要读取的数据p字节数组大于或者等于缓存的大小,就会直接使用文件的句柄读写,而不再往缓存写任何数据了

下面是bufio.Reader的源码结构:

// Reader implements buffering for an io.Reader object.
type Reader struct {
    buf          []byte
    rd           io.Reader // reader provided by the client
    r, w         int       // buf read and write positions
    err          error
    lastByte     int // last byte read for UnreadByte; -1 means invalid
    lastRuneSize int // size of last rune read for UnreadRune; -1 means invalid
}

读取的数据就会缓存再buf数组当中,这个数组默认大小为4096,当然也可以通过bufio.NewReaderSize来指定这个缓存的大小

下面是bufio.ReaderRead方法:

func (b *Reader) Read(p []byte) (n int, err error) {
    n = len(p)
    if n == 0 {
        if b.Buffered() > 0 {
            return 0, nil
        }
        return 0, b.readErr()
    }
    if b.r == b.w {
        if b.err != nil {
            return 0, b.readErr()
        }
        if len(p) >= len(b.buf) {
            // Large read, empty buffer.
            // Read directly into p to avoid copy.
            n, b.err = b.rd.Read(p)
            if n < 0 {
                panic(errNegativeRead)
            }
            if n > 0 {
                b.lastByte = int(p[n-1])
                b.lastRuneSize = -1
            }
            return n, b.readErr()
        }
        // One read.
        // Do not use b.fill, which will loop.
        b.r = 0
        b.w = 0
        n, b.err = b.rd.Read(b.buf)
        if n < 0 {
            panic(errNegativeRead)
        }
        if n == 0 {
            return 0, b.readErr()
        }
        b.w += n
    }

    // copy as much as we can
    // Note: if the slice panics here, it is probably because
    // the underlying reader returned a bad count. See issue 49795.
    n = copy(p, b.buf[b.r:b.w])
    b.r += n
    b.lastByte = int(b.buf[b.r-1])
    b.lastRuneSize = -1
    return n, nil
}

上面的代码中可以看到当len(p)>=len(b.buf)的时候就直接用文件句柄读取了,这样就能够避免多一次的copy

Similarly

对于bufio.Writer也是一样的道理,io.Writer只有一个方法Write(p []byte) (n int,err error);其作用就是将p字节数组中的数据写入磁盘(此处并不一定是磁盘),而如果每次只写入一点,也就是指这个p字节数组很小;那这样就会执行多次写盘操作

而使用bufio.Writer就可以先将数据写入缓存当中,等缓存满了或者是数据全部写完之后就可以将数据写入磁盘当中了,这样就能够减少写盘的操作了,如下图所示:

缓存存在的作用

同样的,如果一次写入的数据len(p)>=len(b.buf)那么就不会缓存这些数据了,而是直接写入磁盘当中了;当然,也可以使用bufio.NewWriterSize来指定缓存的大小

End

不管是读缓存还是写缓存,都是以空间换时间来提升程序的性能;但是,如果开辟过大的内存空间来缓存这些数据也会带来性能上的影响

最后一点就是在执行缓存读写的时候一定要注意执行Flush操作,将数据同步回去,防止内存的泄露