Go的垃圾回收机制在实践中有哪些需要注意的地方?

标签: go 垃圾回收 实践 | 发表时间:2013-09-08 20:39 | 作者:达达
出处:http://www.zhihu.com
不想看长篇大论的,这里先给个结论,go的gc还不完善但也不算不靠谱,关键看怎么用,尽量不要创建大量对象,也尽量不要频繁创建对象,这个道理其实在所有带gc的编程语言也都通用。

想知道如何提前预防和解决问题的,请耐心看下去。

先介绍下我的情况,我们团队的项目《仙侠道》在7月15号第一次接受玩家测试,这个项目的服务端完全用Go语言开发的,游戏数据都放在内存中由go 管理。

在上线测试后我对程序做了很多调优工作,最初是稳定性优先,所以先解决的是内存泄漏问题,主要靠memprof来定位问题,接着是进一步提高性能,主要靠cpuprof和自己做的一些统计信息来定位问题。

调优性能的过程中我从cpuprof的结果发现发现gc的scanblock调用占用的cpu竟然有40%多,于是我开始搞各种对象重用和尽量避免不必要的对象创建,效果显著,CPU占用降到了10%多。

但我还是挺不甘心的,想继续优化看看。网上找资料时看到GOGCTRACE这个环境变量可以开启gc调试信息的打印,于是我就在内网测试服开启了,每当go执行gc时就会打印一行信息,内容是gc执行时间和回收前后的对象数量变化。

我惊奇的发现一次gc要20多毫秒,我们服务器请求处理时间平均才33微秒,差了一个量级别呢。

于是我开始关心起gc执行时间这个数值,它到底是一个恒定值呢?还是更数据多少有关呢?

我带着疑问在外网玩家测试的服务器也开启了gc追踪,结果更让我冒冷汗了,gc执行时间竟然达到300多毫秒。go的gc是固定每两分钟执行一次,每次执行都是暂停整个程序的,300多毫秒应该足以导致可感受到的响应延迟。

所以缩短gc执行时间就变得非常必要。从哪里入手呢?首先,可以推断gc执行时间跟数据量是相关的,内网数据少外网数据多。其次,gc追踪信息把对象数量当成重点数据来输出,估计扫描是按对象扫描的,所以对象多扫描时间长,对象少扫描时间短。

于是我便开始着手降低对象数量,一开始我尝试用cgo来解决问题,由c申请和释放内存,这部分c创建的对象就不会被gc扫描了。

但是实践下来发现cgo会导致原有的内存数据操作出些诡异问题,例如一个对象明明初始化了,但还是读到非预期的数据。另外还会引起go运行时报申请内存死锁的错误,我反复读了go申请内存的代码,跟我直接用c的malloc完全都没关联,实在是很诡异。

我只好暂时放弃cgo的方案,另外想了个法子。一个玩家有很多数据,如果把非活跃玩家的数据序列化成一个字节数组,就等于把多个对象压缩成了一个,这样就可以大量减少对象数量。

我按这个思路用快速改了一版代码,放到外网实际测试,对象数量从几百万降至几十万,gc扫描时间降至二十几微秒。

效果不错,但是要用玩家数据时要反序列化,这个消耗太大,还需要再想办法。

于是我索性把内存数据都改为结构体和切片存放,之前用的是对象和单向链表,所以一条数据就会有一个对象对应,改为结构体和结构体切片,就等于把多个对象数据缩减下来。

结果如预期的一样,内存多消耗了一些,但是对象数量少了一个量级。

其实项目之初我就担心过这样的情况,那时候到处问人,对象多了会不会增加gc负担,导致gc时间过长,结果没得到答案。

现在我填过这个坑了,可以确定的说,会。大家就不要再往这个坑跳了。

如果go的gc聪明一点,把老对象和新对象区别处理,至少在我这个应用场景可以减少不必要的扫描,如果gc可以异步进行不暂停程序,我才不在乎那几百毫秒的执行时间呢。

但是也不能完全怪go不完善,如果一开始我早点知道用GOGCTRACE来观测,就可以比较早点发现问题从而比较根本的解决问题。但是既然用了,项目也上了,没办法大改,只能见招拆招了。

总结以下几点给打算用go开发项目或已经在用go开发项目的朋友:
1、尽早的用memprof、cpuprof、GCTRACE来观察程序。
2、关注请求处理时间,特别是开发新功能的时候,有助于发现设计上的问题。
3、尽量避免频繁创建对象(&abc{}、new(abc{})、make()),在频繁调用的地方可以做对象重用。
4、尽量不要用go管理大量对象,内存数据库可以完全用c实现好通过cgo来调用。

