我目前正在研究 Go 中的一些性能敏感代码。在某一时刻,我有一个特别紧密的内循环,它连续做三件事:
获得几个指向数据的指针。如果发生罕见错误,这些指针中的一个或多个可能是
nil
.检查是否发生此错误,如果发生则记录错误。
使用存储在指针中的数据。
下面显示的是一个具有相同结构的玩具程序(尽管指针实际上永远不会为 nil)。
package main
import (
"math/rand"
"fmt"
)
const BigScaryNumber = 1<<25
func DoWork() {
sum := 0
for i := 0; i < BigScaryNumber; i++ {
// Generate pointers.
n1, n2 := rand.Intn(20), rand.Intn(20)
ptr1, ptr2 := &n1, &n2
// Check if pointers are nil.
if ptr1 == nil || ptr2 == nil {
fmt.Printf("Pointers %v %v contain a nil.\n", ptr1, ptr2)
break
}
// Do work with pointer contents.
sum += *ptr1 + *ptr2
}
}
func main() {
DoWork()
}
当我在我的机器上运行它时,我得到以下信息:
$ go build alloc.go && time ./alloc
real 0m5.466s
user 0m5.458s
sys 0m0.015s
但是,如果我删除打印语句,我会得到以下信息:
$ go build alloc_no_print.go && time ./alloc_no_print
real 0m4.070s
user 0m4.063s
sys 0m0.008s
由于实际上从未调用过 print 语句,因此我调查了 print 语句是否以某种方式导致指针分配到堆而不是堆栈上。-m
在原始程序上运行带有标志的编译器会给出:
$ go build -gcflags=-m alloc.go
# command-line-arguments
./alloc.go:14: moved to heap: n1
./alloc.go:15: &n1 escapes to heap
./alloc.go:14: moved to heap: n2
./alloc.go:15: &n2 escapes to heap
./alloc.go:19: DoWork ... argument does not escape
在打印无语句程序上执行此操作时
$ go build -gcflags=-m alloc_no_print.go
# command-line-arguments
./alloc_no_print.go:14: DoWork &n1 does not escape
./alloc_no_print.go:14: DoWork &n2 does not escape
确认即使是未使用fmt.Printf()
的也会导致堆分配,这对性能有非常实际的影响。我可以通过替换fmt.Printf()
为不执行任何操作并将*int
s 作为参数而不是interface{}
s 的可变参数函数来获得相同的行为:
func VarArgsError(ptrs ...*int) {
panic("An error has occurred.")
}
我认为这种行为是因为 Go 会在将指针放在切片中时在堆上分配指针(尽管我不确定这是转义分析例程的实际行为,但我不知道它如何能够安全地否则做)。
这个问题有两个目的:首先,我想知道我对情况的分析是否正确,因为我不太了解 Go 的逃生分析是如何工作的。其次,我想要在不引起不必要分配的情况下保持原始程序的行为的建议。我最好的猜测是Copy()
在将指针传递到 print 语句之前将函数包装在指针周围:
fmt.Printf("Pointers %v %v contain a nil.", Copy(ptr1), Copy(ptr2))
其中Copy()
定义为
func Copy(ptr *int) *int {
if ptr == nil {
return nil
} else {
n := *ptr
return &n
}
}
虽然这给了我与无打印语句情况相同的性能,但它很奇怪,而不是我想为每个变量类型重写然后环绕我所有的错误日志代码的那种东西。