Go程序GC优化经验分享
http://1234n.com/?post/yzsrwa
最近一段时间对《仙侠道》的服务端进行了一系列针对GC的调优,这里跟各位分享一下调优的经验。
游戏第一次上线的时候,大部分精力都投入在做cpuprof和memprof找性能瓶颈和内存泄漏上,没有关注过Go的GC运行情况。
有一次cpuprof里的scanblock调用所占的比例让我注意到Go的GC所带来的性能消耗,记得那份cpuprof里,scanblock调用占到49%。也就是说有一半的CPU时间浪费在了GC上。
于是我开始研究如何进行优化,过程中免不了要分析数据,经过一番搜索,我好到了GOGCTRACE这个环境变量。
用法类似这样:
GOGCTRACE=1 ./my_go_program 2> log_file
通过这个环境变量可以让Go程序在每次GC时都输出信息,信息是输出到标准错误的,所以需要用 2> 把输出重定向到文件里。
输出的内容像这样:
gc16(8): 34+6+5 ms, 367 -> 365 MB 817253 -> 782045 (18216892-17434847) objects, 64(2182) handoff, 72(22022) steal, 553/244/51 yields
其中gc16表示第16次进行GC,后面的(8)表示由8个线程执行,这个线程数对应GOMAXPROCS环境变量,34+6+5 ms分别代表一系列GC动作消耗的时间,这三个时间加起来45ms,就是这个程序在这次GC过程中暂停的时间。
后面接着的是内存、对象数量等,在GC前后的变化,其中最关键的是对象数量,这边可以看到GC后还有782045个对象存在。
我在实际游戏服和内网开发测试服都开启了GOGCTRACE,发现GC暂停时间相差甚大,当时(还未做第一次优化前)外网GC暂停达到400多ms,而内网才20ms。
显然跟内存中数据多少有关系,于是我推测跟内存中对象数量关系最大,原因很简单,假设我是GC开发者,不可能让一个对象占用100M内存跟一万个对象占用100M内存同样消耗性能,显然那一个占用100M内存的对象,当我发现它不需要回收的话,我就不需要做什么事情了,而那一万个对象,我需要逐个检查是否还有被引用,所以内存大小不是关键,对象数量才是关键。
于是我按这个推测进行了第一次性能优化,我把存储游戏内存数据的链表结构改为slice,当初设计成链表是因为数据有插入和删除,slice可以扩容但是要收缩就比较麻烦了,于是想到了链表,链表要删除单个节点的时候,只需要把节点从链表上断开,不需要复制数据,效率高于数组结构。这里直观的表示一下两种数据结构的区别:
type MyData1 struct {
next *MyData
Id int
Name string
}
var mydata1 *MyData1
type MyData2 struct {
Id int
Name string
}
var mydata2 []MyData2
上面示例代码的mydata1用的是链表结构,每个节点都有一个指向下一个节点的指针,想像下存储1万个对象到mydata1,是不是需要创建1万个MyData1类型的对象。
示例中的mydata2用的是slice结构,一个slice就是一个对象,其中的元素都是这一块内存中的值,而不是对象,需要注意 []MyData2 和 []*MyData2 是不一样的,如果换用第二种写法,那么每个元素一样都是一个对象,因为这时候slice存的不是值而是指向对象的指针,而这些指针每一个都分别指到一个对象。
我做了一组不同数据结构跟对象数量关系的实验,可以直观的感受区别:github链接
经过这番改造,对象数量少了一个数量级,具体对少对象我已经记不得了,但是可以自己估计一下,一个mydata1这样的内存表,假设平均20条记录,假设有50个这样的表,就是1000个对象,换成mydata2这样的内存表,就只要50个对象。
当然这样一换,内存占用肯定就上去了,但是实际观测下来,内存占用在可接收范围,甚至还是远小于之前我用erlang开发的游戏,而GC扫描时间从300多ms降到几十ms,降了一个数量级。
本来优化到此我就打算告一段落了,但是随着游戏的持续运行,数据的持续增加,我发现slice自身占用的对象数量也还是值得动动脑筋消除掉的,线上GC暂停时间最高的服务器,达到了100ms,如果再涨上去,一样还是可能达到200ms设置300ms。
所以又继续懂了一些脑筋,比如把玩家数据压缩起来,等需要用的时候再解开来用,尝试过json序列化等等,目的都是把多个对象归并成一个。
但是这些方案都是牺牲数据访问的效率为代价的,需要访问数据时就要反序列化展开数据。
其实在第一次优化时,我大部分时间花在尝试cgo上面,而不是尝试slice上,我第一个思路是用cgo申请内存,伪造成go的对象,这些对象就不受Go的GC管理里,也就不会对GC有负担。但是尝试下来,总是遇到各种指针异常,我可以确信不是我的指针运算问题,但是为什么自己申请的内存会影响到Go的执行,我一直弄不明白,时间不等人,不可能一直研究下去,所以我才想了slice的这个方案,不是最优解但至少暂时解决问题。
而这一次,因为使用了slice,原先的内存数据库的数据结构就变得很单一,而优化的目的也明显,减少slice的内存消耗。正好那阵子我在尝试将SpiderMonkey嵌入到Go,接触到了cgo操作slice的一些技巧,比如将C的数组映射成Go的slice,或者利用reflect.SliceHeader取得slice所指向的内存块地址,然后用cgo复制数据。
于是我就想到用C来申请slice所需内存块,然后自己构造SliceHeader的办法。
这里需要说明下SliceHader和slice之间的关系。
Go提供了一个很有用的数据结构slice,slice比起C时代的数值有很明显的优势,有边界判断、可以反复切割、没有牺牲运行效率,如何做到的呢?官方这片文章有很清楚的说明:点击查看
简单说来,Go的slice其实是一个三个字段的结构体,三个字段分别存放着slice的当前长度、内存块的大小和实际内存块的地址,每次len(slice)的时候是不需要循环计算长度的,只是到结构体里去一下长度,而重新切割的过程,只是重新构造一个指向同一个内存块或块中某一位置的过程,所以不会有内存拷贝和循环等消耗性能的操作。
这个三个字段的结构体,在Go的反射包里面使用SliceHeader类型表示,这让我们的程序有机会构造自己的SliceHeader。
cgo的wiki文档里有这样一段示例代码,演示如何把C的数组包装成Go的slice:
import "C"
import "unsafe"
...
var theCArray *TheCType := C.getTheArray()
length := C.getTheArrayLength()
var theGoSlice []TheCType
sliceHeader := (*reflect.SliceHeader)((unsafe.Pointer(&theGoSlice)))
sliceHeader.Cap = length
sliceHeader.Len = length
sliceHeader.Data = uintptr(unsafe.Pointer(&theCArray[0]))
// now theGoSlice is a normal Go slice backed by the C array
这边用到了unsafe.Pointer,通过Pointer类型,我们可以在Go的程序里实现指针运算,之前我有写过相关文章,这里就不重复介绍了:点击查看
于是我将内存数据库用到的slice类型全部换成自己用C伪造的slice,还好当初内存数据库用的是代码生成器,否则代码就要改死掉了 :)
全部替换完后,我拿外网同样数据对比,优化前的程序GC扫描时间100多ms,对象数量140万,优化后的程序GC扫描时间18ms,对象数量16万。
本来可以就这样打完收功了,但是生活总是充满戏剧性,内网测试的时候发现好友列表里面的名字全乱码了,肯定跟优化有关系,但为什么会乱码呢?
我的推测是go构造的字符串对象被C构造的对象引用,这样的引用导致go把字符串对象当成没人使用,于是就被回收利用了。
我只好把所有字符串字段也全部改为C伪造的对象,原理给伪造slice是一样的,不同的是字符串用StringHeader表示。
经过改造,字符串再也不会乱码了,不过需要很小心的释放内存。
优化过程中Go提供的pprof模块起到了很重要的作用,所有的优化都是以数据为依据的,如果不能看到数据就没有办法定位问题。
程序中可以用 pprof.Lookup("heap") 来获得堆信息,其中包含了对象数量和GC执行时间等有用的数据。
上次群里有人问 map[int]XXX 这样的数据结构是否会有GC问题,正好这个数据结构我之前也考虑过,也在上面的数据结构实验里体现了,map[int]XXX 和 map[int]XXX是一样的,一条数据就是一个对象,对GC是否有影响取决于对象的数量。
从上面的观测数值来看百来万的对象数量所造成的暂停应该还不足以影响程序,除非应用场景对实时性要求非常高。
但是对于游戏这样的常驻内存程序来说,对象的增长速度和对象数量上限也需要留意,比如刚开始对象数量只有几万,随着日子增长,玩家数据增多,对象数量达到百万千万,那时候可能就会有影响了。
之前第一次优化过后正好有人在知乎问Go的GC情况,我回了一帖,里面有比较详细的第一次优化的数据,大家可以参考一下:点击查看