go bufio缓存原理
本系列计划用三篇文章来写:bufio包中的读取原理、bufio包中的写入原理以及使用bufio高效读取的示例。今天跟大家分享第一篇读取原理。
为了方便演示,上图中是一块大小只有5个字节的缓冲区,当然在bufio中实际默认的缓冲区大小是4096字节,即4KB。下面我们看该读取函数在缓冲区的三种状态下各自的读取策略。
第一步是填充缓冲区。因为缓冲区是空的状态,所以先将文件的内容读取到缓冲区。示例中缓冲区的容量为5,所以,会从缓冲区的位置0开始,将缓冲区尽量填满。所谓尽量填满,有以下两种情况:
我们看到,最终缓冲区的状态还是空,数据只不过是在缓冲区中中转了一下而已。因此,更有效的做法应该是从文件直接读取对应的字节数到 调用者的 p 中就行,而不再经过缓冲区。因为最终读取的效果是一样的,但直接读取效率还会更高,因为少了一次从缓冲区到 调用者 p 的拷贝操作。如下图所示:
同样,如果当读取的字节数长度大于缓冲区的长度,也是同样的原理。
调用者期望读取2个字节的内容,但缓冲区中只有1个字节可读,这时是不再从文件中继续读取的,而是只读取缓冲区的内容即可,如下图:
最后,将实际读取到的字节数返回给调用者,并将下次可读取的索引位置 r 进行更新,如下图:
这时缓冲区的状态实际上是变成了空的状态。如果再继续读取的话,r和w就会复位成0,并从文件中再读取一大块内容填充到缓冲区中。另外还有一种就是缓冲区满的状态下的读取逻辑,这种场景下就结合场景二进行读取即可。以上实际上就是bufio包中的Read([]byte)函数的逻辑,按字节读取。其实在实际编程中,我们经常会遇到的是按行读取,更通用一点就是一直读取到指定的字符为止。下面我们来看看这种读取操作的实现。
第四步,继续从缓冲区中查找字符字符r,如果找到了,则将暂存区中的内容及缓冲区中r及之前的内容都返回给调用者。如下图:
01 Go中普通的文件读写
首先我们来看看在Go中对文件的普通读取方式是怎么样的。下面是普通的读取文件内容的示例代码:package main
import (
"fmt"
"io/ioutil"
"os"
"sync/atomic"
)
func main() {
filename := "./test.txt"
//以读写模式打开文件
fd, _ := os.OpenFile(filename, os.O_RDWR, 0666)
b := make([]byte, 2)
// 从文件中读取最多len(b)个字节的内容到b中
n, err := fd.Read(b)
fmt.Printf("n:=%d, b:=%s, err=%+v\n", n, b, err)
}
上面的读取方式是通过文件系统的IO进行读取的,每次都需要一次底层的系统调用,若需要连续多次读取,那么这种方式的效率就会大大降低。如下图:
02 将文件内容预读取到缓存--bufio
这里的思想很简单,当用户从文件中读取数据的时候,先从文件中读取一大块内容到内存缓冲区,以供后面的读取操作直接从内存缓冲区进行读取,以降低从文件中读取的系统调用次数。如下图所示:03 缓冲区的状态
缓冲区有三种状态,分别是缓冲区为空、缓冲区未满但有可读取的数据以及缓冲区满的状态。在bufio中,缓冲区本质上是一个字节切片,并通过两个整型变量r和w分别表示可读取以及可写入的索引位置。从文件中每加载一个字节的内容到缓冲区则w+1,从缓冲区每读走一个字节的内容,则r+1。下面我们分别看下三种状态。1)缓冲区为空的状态
缓冲区为空的状态本质上是指没有内容可读。其判断标准如下:r和w相等,意味着已经将写入到缓冲区的内容都读完了。其中最简单的就是r和w都等于0,缓冲区中没有任何内容,如下图所示:r == w
缓冲区为空的状态还有一种情况是缓冲区中有内容,但已经都被读取走了,即r和w相等,如下图: 在这种状态下,当再需要读取内容时,会首先将r和w都置为0,然后从文件中加载新的数据填充到缓冲区中以供下次调用方读取。
2)缓冲区为非空的状态
这种状态是指在缓冲区中有可读的内容,其判断标准如下:r != w && (w-r) < len(buf)
r != w 说明buf[r:w]这段内容还没被调用方读取。(w-r) < len(buf) 说明buf不是满的状态,还有空间可以继续填充内容。在这种状态下,当程序执行读操作时,会直接从缓冲区中读取。如下图: 上图中,虚线部分表示已经被读走的内容。buf[r:w]这段切片是可读的缓冲内容,在该示例中为buf[2:8]。
3)缓冲区满状态
还有最后一种缓冲区状态,即缓冲区满。其通用的判断标准如下:如果要满足上述公式,只有一种情况,即 r=0,表示还没有从缓冲区读走任何内容。w=len(buf),表示从文件中读取的内容已经填满了整个缓冲区。该示例中为w=10,即表示没有任何空闲的空间。如下图所示:(w-r) >= len(buf)
04 读取特定字节数的操作-- Read([]byte)
我们还是从最简单的读取操作开始。还是上面的例子,我们将其改写成使用bufio的读取操作:package main
import (
"bufio"
"fmt"
"os"
)
func main() {
filename := "./test.txt"
//以读写模式打开文件
fd, _ := os.OpenFile(filename, os.O_RDWR, 0666)
//将fd包装到buffer Reader中
bufioReader := bufio.NewReader(fd)
p := make([]byte, 2)
n, _ := bufioReader.Read(p)
fmt.Printf("n=%d,p=%s", n, p)
}
基本用法看起来和直接从文件中读取差不多,只不过是多包装了一层buffer Reader。下面我们看看其内部的具体实现。上面提到bufio的基本思想是有一个缓冲区,调用方直接从缓冲区中读取。下图是其初始的状态: 场景一:当缓冲区为空状态时的读取逻辑(即r等于w)
在缓冲区为空时,进行读取也有两种情况:- 若调用方要读取的字节数 小于 缓冲区的长度,则先填充缓冲区,再从缓冲区中读取。
- 若调用方要读取的字节数 ≥ 缓冲区的长度,则直接从文件中读取,不填充缓冲区。
p := make([]byte, 2)
n, _ := bufioReader.Read(p)
这里是要从文件中读取2个字节。即要读取的字节数少于缓冲区的内容字节数。我们看下下图:- 若文件的可读取的内容字节数大于等于缓冲区容量,那就先将缓冲区填满。
- 若文件的可读取的内容字节数小于缓冲区容量,则将可读取内容全部读取到缓冲区即可。
- 先从文件读取5个字节到缓冲区,这时 w=5,代表下次再写入缓冲区的位置。缓冲区从空的状态转换到满的状态。
- 然后再将缓冲区的5个字节全部拷贝到 p 中,这时r = 5,代表下次再从缓冲区读取数据的位置。这时缓冲区中的内容都已经被读走了, r 和 w相等。这时缓冲区从满的状态又变成了空的状态。
场景二:当缓冲区为非空状态的读取逻辑
如果在缓冲区非空状态下进行读取操作时,唯一需要注意的点就是当缓冲区中可读取的内容字节数小于调用者要读取的字节数时,则只能读取缓冲区中的内容。如下是bufio中缓冲区的初始状态,目前只有1字节的内容可读,如下图:05 从缓冲区中读取到指定位置
这种读取方式是从缓冲区中读取数据,直到遇到指定的字符为止(实际上是指定字符所在的切片索引位置)。按行读取是最常见的场景之一,即一直读取到换行符为止。这种读取方式中也分两种情况:- 情况1:当缓冲区中包含指定的字符,则从缓冲区中直接返回包含该字符及之前的有效内容。
- 情况2:当缓冲区中没有指定的字符,又分两种情况:
- 2.1 若缓冲区是满的状态,则返回整个缓冲的内容
- 2.2 若缓冲区处于非空状态(也非满的状态),则将缓冲区填满内容,再从该缓冲区中查找是否存在指定的字符。若在缓冲区中能够查找到指定的字符,则返回该指定字符及以前的内容,否则,返回整个缓冲区的内容,即2.1的情况。
E
为止。缓冲区中的状态如下: 缓冲区中的buf[1:5]这段内容中包含字符E,那么直接返回buf[1:2]的内容即可。
情况2稍微复杂下。下面我们稍微拆解下在缓冲区各种状态下第一次未找到对应的字符的情况。首先我们看当缓冲区处于满的状态下,第一次未找到对应字符的逻辑。如下:B
,发现没有对应的字符,同时又发现缓冲区状态是满的状态,所以就直接将缓冲区中的所有内容都返回,同时将缓冲区满的错误返回给调用者。如下图,则返回给调用者 HELLOGO!
,同时返回errors.New("buffer is full"):
我们从缓冲区的索引5的位置开始查找字符B
,发现没有找到。但同时也发现缓冲区处于非满的状态,因为从索引0到索引5之间的内容已经被读取走了,所以这段内存相当于处于空闲的状态。因此,这里会先将缓冲区中5到8之间的内容移动到0到3之间,然后再从文件中读取一段内容填充到缓冲3到8的位置上,最后继续查找。如下图:
上图是移动完内容之后的结果。然后从文件中读取内容填充剩余的缓冲区,如下图:
这样,缓冲区中又有了新的内容,则会从新的内容中继续查找指定的字符B,如下图:
这里要注意的是0-3之间的内容不再重复查找,只会从3-8之间查找。这里找到了字符B的位置在buf[3:8]这段内容的位置0处。最后返回buf[0:4]的这段内容,因为之前的内容也是搜索的一部分。如下图:
以上在缓冲区中移动内容到开始位置,并重新填充内容到缓冲区的过程实际上就是bufio包中的fill方法。而整个按指定字符读取的过程是bufio包中的ReadLine和ReadSlice函数的对应实现(ReadLine函数调用了ReadSlice函数)。ReadLine函数默认是读取内容,直到遇到第一个换行符\n为止。我们注意到以上的ReadLine和ReadSlice函数都是在缓冲区中的内容中搜索。只要在缓冲区满的状态下,无论是能否搜索到对应的字符,都会返回。我们知道,文件内容的大小一般都会远远大于缓冲区的大小,那如果在缓冲区满的状态下没有找到对应的字符,如何继续往下查找呢?
06 从全文件中读取到指定位置
这种读取方式是从缓冲区中读取,如果该缓冲区中没有读到指定的字符,那么就将该缓冲区的内容暂存到一个临时区,然后再读取文件将缓冲区填满,再次查找,依次循环,直到读到指定的字符为止或读到文件的末尾,将所有的结果返回给调用者。假设缓冲区处于满的状态,我们要查找指定的字符r
第一步先从缓冲区中查找,如下: 第二步,在缓冲区中未找到指定的字符 r,所以需要将缓冲区中的内容移动到暂存区存储起来,以备后续返回时用,如下图:
第三步,这时缓冲区实际处于空的状态,然后需要从文件中读取内容再次填充缓冲区,继续查找 是否有 r字符。如下图:
如果在第四步中依然没找到指定的字符r,那么就会调回第二步,依次循环,直到找到指定的字符或将文件中所有的内容都扫描完为止,最终将暂存区即缓冲区中的内容都返回。此过程就是bufio中的ReadString函数即对应的collectFragments函数的实现。有兴趣的同学可以查看对应的源码。
· 全网最简单!3分钟用满血DeepSeek R1开发一款AI智能客服,零代码轻松接入微信、公众号、小程
· .NET 10 首个预览版发布,跨平台开发与性能全面提升
· 《HelloGitHub》第 107 期
· 全程使用 AI 从 0 到 1 写了个小工具
· 从文本到图像:SSE 如何助力 AI 内容实时呈现?(Typescript篇)
2021-05-18 Kibana+ElasticSearch实现索引数据的几种查询方式
2018-05-18 Centos6.9下RabbitMQ集群部署记录