转载 | Zero Copy Readers in Go

Apr 22, 2025

io.Reader 接口是一个小型接口,定义了一个单一的 Read 方法。调用者调用实现了 Reader 接口的类型时,会传入一个字节切片,底层的数据源会将字节填充到这个切片中。这个数据源可以是文件、网络套接字等。

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

不过,这个接口也带来了一些挑战。它要求必须将源数据复制到调用者提供的字节切片中。如果数据源已经存在于内存里,允许调用者直接从已经在内存中的数组读取数据会更高效,而不必做复制。在这篇文章中,我将介绍几个这类场景的示例。

Slices and Arrays in Go

快速复习一下 Go 语言中的切片是很有用的。Go 官方博客中有一篇文章《Go Slices: usage and internals》对切片的实现机制做了很好的介绍。

切片是由内存中的一个数组支持的,切片本身提供了一个对该数组子集的“视图”。

a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
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 (w *zeroCopyWriter) Write(b []byte) (int, error) {
    fmt.Printf("%v", b)
    return len(b), nil
}

func main() {
    r := bytes.NewReader([]byte("Hello, 世界"))
    r.WriteTo(&zeroCopyWriter{})
}

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 方法做不到这一点,但可以通过结合使用 BufferedPeekDiscard 这些方法来实现。

b := []byte("Hello, 世界")
r := bufio.NewReader(bytes.NewReader(b))

// Determine how many bytes to read.
numBytesToRead := r.Buffered()
if numBytesToRead < 5 {
    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个字节,对应已经读取或跳过的数据,更新缓冲区状态。
  • 结合使用 PeekDiscard,你就可以“读”缓冲区的数据而不做额外复制,只要数据足够,你操作的就是缓冲区的内存片段。

https://inasa.dev/posts/rss.xml