手机回复打字好累,先写到这里,后面再来补充案例的数据。

数据补充:

图1,7月22日的一次cpuprof观测,采样3000多次调用,数据显示scanblock吃了43.3%的cpu。


图2,7月23日,对修改后的程序做cpuprof,采样1万多次调用,数据显示cpu占用降至9.8%



数据1,外网服务器的第一次gc trace结果,数据显示gc执行时间有400多ms,回收后对象数量1659922个:
   gc13(1): 308+92+1 ms , 156 -> 107 MB 3339834 -> 1659922 (12850245-11190323) objects, 0(0) handoff, 0(0) steal, 0/0/0 yields

数据2,程序做了优化后的外网服务器gc trace结果,数据显示gc执行时间30多ms,回收后对象数量126097个:
   gc14(6): 16+15+1 ms, 75 -> 37 MB 1409074 -> 126097 (10335326-10209229) objects, 45(1913) handoff, 34(4823) steal, 455/283/52 yields


示例1,数据结构的重构过程:

最初的数据结构类似这样
   // 玩家数据表的集合
type tables struct {
        tableA *tableA
        tableB *tableB
        tableC *tableC
        // ...... 此处省略一大堆表
}

// 每个玩家只会有一条tableA记录
type tableA struct {
        fieldA int
        fieldB string
}

// 每个玩家有多条tableB记录
type tableB struct {
        xxoo int
        ooxx int
        next *tableB  // 指向下一条记录
}

// 每个玩家只有一条tableC记录
type tableC struct {
        id int
        value int64
}

最初的设计会导致每个玩家有一个tables对象,每个tables对象里面有一堆类似tableA和tableC这样的一对一的数据,也有一堆类似tableB这样的一对多的数据。

假设有1万个玩家,每个玩家都有一条tableA和一条tableC的数据,又各有10条tableB的数据,那么将总的产生1w (tables) + 1w (tableA) + 1w (tableC) + 10w (tableB)的对象。

而实际项目中,表数量会有大几十,一对多和一对一的表参半,对象数量随玩家数量的增长倍数显而易见。

为什么一开始这样设计?

1、因为有的表可能没有记录,用对象的形式可以用 == nil 来判断是否有记录
2、一对多的表可以动态增加和删除记录,所以设计成链表
3、省内存,没数据就是没数据,有数据才有对象

改造后的设计:

   // 玩家数据表的集合
type tables struct {
        tableA tableA
        tableB []tableB
        tableC tableC
        // ...... 此处省略一大堆表
}

// 每个玩家只会有一条tableA记录
type tableA struct {
        _is_nil bool
        fieldA int
        fieldB string
}

// 每个玩家有多条tableB记录
type tableB struct {
        _is_nil bool
        xxoo int
        ooxx int
}

// 每个玩家只有一条tableC记录
type tableC struct {
        _is_nil bool
        id int
        value int64
} 

一对一表用结构体,一对多表用slice,每个表都加一个_is_nil的字段,用来表示当前的数据是否是有用的数据。

这样修改的结果就是,一万个玩家,产生的对象总量是 1w (tables) + 1w ([]tablesB),跟之前的设计差别很明显。

但是slice不会收缩,而结构体则是一开始就占了内存,所以修改后会导致内存消耗增大。

参考链接:

go的gc代码,scanblock等函数都在里面:
http://golang.org/src/pkg/runtime/mgc0.c

go的runtime包文档有对GOGCTRACE等关键的几个环境变量做说明:
http://golang.org/pkg/runtime/

如何使用cpuprof和memprof,请看《Profiling Go Programs》:
http://blog.golang.org/profiling-go-programs

我做的一些小试验代码,优化都是基于这些试验的数据的,可以参考下:
https://github.com/realint/labs/tree/master/src

— 完 —
本文作者: 达达

【知乎日报——比新闻更多】听亲历者和内行专家八仙过海谈新闻,离现场更近一点。
下载知乎新 app - 知乎日报客户端(Android / iPhone 同步上架):
http://daily.zhihu.com/download
延伸阅读:
为什么 Go 语言的 hello world 输出的是中文「Hello, 世界」?
Go 语言的语法为何被设计的这么有趣?

相关 [go 垃圾回收 实践] 推荐:

Go的垃圾回收机制在实践中有哪些需要注意的地方?

- - 知乎每日精选
不想看长篇大论的,这里先给个结论,go的gc还不完善但也不算不靠谱,关键看怎么用,尽量不要创建大量对象,也尽量不要频繁创建对象,这个道理其实在所有带gc的编程语言也都通用. 想知道如何提前预防和解决问题的,请耐心看下去. 先介绍下我的情况,我们团队的项目《仙侠道》在7月15号第一次接受玩家测试,这个项目的服务端完全用Go语言开发的,游戏数据都放在内存中由go 管理.

