转载 | Zero Copy Readers in Go
io.Reader
接口是一个小型接口,定义了一个单一的 Read
方法。调用者调用实现了 Reader
接口的类型时,会传入一个字节切片,底层的数据源会将字节填充到这个切片中。这个数据源可以是文件、网络套接字等。
type Reader interface
不过,这个接口也带来了一些挑战。它要求必须将源数据复制到调用者提供的字节切片中。如果数据源已经存在于内存里,允许调用者直接从已经在内存中的数组读取数据会更高效,而不必做复制。在这篇文章中,我将介绍几个这类场景的示例。
Slices and Arrays in Go
快速复习一下 Go
语言中的切片是很有用的。Go
官方博客中有一篇文章《Go Slices: usage and internals》对切片的实现机制做了很好的介绍。
切片是由内存中的一个数组支持的,切片本身提供了一个对该数组子集的“视图”。
a := [10]int
s := a[3:6] // 3,4,5
fmt.Println(cap(s)) // 7,表示从切片起始位置到数组末尾的容量
然而,切片会保留对完整的底层数组的引用,这意味着你可以创建切片来查看数据的子集,而不需要分配一个新的数组并复制数据。 需要注意的是,如果你修改了切片中的数据,原始数组也会被修改。
我们希望利用切片这一特性,能够在不做不必要复制的情况下直接读取数组的数据。
bytes.Reader
bytes.Reader
是一个流行的类型,它实现了针对字节切片的 io.Reader
接口。不幸的是,直接使用这些方法并不能实现对底层 []byte
的零拷贝读取。
相反,你需要用一种更间接的方式,使用 WriteTo
方法,bytes.Reader
会将底层 []byte
的切片传递给给定的 io.Writer
。
这样就可以在不进行数据复制的情况下读取底层的数据。
type zeroCopyWriter struct
func (b []byte) (int, error)
func main()
zeroCopyWriter
是我们自定义的一个结构体,实现了 io.Writer
接口的 Write
方法。
Write
方法收到的参数 b []byte
是从 bytes.Reader
底层传递过来的字节切片,是底层[]byte
的一个切片引用(没做拷贝)。
调用 r.WriteTo(&zeroCopyWriter{})
,实际上会触发 bytes.Reader
内部调用 zeroCopyWriter.Write
并传入它的底层 []byte
。
这样,我们可以“零拷贝”直接拿到底层数据,而不用调用 r.Read()
那样拷贝数据。
bufio.Reader
Go
语言中的 bufio.Reader
是一个从底层 io.Reader
读取数据并将数据存储到缓冲区的类型。这使程序能够通过批量读取来减少系统调用次数,并允许调用者从存储的缓冲区中读取数据。
当调用 bufio.Reader.Read
时,如果读取器的缓冲区没有足够的字节,bufio.Reader
会调用底层的 io.Reader
来填充缓冲区,缓冲区默认大小是4096字节。通常这导致一个系统调用,从文件或网络套接字读取数据。然后字节会从缓冲区返回。一旦缓冲区被填满,之后的调用只要缓冲区数据足够,就可以直接从缓冲区读取。这非常有用,因为许多程序会频繁地进行小规模的读取调用,如果每次调用都产生系统调用,会影响性能。
第一次将数据复制进缓冲区是无法避免的,但我们可以避免第二次将数据从缓冲区复制到另一个数组。单独使用 Read
方法做不到这一点,但可以通过结合使用 Buffered
、Peek
和 Discard
这些方法来实现。
b := []byte("Hello, 世界")
r := bufio.NewReader(bytes.NewReader(b))
// Determine how many bytes to read.
numBytesToRead := r.Buffered()
if numBytesToRead < 5
// Get a slice of the buffer.
p, _ := r.Peek(numBytesToRead)
fmt.Println(string(p))
// Discard the bytes read.
_, _ = r.Discard(len(p))
Peek
方法会返回一个底层缓冲区的切片,这使得我们可以直接从缓冲区读取数据。处理完这些字节后,我们可以调用 Discard
来推进读取器的位置。因为 Peek
返回的切片指向的是底层的字节数组,所以当读取器位置被推进后,这个切片就不再有效,缓冲区内容可能已被覆盖。
我在实现自己的缓冲符文读取器(buffered rune reader
,见 ianlewis/runeio
)时用了这种方式,这样调用者就可以对符文流进行窥视(peek
),而无需推进读取器,同时实现零拷贝语义。
bufio.Reader
bufio.Reader 的作用:
bufio.Reader
是对底层io.Reader
的一个包装,里面有一个缓冲区(默认大小是 4096 字节)。- 它的主要目的是减少系统调用次数,提高读取效率。
- 这是因为每次系统调用(比如从文件、网络读取数据)都比较慢,频繁调用性能会受影响。
具体工作流程:
- 当你调用
bufio.Reader.Read
读取数据时,如果缓冲区里的数据不够,它才会去底层调用一次系统调用,读取一大块数据(4096字节),先填满缓冲区。 - 这部分数据是要经过一次复制的,从文件或网络的数据被“搬运”到缓冲区。
- 接下来,程序继续从缓冲区读取数据,不再直接调用底层系统。这是优化点,减少多次系统调用。
为什么这能提高性能:
- 很多程序会运行“多次小量读取”操作,如果每次读取都调系统调用,从操作系统请求数据,效率很低。
- 用缓冲区先读大块数据,一次调用读入足够多的数据,后续读取直接从内存缓冲区拿数据,速度快很多。
第一次复制无法避免,但后续复制可以避免吗?
- 第一次从操作系统读入缓冲区是必须的(这是物理数据进入内存中的步骤)。
- 正常情况下,如果程序要把数据进一步拷贝到另外一个切片或数组,这又是一次额外复制。
- 可以避免第二次从缓冲区复制到别的数组。
怎么避免第二次复制?
- 使用
bufio.Reader
的以下方法:Buffered()
: 返回当前缓冲区中累计了多少字节尚未读取,可以先知道缓冲区内数据量。Peek(n int)
: 可以“偷看”缓冲区里前面n
个字节的数据,返回的是缓冲区的切片引用,不复制数据(零拷贝读取)。这样你可以直接操作缓冲区里已有的数据。Discard(n int)
: 丢弃缓冲区内前n
个字节,对应已经读取或跳过的数据,更新缓冲区状态。
- 结合使用
Peek
和Discard
,你就可以“读”缓冲区的数据而不做额外复制,只要数据足够,你操作的就是缓冲区的内存片段。