Go中的bytes.Buffer:无效的优化

许多Go程序员熟悉bytes.Buffer 。 它的优点之一是,它使您可以避免像“ 小缓冲区/大小优化 ”一样在堆上分配内存:


type Buffer struct { bootstrap [64]byte //        // ...   } 

只有一个问题。 此优化无效


到本文结尾,您将了解为什么此优化无效的原因以及我们可以采取的措施。


如预期的那样,“小缓冲区优化”


让我们介绍一下bytes.Buffer的稍微简化的定义:


 const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte } 

例如,当我们对Buffer执行操作时,调用Buffer.Write方法,该记录始终在buf ,但是,在此记录之前,在每个类似方法内启动Buffer.grow(n) ,以确保此slice中有足够的空间用于接下来的n个字节。


成长可能看起来像这样:


 func (b *Buffer) grow(n int) { //         bytes.Buffer. l := len(b.buf) //   Buffer need := n + l have := cap(b.buf) - l if have >= need { b.buf = b.buf[:need] return } if need <= smallBufSize { //     , //   . b.buf = b.bootstrap[:] } else { // growFactor -     . //     need  need*2. newBuf := make([]byte, need, growFactor(need)) copy(newBuf, b.buf) b.buf = newBuf } } 

我们的Buffer.grow实现中使用的假设


我们假设len(b.buf)是Buffer中的实际数据长度,这将要求Write使用append方法将新字节添加到片中。 标准库中的bytes.Buffer不是这种情况,但是作为示例,这是不相关的实现细节。




如果b在堆栈上分配的,则它内部的bootstrap将在堆栈上分配,这意味着片b.buf将在不需要附加分配的情况下重用b内部的内存。


grow揭示bootstrap数组已经不足时,将创建一个新的“真实”切片,在该切片中,将复制过去存储(来自“小缓冲区”)中的元素。 之后, Buffer.bootstrap将失去其相关性。 如果Buffer.Reset ,则cap(b.buf)将保持不变,并且不再需要bootstrap数组。


内存在堆中耗尽


可以进一步期望读者至少对Go语言中的转义分析有所了解。


请考虑以下情况:


 func f() *Buffer { var b bytes.Buffer // leak.go:11:6: moved to heap: b return &b // leak.go:12:9: &b escapes to heap } 

这里b将分配在堆上。 这样做的原因是指向b指针泄漏:


 $ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b 

术语学


在本文中,“泄漏”和“转义”几乎是同义词。


编译器本身存在一些差异,例如,值“转义为转义”,但函数参数为“泄漏参数x”。


参数泄漏意味着传递给该参数的参数将在堆上分配。 换句话说,泄漏参数使参数逃逸到堆中。




上面是一个明显的例子,但是这是怎么回事:


 func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } 

这里我们只需要1个字节,一切都适合bootstrap ,缓冲区本身是局部的,不会从函数中“转义”。 您可能会感到惊讶,但是结果是相同的,在堆上分配b



可以肯定的是,您可以使用基准进行检查:


 BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op 

基准清单


 package p import ( "bytes" "testing" ) func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } func BenchmarkLength(b *testing.B) { for i := 0; i < bN; i++ { _ = length() } } 



说明112 B / op


当运行时向分配器询问N个字节时,不必精确分配N个字节。


以下所有结果均针对GOOS=linuxGOARCH=AMD64

 package benchmark import "testing" //go:noinline func alloc9() []byte { return make([]byte, 9) } func BenchmarkAlloc9(b *testing.B) { for i := 0; i < bN; i++ { _ = alloc9() } } 

如果运行, go test -bench=. -benchmem go test -bench=. -benchmem与此测试:


 BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op 

请求了9个字节,分配了16个字节,现在又回到bytes.Buffer


 fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104 

让我们看一下$ GOROOT / src / runtime / sizeclasses.go


 // class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% // 6 80 8192 102 32 19.07% // 7 96 8192 85 32 15.95% // 8 112 8192 73 16 13.56% // ...  

它不适合96字节,已选择112。




但是为什么会这样呢?


发生了什么,为什么


在一开始提到的问题中可以找到对这种情况的一些分析。
还有一个简单的复制器


问题所在仅在赋值b.buf = b.bootstrap[:] 。 此代码使转义分析假定b.bootstrap “运行”,并且由于它是一个数组,因此存储在对象本身内部,这意味着所有b必须分配在堆上。


如果引导程序是切片,而不是数组,则不会发生,因为存在针对将切片从对象分配给对象本身的特别优化:


 //   ,     , // object      . object.buf1 = object.buf2[a:b] 

为什么这种优化不适用于数组的答案已经在上面提出,但这是从esc.go#L835-L866本身的挤压(整个优化代码通过参考突出显示):


 // Note, this optimization does not apply to OSLICEARR, // because it does introduce a new pointer into b that was not already there // (pointer to b itself). After such assignment, if b contents escape, // b escapes as well. If we ignore such OSLICEARR, we will conclude // that b does not escape when b contents do. 