图文结合,白话 Go 的垃圾回收原理

- - IT瘾-dev
前面两篇文章介绍了Go语言的 内存分配策略以及 Go协程动态扩充和收缩栈内存的原理,今天这篇文章我们主要来聊一下内存管理的另外一大块内容:垃圾回收. 下面首先我们会聊一下什么是GC (垃圾回收),GC的作用是什么,然后再结合图示用每个人都能听懂的大白话解释Go的GC原理. 现代高级编程语言管理内存的方式分为两种:自动和手动,像C、C++ 等编程语言使用手动管理内存的方式,工程师编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go 等语言使用自动的内存管理系统,有内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC.

JVM理论与实践【堆内存结构与垃圾回收】

- - ITeye博客
        在生产环境下,通常都需要对JVM进行参数优化,其中对垃圾回收器的参数优化是一个非常重要的一方面. 下面重点介绍Java的堆内存,垃圾回收算法,常用的垃圾回收器以及Java堆内存的分配策略,这些内容将作为对JVM进行垃圾回收参数优化的重要基础. 然后通过简单示例验证Java的垃圾回收机制.

【大内存服务GC实践】- 一文看懂G1GC垃圾回收器

- - 有态度的HBase/Spark/BigData
笔者在这个系列的第一篇文章 《一文看懂”ParNew+CMS”垃圾回收器》中详细介绍了”ParNew+CMS”垃圾回收器的工作原理. 文章最后笔者提到CMS垃圾回收器有两个比较显著的问题,一个是长时间运行无法避免Full GC,一个是Remark阶段STW时间较长. 正是因为这两个问题的存在,CMS垃圾回收器在JDK9被标记弃用,慢慢开始退出历史舞台.

jvm垃圾回收

- Cano - 淘宝共享数据平台 tbdata.org
在jvm中堆空间划分为三个代:年轻代(Young Generation)、年老代(Old Generation)和永久代(Permanent Generation). 年轻代和年老代是存储动态产生的对象. 永久带主要是存储的是java的类信息,包括解析得到的方法、属性、字段等等. 我们这里讨论的垃圾回收主要是针对年轻代和年老代.

如何降低90%Java垃圾回收时间?以阿里HBase的GC优化实践为例

- - 数据库 - ITeye博客
      过去的一年里,我们准备在Ali-HBase上突破这个被普遍认知的痛点,为此进行了深度分析及全面创新的工作,获得了一些比较好的效果. 以蚂蚁风控场景为例,HBase的线上young GC时间从120ms减少到15ms,结合阿里巴巴JDK团队提供的利器——AliGC,进一步在实验室压测环境做到了5ms.

JVM 垃圾回收算法

- - 码蜂笔记
《深入理解Java虚拟机:JVM高级特性与最佳实践》-笔记. 垃圾回收,Garbage Collection,简称GC. 判断对象是否存活一般有两种方式:. 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收. 此方法简单,无法解决对象相互循环引用的问题.

Java中的垃圾回收

- - Java译站
前文中对标记删除算法的介绍更多还是偏理论性质的. 实践中,为了更好地满足现实的场景及需求,还需要对算法进行大量的调整. 举个简单的例子,我们来看下JVM需要记录哪些信息才能让我们得以安全地分配对象空间. 碎片及整理(Fragmenting and Compacting). JVM在清除不可达对象之后,还得确保它们所在的空间是可以进行复用的.

Java垃圾回收调优

- - 编程语言 - ITeye博客
在Java中,通常通讯类型的服务器对GC(Garbage Collection)比较敏感. 通常通讯服务器每秒需要处理大量进出的数据包,需要解析,分解成不同的业务逻辑对象并做相关的业务处理,这样会导致大量的临时对象被创建和回收. 同时服务器如果需要同时保存用户状态的话,又会产生很多永久的对象,比如用户session.

谈谈ActionScript垃圾回收(下)

- Tomyail - Kevin Cao's Blog
前文我们介绍了GC的工作机制和帮助GC更好工作的最佳实践. 其实只要我们遵守谁创建谁清理的原则来管理对象,就能基本上避免回收失败,也就是我们通常说的内存泄漏问题. 但是在实际项目中我们还会看到各种原因引起的内存泄漏,接下来就让我们一起来找出病因. 首先我们需要观察症状,也就是内存的使用曲线. 排查的方法是反复执行一些创建和删除对象的方法、反复加载和卸载子文件.