逃逸对性能的影响
在(1)中,通过一个共享在 goroutine 的栈上的值的例子讲解了逃逸分析的基础。还有其他没有介绍的造成值逃逸的场景。为了帮助大家理解,我将调试一个分配内存的程序,并使用非常有趣的方法。
程序
我想了解 io 包,所以我创建了一个简单的项目。给定一个字符序列,写一个函数,可以找到字符串 elvis 并用大写开头的 Elvis 替换它。
func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
// 使用一个字节流来处理
input := bytes.NewBuffer(data)
// 我们想要替换的字节内容长度
size := len(find)
//用来判断的窗口
buf := make([]byte, size)
end := size - 1
// 先从input中读出,填满窗口
if n, err := io.ReadFull(input, buf[:end]); err != nil {
output.Write(buf[:n])
return
}
for {
// 一次读一个(等于窗口右移)
if _, err := io.ReadFull(input, buf[end:]); err != nil {
output.Write(buf[:end])
return
}
// If we have a match, replace the bytes.
if bytes.Compare(buf, find) == 0 {
output.Write(repl)
// Read a new initial number of bytes.
if n, err := io.ReadFull(input, buf[:end]); err != nil {
output.Write(buf[:n])
return
}
continue
}
// Write the front byte since it has been compared.
output.WriteByte(buf[0])
// Slice that front byte out.
copy(buf, buf[1:])
}
}
我想知道的是这个函数的性能表现得怎么样,以及它在堆上分配带来什么样的压力。为了这个目的,我们将进行压力测试。
压力测试(Benchmarking)
func BenchmarkAlgorithmOne(b *testing.B) {
var output bytes.Buffer
in := assembleInputStream()
find := []byte("elvis")
repl := []byte("Elvis")
b.ResetTimer()
for i := 0; i < b.N; i++ {
output.Reset()
algOne(in, find, repl, &output)
}
}
D:\GoProject\GoStudy\mem>go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
goos: windows
goarch: amd64
BenchmarkAlgorithmOne-8 1015215 3451 ns/op 53 B/op 2 allocs/op
PASS
ok _/D_/GoProject/GoStudy/mem 8.755s
运行完压力测试后,我们可以看到 algOne 函数分配了两次值,每次分配了 117 个字节。这真的很棒,但我们还需要知道哪行代码造成了分配。为了这个目的,我们需要生成压力测试的分析数据。
性能分析(Profiling)
为了生成分析数据,我们将再次运行压力测试,但这次为了生成内存检测数据,我们打开 -memprofile 开关。
一旦压力测试完成,测试工具就会生成两个新的文件
456 mem.out
3,398,144 mem.test.exe
有了分析数据和二进制测试文件,我们就可以运行 pprof 工具学习数据分析.
当分析内存数据时,为了轻而易举地得到我们要的信息,你会想用 -alloc_space 选项替代默认的 -inuse_space 选项。这将会向你展示每一次分配发生在哪里,不管你分析数据时它是不是还在内存中。
在 (pprof) 提示下,我们使用 list 命令检查 algOne 函数。这个命令可以使用正则表达式作为参数找到你要的函数。
(pprof) list algOne
Total: 86.50MB
ROUTINE ======================== _/D_/GoProject/GoStudy/mem.algOne in D:\GoProject\GoStudy\mem\demo1.go
7.50MB 86.50MB (flat, cum) 100% of Total
. . 37: return &u
. . 38:}
. . 39:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
. . 40:
. . 41: // 使用一个字节流来处理
. 79MB 42: input := bytes.NewBuffer(data)
. . 43:
. . 44: // 我们想要替换的字节内容长度
. . 45: size := len(find)
. . 46:
. . 47: //用来判断的窗口
7.50MB 7.50MB 48: buf := make([]byte, size)
. . 49: end := size - 1
. . 50:
. . 51: // 先从input中读出,填满窗口
. . 52: if n, err := io.ReadFull(input, buf[:end]); err != nil {
. . 53: output.Write(buf[:n])
func NewBuffer(buf []byte) *Buffer { return &Buffer{buf: buf} }
基于这次的数据分析,我们现在知道了 input,buf 切片在堆中分配。因为 input 是指针变量,分析数据表明 input 指针变量指定的 bytes.Buffer 值分配了.
编译器报告
&bytes.Buffer literal escapes to heap:
.\demo1.go:42:26: flow: ~R0 = &{storage for &bytes.Buffer literal}:
.\demo1.go:42:26: from &bytes.Buffer literal (spill) at .\demo1.go:42:26
.\demo1.go:42:26: from ~R0 = (assign-pair) at .\demo1.go:42:26
.\demo1.go:42:26: flow: input = ~R0:
.\demo1.go:42:26: from input := (*bytes.Buffer)(~R0) (assign) at .\demo1.go:42:8
.\demo1.go:42:26: flow: io.r = input:
.\demo1.go:42:26: from input (interface-converted) at .\demo1.go:69:28
.\demo1.go:42:26: from io.r, io.buf = (assign-pair) at .\demo1.go:69:28
.\demo1.go:42:26: flow: {heap} = io.r:
.\demo1.go:42:26: from io.ReadAtLeast(io.r, io.buf, len(io.buf)) (call parameter) at .\demo1.go:69:28
interface-converted
这几行告诉我们代码中的第 93 行造成了逃逸。input 变量被赋值给一个接口变量。
42 input := bytes.NewBuffer(data)
69 if n, err := io.ReadFull(input, buf[:end]); err != nil
func ReadFull(r Reader, buf []byte) (n int, err error) {
return ReadAtLeast(r, buf, len(buf))
}
传递 bytes.Buffer 地址到调用栈,在 Reader 接口变量中存储会造成一次逃逸。现在我们知道使用接口变量是需要开销的:分配和重定向。所以,如果没有很明显的使用接口的原因,你可能不想使用接口。
为了避免这个问题,现在使用input自身的read函数
func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
// 使用一个字节流来处理
input := bytes.NewBuffer(data)
// 我们想要替换的字节内容长度
size := len(find)
//用来判断的窗口
buf := make([]byte, size)
end := size - 1
// 先从input中读出,填满窗口
if n, err := input.Read(buf[:end]); err != nil {
output.Write(buf[:n])
return
}
for {
// 一次读一个(等于窗口右移)
if _, err := input.Read(buf[:end]); err != nil {
output.Write(buf[:end])
return
}
// If we have a match, replace the bytes.
if bytes.Compare(buf, find) == 0 {
output.Write(repl)
// Read a new initial number of bytes.
if n, err := input.Read(buf[:end]); err != nil {
output.Write(buf[:n])
return
}
continue
}
// Write the front byte since it has been compared.
output.WriteByte(buf[0])
// Slice that front byte out.
copy(buf, buf[1:])
}
}
并且再次测试
D:\GoProject\GoStudy\mem>go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
goos: windows
goarch: amd64
BenchmarkAlgorithmOne-8 5362116 717 ns/op 5 B/op 1 allocs/op
PASS
可以看到从3451ns直接降到了717ns
解决了这个问题,我们现在可以关注 buf 切片数组。如果再次使用测试代码生成分析数据,我们应该能够识别到造成剩下的分配的原因。
.\demo1.go:48:13: make([]byte, size) escapes to heap:
.\demo1.go:48:13: flow: {heap} = &{storage for make([]byte, size)}:
.\demo1.go:48:13: from make([]byte, size) (non-constant size) at .\demo1.go:48:13
non-constant size
告诉我们这是动态类型的逃逸,给slice赋值时,编译时,size的大小是不确定的。
为了验证,将大小直接设置为常量5,然后再次运行压力测试
D:\GoProject\GoStudy\mem>go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
goos: windows
goarch: amd64
BenchmarkAlgorithmOne-8 6599544 613 ns/op 0 B/op 0 allocs/op
PASS
这次从713ns降低到了613ns证明我们的分析是正确的。
结论
Go 拥有一些神奇的工具使你能了解编译器作出的跟逃逸分析相关的一些决定。基于这些信息,你可以通过重构代码使得值存在于栈中而不需要在(被重新分配到)堆中。你不是想去掉所有软件中所有的内存(再)分配,而是想最小化这些分配。
这就是说,写程序时永远不要把性能作为第一优先级,因为你并不想(在写程序时)一直猜测性能。写正确的代码才是你第一优先级。这意味着,我们首先要关注的是完整性、可读性和简单性。一旦有了可以运行的程序,才需要确定程序是否足够快。假如程序不够快,那么使用语言提供的工具来查找和解决性能问题。
参考:
Go 语言机制之栈与指针
Go 语言机制之逃逸分析
Go 语言机制之内存剖析