值得在这里添加的是,对于指针分析器,有多个级别的“泄漏”,其中主要是:


  1. 对象本身转义(b转义)。 在这种情况下,对象本身需要在堆上分配。
  2. 对象的元素(b内容转义)转义。 在这种情况下,对象中的指针被认为是转义的。

数组的情况很特殊,如果数组泄漏,包含它的对象也必须泄漏。


转义分析仅根据被分析函数主体中可用的信息来决定是否可以将对象放置在堆栈上。 Buffer.grow方法按指针取b ,因此需要计算一个合适的放置位置。 由于在数组的情况下,我们无法将"b escape""b contents escape"区分开,因此我们必须更加悲观,并得出这样的结论: b不安全地放置在堆栈上。


假设相反, self-assignment模式对数组的解析与对切片的解析相同:


 package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:] // ignoring self-assignment to b.slice sink = b.array // b.array escapes to heap // b does not escape } 

在这种情况下将b放置在堆栈上的决定将导致灾难:退出退出在其中创建b的函数之后,接收sink将引用的内存将不过是垃圾。


数组指针


想象一下,我们的Buffer声明有所不同:


 const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte } 

与常规数组不同,指向数组的指针不会将所有元素存储在Buffer自身内部。 这意味着,如果堆上的bootstrap分配不要求堆上的Buffer分配。 由于转义分析可以在可能的情况下在堆栈上分配指针字段,因此我们可以假定这样的Buffer定义会更成功。


但这是理论上的。 实际上,指向数组的指针没有太多处理,并且与常规数组中的切片属于同一类别,这并不完全正确。 CL133375:cmd / compile / internal / gc:在esc.go中处理数组切片自分配的目的是纠正这种情况。


假设此更改已被Go编译器接受。


零值我们失去了


不幸的是,从[64]byte*[64]byte存在一个问题:现在我们不能在没有显式初始化的情况下使用bootstrapBuffer的零值不再有用,我们需要构造函数。


 func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} } 

我们返回Buffer而不是*Buffer ,以避免指针分析出现问题(在Go中非常保守),并考虑到NewBuffer始终内置在调用位置这一事实,因此不会有不必要的复制。


在将NewBuffer主体嵌入到调用位置之后,转义分析可以尝试证明new(*[smallBufSize]byte)没有超过调用它的函数的框架的生存期。 如果是这样,则分配将在堆栈上。


英特尔字节缓冲


上面描述的优化应用于intel-go / bytebuf包中


该库导出bytebuf.Buffer类型,该类型重复99.9% bytes.Buffer 。 所有更改都简化为引入了构造函数( bytebuf.New )和指向数组而不是常规数组的指针:


 type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] - bootstrap [64]byte // helps small buffers avoid allocation. + bootstrap *[64]byte // helps small buffers avoid allocation. lastRead readOp // last read operation (for Unread*). } 

这是与bytes.Buffer的性能比较:


 name old time/op new time/op delta String/empty-8 138ns ±13% 24ns ± 0% -82.94% (p=0.000 n=10+8) String/5-8 186ns ±11% 60ns ± 1% -67.82% (p=0.000 n=10+10) String/64-8 225ns ±10% 108ns ± 6% -52.26% (p=0.000 n=10+10) String/128-8 474ns ±17% 338ns ±13% -28.57% (p=0.000 n=10+10) String/1024-8 889ns ± 0% 740ns ± 1% -16.78% (p=0.000 n=9+10) name old alloc/op new alloc/op delta String/empty-8 112B ± 0% 0B -100.00% (p=0.000 n=10+10) String/5-8 117B ± 0% 5B ± 0% -95.73% (p=0.000 n=10+10) String/64-8 176B ± 0% 64B ± 0% -63.64% (p=0.000 n=10+10) String/128-8 368B ± 0% 256B ± 0% -30.43% (p=0.000 n=10+10) String/1024-8 2.16kB ± 0% 2.05kB ± 0% -5.19% (p=0.000 n=10+10) name old allocs/op new allocs/op delta String/empty-8 1.00 ± 0% 0.00 -100.00% (p=0.000 n=10+10) String/5-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/64-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/128-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) String/1024-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) 

所有其他信息都可以在README中获得


由于无法使用零值并且无法绑定到构造函数New ,因此无法将此优化应用于bytes.Buffer


这是制作更快的bytes.Buffer的唯一方法吗? 答案是否定的。 但这绝对是在实现上需要最少更改的方法。


转义分析计划


按照目前的形式,Go中的转义分析非常薄弱。 几乎所有带有指针值的操作都会导致在堆上进行分配,即使这不是一个合理的决定。


我将大部分时间都花在golang / go项目上,以解决这些问题,因此在即将发布的版本(1.12)中可能会进行一些改进。


您可以在我的下一篇文章中了解编译器这一部分的结果和内部结构的详细信息。 我将尝试提供一组建议,这些建议在某些情况下将有助于结构化代码,从而减少不必要的内存分配。

Source: https://habr.com/ru/post/zh-CN422447/


All Articles