Golang升级到1.7后,之前正确的函数出现错误,分析原因及解决办法

Golang升级到1.7后,之前正确的函数出现错误,分析原因及解决办法

最近尝试把开发环境,升级到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指向的内存块,当成了没有被使用的内存块回收了。

解决办法有两个:

  1. 一是尽量不要过分追求性能,使用反射reflect和unsafe包内的函数。这样能避免一些诡异的、很难分析的bug出现。
  2. 如果非要使用反射reflect和unsafe包内的函数,请注意一定要使用runtime.KeepAlive告诉SSA编译器,在指定的代码段内,不要回收内存块。
编辑于 2016-10-06 14:55