Go语言源码分析【001】bytes.buffer

bytes.buffer 定义
bytes 包下的Buffer是一个结构体,由三个字段组成。
分别是存储内容的byte切片,读偏移量off以及上一具体操作lastRead

type Buffer struct { buf[]byte // contents are the bytes buf[off : len(buf)] offint// read at &buf[off], write at &buf[len(buf)] lastRead readOp // last read operation, so that Unread* can work correctly. }

off保存的目前buf字节切片中已读区域的长度;换句话说,它将buf分成了两部分:已读和未读。
Go语言源码分析【001】bytes.buffer
文章图片

而Buffer会对外隐藏这一信息,对外暴露的方法也更多地关注buf的未读区域部分。
func (b *Buffer) Bytes() []byte { return b.buf[b.off:] }func (b *Buffer) String() string { if b == nil { return "" } return string(b.buf[b.off:]) }func (b *Buffer) Len() int { return len(b.buf) - b.off }

【Go语言源码分析【001】bytes.buffer】但值得注意的是,Buffer对外提供的Cap方法返回的是buf切片所有已经分配的空间。
func (b *Buffer) Cap() int { return cap(b.buf) }

刚刚已经提及,off将buf分成了两部分:已读和未读,而Buffer又如何知道该读到何处才停止?答案显然不是cap(buf)
其实还有一个类似于buf,用于作为分界线的值,那就是buf的长度。buf的长度又会继续将buf的未读区域分为已写和未写区域。这样一来,可以得出以下规律:
  • 对Buffer进行的读操作会在 buf[off : len(buf)] 中进行
  • 对Buffer进行的写操作会在 buf[len(buf) : cap(buf)] 中进行
Go语言源码分析【001】bytes.buffer
文章图片

Buffer的容量增长策略
Go语言中对切片取下标时,下标可以大于切片的长度,但如果下标取值大于该切片的容量,则运行时会报越界错误。
在对Buffer进行写操作时,我们却无需担心这类事情的发生,因为Buffer内部已经帮我们做好了一切。其中最主要的方法当属tryGrowByReslice和grow方法。
func (b *Buffer) tryGrowByReslice(n int) (int, bool) { if l := len(b.buf); n <= cap(b.buf)-l { b.buf = b.buf[:l+n] return l, true } return 0, false }

func (b *Buffer) grow(n int) int { m := b.Len() // If buffer is empty, reset to recover space. if m == 0 && b.off != 0 { b.Reset() } // Try to grow by means of a reslice. if i, ok := b.tryGrowByReslice(n); ok { return i } if b.buf == nil && n <= smallBufferSize { b.buf = make([]byte, n, smallBufferSize) return 0 } c := cap(b.buf) if n <= c/2-m { // We can slide things down instead of allocating a new // slice. We only need m+n <= c to slide, but // we instead let capacity get twice as large so we // don't spend all our time copying. copy(b.buf, b.buf[b.off:]) } else if c > maxInt-c-n { panic(ErrTooLarge) } else { // Not enough space anywhere, we need to allocate. buf := makeSlice(2*c + n) copy(buf, b.buf[b.off:]) b.buf = buf } // Restore b.off and len(b.buf). b.off = 0 b.buf = b.buf[:m+n] return m }

在每一次对buf区域进行写操作前,Buffer都要对容量大小进行检查,容量不足则需要扩容。
而切片容量增长就意味着要为新容量的切片重新分配一块空间,然后再把旧切片的内容复制过去。
在写操作前,Buffer做了这么几件事
  1. Buffer会先尝试调用tryGrowByReslice方法。
    tryGrowByReslice会判断目前buf的长度加上扩容大小n的和会不会超过buf的容量。
    • 未超过,则没有问题
    • 超过了,则继续调用grow方法
  2. grow方法也并不会直接无脑去进行申请一块新空间,它会再次调用tryGrowByReslice方法。看到这里,你可能会问,为什么要连续调用两次tryGrowByReslice?
    • 其实在两次调用中间,grow会调用Reset方法,对buf进行一定的整理工作
    • Reset方法发现buf未读区长度为0时,它会直接抹掉buf中的已读区域(已读的内容其实Buffer没有责任再去维护)
    func (b *Buffer) Reset() { b.buf = b.buf[:0] b.off = 0 b.lastRead = opInvalid }

  3. 这时候,留给切片扩容的空间就会增加了,所以需要再次调用tryGrowByReslice方法检查一遍。但如果还是超过了,接下来grow方法会检查buf会不会是一个刚刚初始化的空切片?
    • 若buf是个空切片,那之后grow方法为它申请的容量大小并不会直接取值扩容大小n,而是会在扩容大小n不超过64的情况下,grow方法会为其初步分配大小为64的容量。
  4. 但如果扩容大小n大于64或者它压根就不是个空切片,grow方法就得接着往下继续处理
    但这时候grow方法又想出了一个逃避直接扩容的理由,它又将目标瞄向了Buffer中的已读区,但相比之前,这次的优化成本也会有所提升。
    • 之前是因为未读区的长度为0,所以Buffer直接将已读区长度压到0即可
    • 而这时候还需要调用内建函数copy,将未读区的数据粘贴到buf的头部。理论上,未读区长度m加上扩容大小n不超过buf的容量的时候,这种方法都适用
      Go语言源码分析【001】bytes.buffer
      文章图片
    可事实是,Buffer在 m+n > cap(buf)/2 的时候就不采用上面的方案了,而是选择乖乖地新建切片后复制(新切片容量大小为原有容量大小的两倍加上扩容大小n)。
    因为Go语言团队认为,如果扩容大小后的总容量都已经超过了现有容量的一半了,那么大概率很快就会超过现有容量。这样一来还不如直接扩容,还能减少非必要的复制次数。
注意,grow方法只是在原有的buf长度基础上,探测下buf再放下n个字节是否合法,并帮助完成一些扩容工作,而并非是让buf的长度增加。
Buffer还为外部提供了Grow方法,是grow方法被简单封装后的版本。
Buffer部分方法的代码
Read
func (b *Buffer) Read(p []byte) (n int, err error) { b.lastRead = opInvalid if b.empty() { // Buffer is empty, reset to recover space. b.Reset() if len(p) == 0 { return 0, nil } return 0, io.EOF } n = copy(p, b.buf[b.off:]) b.off += n if n > 0 { b.lastRead = opRead } return n, nil }

Read方法会接收一个切片,然后将未读区的数据复制过去,进而off偏移,未读区长度减小
可以注意到,Read方法只会在每次开始的时候检测数据是否被读完。这样就意味着,如果使用者只依赖Read方法返回的error判断数据是否已被读完,那么使用者接收到数据已经读完的通知将会“延迟”一次。即使某次已经读完数据,还要到下次调用Read的时候才能获取到io.EOF错误。
注意Read还选择接收内建函数copy的返回值,这里的返回值就是在告知我们:本次Read总共读了多少字节
copy的函数逻辑等同如下,它在完成复制工作的同时,会返回两个切片长度的最小值
func copy(dst, src []byte) int { minLen := min(len(dst), len(src)) for i:=0; i < minLen; i++ { dst[i] = src[i] } return minLen }func min(i, j int) int { if i < j { return i } return j }

看到这里,不知道你有没有对解决前文的“延迟”通知有了更好的方案。即我们可以放弃判断error,而是选择比对返回的读取字节数和传入切片的长度的方式进而判断是否读完数据,这样一来,“延迟”通知的概率将会大大降低。
copy函数额外地提供了一个语法糖:支持直接传递字符串到src参数中,之后copy函数会将该字符串中的字节拷贝到dst数组中。这在Buffer中的WriteString方法中也有所体现。
func (b *Buffer) WriteString(s string) (n int, err error) { b.lastRead = opInvalid m, ok := b.tryGrowByReslice(len(s)) if !ok { m = b.grow(len(s)) } return copy(b.buf[m:], s), nil }

ReadByte 顾名思义,就是只读取一个字节。处理逻辑与Read方法大同小异,只不过第一个返回值从本次读取的字节数(int)变为本次读取的某个字节(byte)。
ReadRune ReadRune它会尝试在未读区中读取一个rune。rune是Go语言独有的一个类型,是int32的别名,而它并不像int32一样被拿去作运算,它常用于表示采取UTF-8编码格式的一个字符。
ReadBytes
func (b *Buffer) ReadBytes(delim byte) (line []byte, err error) { slice, err := b.readSlice(delim) // return a copy of slice. The buffer's backing array may // be overwritten by later calls. line = append(line, slice...) return line, err }

ReadBytes会尽可能地去读取数据,直到遇到入参参数delim(读出来的数据包括delim)。在返回之前,它还做了一步处理,它将读取到的字节一个个转移到新的切片,然后再返回新的切片。
从文章开头我们看到,Bytes方法是直接返回了buf缓冲区的未读区域。但这种使用方式其实会产生两个问题。
  1. Bytes返回的字节切片和Buffer实质上使用的是同一个底层数组,因此当Buffer对底层数组进行修改的时候,Bytes返回值里的内容也可能会被改写掉。
  2. 剥开层层包装,其实Bytes返回的内容包括着指向Buffer底层数组的指针。如果某个指针一直指向Buffer底层数组而又迟迟没有被GC掉,那么底层数组的GC时机也会被延迟。
因此这里ReadBytes对数据进行了批量复制再返回,实际上就是将返回得到的切片的底层数组与Buffer底层数组分离开,从而规避以上的两个问题。
UnRead Buffer还提供了两个UnRead的方法:UnreadByte与UnreadRune,为上一次ReadByte或ReadRune提供回退操作。而ReadRune读出的Rune字节个数是不确定的,所以Buffer需要用了lastRead来存取上次读取的字节数目。
但也许是为了节省Buffer结构体的空间,lastRead并没有设计成切片或数组,所以Buffer只能看到之前的那一步操作。如果Unread之前有其他方法进行了操作改写覆盖掉了lastRead,则回退操作可能会宣告失败。
Truncate 将已读区全部清空并在未读区额外清理n个字节
Next 在使用者的角度上,它的作用与Read方法十分相似,可以让我们得到一个未读区中一连串的字节数据,并且缩小未读区长度。
但是Next方法中获取的数据是直接从buf中返回的,因此也会存在着之前Bytes方法中出现的问题。
接口相关
到这里,你可以发现,Buffer实现的方法几乎均与读写有关,其中就包括了两个具备特征性的Read和Write方法,帮助Buffer实现了Go语言中最经典的两个接口:io.Reader和io.Writer。
而Buffer也有两个方法,是利用了传入的io.Reader与io.Writer来实现功能。
const MinRead = 512func (b *Buffer) ReadFrom(r io.Reader) (n int64, err error) { b.lastRead = opInvalid for { i := b.grow(MinRead) b.buf = b.buf[:i] m, e := r.Read(b.buf[i:cap(b.buf)]) if m < 0 { panic(errNegativeRead) }b.buf = b.buf[:i+m] n += int64(m) if e == io.EOF { return n, nil // e is EOF, so return nil explicitly } if e != nil { return n, e } } }

ReadFrom方法可以看作Buffer操作一个可读对象,在它身上拿数据,写到buf缓冲区中。
func (b *Buffer) WriteTo(w io.Writer) (n int64, err error) { b.lastRead = opInvalid if nBytes := b.Len(); nBytes > 0 { m, e := w.Write(b.buf[b.off:]) if m > nBytes { panic("bytes.Buffer.WriteTo: invalid Write count") } b.off += m n = int64(m) if e != nil { return n, e } // all bytes should have been written, by definition of // Write method in io.Writer if m != nBytes { return n, io.ErrShortWrite } } // Buffer is now empty; reset. b.Reset() return n, nil }

WriteTo方法又可以看作Buffer操作一个可写对象,从buf上拿数据,再写到这个实现io.Writer的类型中。
依靠这两个方法,我们可以依靠Buffer实现文件复制的功能。
package mainimport ( "bytes" "os" )func main() { b := bytes.NewBuffer(nil) fileObj, err := os.OpenFile("./main.go", os.O_RDWR, os.ModePerm) if err != nil { panic(err) } defer fileObj.Close()fileObjCopyed, err := os.Create("./main_copy.go") if err != nil { panic(err) } defer fileObjCopyed.Close()// read bytes from origin io.Reader b.ReadFrom(fileObj)// write bytes to io.Writer b.WriteTo(fileObjCopyed) }

启发
  1. 在使用Buffer之前,如果我们能够预估到最大写入字节数,我们可以先使用Grow方法为buf扩容,避免底层数组空间的重新分配。这与使用make方式创建切片前预估好容量大小是同样的道理。
  2. 在用Bytes或Next方法获取所有未读区数据之前,我们应保证未读区域不再受到“污染”。
  3. 由于Buffer实现了Read方法,所以Buffer本质上就是一个io.Reader。因此如果我们要批量读取Buffer中的未读区数据,可以使用如同ioutil.ReadAll的函数帮助我们完成。当然,直接使用Buffer提供的Read方法也不是一个很坏的选择。

    推荐阅读