最近尝试把开发环境,升级到Golang1.7.1后,程序会偶发性的宕掉,查看日志后,发现总是在一个计算切片的哈希值的地方,错误信息是:
unexpected fault address 0xc043df4000,
fatal error: fault
在1.7之前程序持续运行2年了,从来没有出现这个问题,怀疑是Golang编译器升级到SSA后导致的。将程序的代码精简为以下函数:
//本代码的主要作用是,把一个字符串的Assii的值累加起来。
func SimpleCrc(ptr uintptr, size int) int {
ret := 0
maxPtr := ptr + uintptr(size)
for ptr < maxPtr {
b := *(*byte)(unsafe.Pointer(ptr)) //出错的地方
ret += int(b)
ptr++
}
return ret
}
注:实际的代码比这个复杂很多。采用类似这种写法后,相比常规写法性能提升高达8倍。
分析错误直接表现是“非法内存地址访问”导致的,只有一种原因是“字符串使用的内存被SSA编译释放了”,被GC提前回收了并且归还给了windows操作系统。因此查阅了SSA编译器的原理。发现SSA编译器变得聪明很多,它能根据(既定规则)快速判断出,内存不再被使用,所以内存回收非常迅速。由此思考的着眼点变为:有没有什么办法告知SSA编译器,特定的内存在指定的代码区不要回收?,记得之前看过Golang1.7在runtime包中,增加一个函数func KeepAlive(interface{}) {},查看注释后发现“使用该函数可以设定内存在指定的代码区保持有效”,而不被GC回收。
为了重现上述推断,因此编写以下示例:
// memTest
package main
import (
"fmt"
"reflect"
"runtime"
"unsafe"
)
func SimpleCrc(ptr uintptr, size int) int {
ret := 0
maxPtr := ptr + uintptr(size)
for ptr < maxPtr {
b := *(*byte)(unsafe.Pointer(ptr))
ret += int(b)
ptr++
}
return ret
}
//模拟申请内存,触发Gc回收内存
func Allocation(size int) {
var free []byte
free = make([]byte, size)
if len(free) == 0 {
panic("Allocation Error")
}
}
func SliceCrcTest(slice []byte, N int) (ret int) {
newSlice := []byte(string(slice)) //获取独立内存
sh := (*reflect.SliceHeader)(unsafe.Pointer(&newSlice)) //反射切片结构
ptr, size := uintptr(sh.Data), sh.Len //获取地址尺寸
runtime.GC() //强制内存回收
for i := 0; i < N; i++ {
ret = SimpleCrc(ptr, size) //计算crc校验码
Allocation(size) //模拟申请内存,触发Gc回收内存
}
//runtime.KeepAlive(newSlice) //本行一旦注释后结果不再是1665,取消注释节正确
return
}
func StringCrcTest(str string, N int) (ret int) {
newStr := string([]byte(str)) //获取独立内存
runtime.SetFinalizer(&newStr, func(x *string) {}) //设置回收事件
sh := (*reflect.StringHeader)(unsafe.Pointer(&newStr)) //反射字符串结构
ptr, size := uintptr(sh.Data), sh.Len //获取地址尺寸
runtime.GC() //强制内存回收
for i := 0; i < N; i++ {
ret = SimpleCrc(ptr, size) //计算crc校验码
Allocation(size) //模拟申请内存,触发Gc回收内存
}
//runtime.KeepAlive(newStr) //本行一旦注释后结果不再是1665,取消注释节正确
return
}
func main() {
var B = []byte("1234567890-1234567890-1234567890") //Crc的值为:1665
var S = string(B) //生成字符串
N := 1000000 //循环执行1,000,000次
fmt.Printf("SimpleCrc(\"%s\") = %v\n", B, SliceCrcTest(B, N))
fmt.Printf("SimpleCrc(\"%s\") = %v\n", B, StringCrcTest(S, N))
}
上述代码重现的思路是,首先申请内存,具体是new一个切片或字符串(其值是"1234567890-1234567890-1234567890",它的正确CRC结果是1665),分别传入函数SliceCrcTest和StringCrcTest查看运行结果;这里只介绍SliceCrcTest函数的内部实现思路,StringCrcTest和SliceCrcTest非常一致,请自己分析理解。
在SliceCrcTest函数内部,首先是代码
newSlice := []byte(string(slice)) //获取独立内存
本行代码重复申请了两次内存,其目的是,产生一个局部变量,加快重现GC回收newSlice。
sh := (*reflect.SliceHeader)(unsafe.Pointer(&newSlice)) //反射切片结构
本行代码是通过反射,获取到切片newSlice的数据结构,目的是读取“1234567890-1234567890-1234567890”的首地址和长度。
ptr, size := uintptr(sh.Data), sh.Len //获取地址尺寸
本行代码是获取“1234567890-1234567890-1234567890”的首地址和长度,到变量ptr, size。
runtime.GC() //强制内存回收
本行代码是强制启动内存回收扫描,然后for循环一百万次,这样做的目的是留出足够的时间让GC取回收内存,循环体类执行代码如下。
ret = SimpleCrc(ptr, size) //计算crc校验码
Allocation(size) //模拟申请内存,触发Gc回收内存
调用SimpleCrc计算“1234567890-1234567890-1234567890”的校验码,并把最后一次的结果保存到ret返回变量(正确值是1665)。Allocation函数是模拟申请一次内存,函数返回后就内存会被GC回收。
//runtime.KeepAlive(newSlice) //本行一旦注释后结果不再是1665,取消注释节正确
这条语句最为关键,本语句被注释了,那么SliceCrcTest的结果应该是0,这代表着,newSlice 内存被GC回收了,并且同一块内存被再次分配给Allocation函数中的free变量,由于free的初始化为由32个‘0’组成的切片,因此SliceCrcTest计算结果变成了“0”。这样就问题重现了,被SSA编译器误认为,内存不在有效,因此GC就会回收。
注:在实际的重现过程中,因为这是一个随机的过程,不同的操作系统可能不会重现,但是只要知道思路和原理,稍微调整一下N的数值,把它加大就会重现。
N := 1000000 //循环执行1,000,000次
总结: 由于Golang的SSA的编译器,变得非常聪明了,因此会把使用反射reflect.StringHeader,reflect.SliceHeader返回值中的uintptr指向的内存块,当成了没有被使用的内存块回收了。
解决办法有两个:
一是尽量不要过分追求性能,使用反射reflect和unsafe包内的函数。这样能避免一些诡异的、很难分析的bug出现。 如果非要使用反射reflect和unsafe包内的函数,请注意一定要使用runtime.KeepAlive告诉SSA编译器,在指定的代码段内,不要回收内存块。