http://ruizeng.net/golang-gc-internals/
摘要
在实际使用go语言的过程中,碰到了一些看似奇怪的内存占用现象,于是决定对go语言的垃圾回收模型进行一些研究。本文对研究的结果进行一下总结。
什么是垃圾回收?
曾几何时,内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序员必须对内存小心的进行管理操作,控制内存的申请及释放。稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位,一直成为困扰开发者的噩梦。如何解决这个头疼的问题呢?过去一般采用两种办法:
- 内存泄露检测工具。这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。
- 智能指针。这是c++中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是采用最广泛的做法,但是对程序员有一定的学习成本(并非语言层面的原生支持),而且一旦有忘记使用的场景依然无法避免内存泄露。
为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。
常见的垃圾回收方法
引用计数(reference counting)
这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。
这种方法的优点是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa框架,php,python等。简单引用计数算法也有明显的缺点:
- 频繁更新引用计数降低了性能。一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放。等等还有很多其他方法,具体可以参考这里。
- 循环引用问题。当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决办法是避免产生循环引用,如cocoa引入了strong指针和weak指针两种指针类型。或者系统检测循环引用并主动打破循环链。当然这也增加了垃圾回收的复杂度。
标记-清除(mark and sweep)
该方法分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。
分代收集(generation)
经过大量实际观察得知,在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为 代(generation)的空间。新创建的对象存放在称为 新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被 提升(promotion)到老年代中。因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。
GO的垃圾回收器
go语言垃圾回收总体采用的是经典的mark and sweep算法。
- 1.3版本以前,golang的垃圾回收算法都非常简陋,然后其性能也广被诟病:go runtime在一定条件下(内存超过阈值或定期如2min),暂停所有任务的执行,进行mark&sweep操作,操作完成后启动所有任务的执行。在内存使用较多的场景下,go程序在进行垃圾回收时会发生非常明显的卡顿现象(Stop The World)。在对响应速度要求较高的后台服务进程中,这种延迟简直是不能忍受的!这个时期国内外很多在生产环境实践go语言的团队都或多或少踩过gc的坑。当时解决这个问题比较常用的方法是尽快控制自动分配内存的内存数量以减少gc负荷,同时采用手动管理内存的方法处理需要大量及高频分配内存的场景。
- 1.3版本开始go team开始对gc性能进行持续的改进和优化,每个新版本的go发布时gc改进都成为大家备受关注的要点。1.3版本中,go runtime分离了mark和sweep操作,和以前一样,也是先暂停所有任务执行并启动mark,mark完成后马上就重新启动被暂停的任务了,而是让sweep任务和普通协程任务一样并行的和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行。go team自己的说法是减少了50%-70%的暂停时间。
- 1.4版本(当前最新稳定版本)对gc的性能改动并不多。1.4版本中runtime很多代码取代了原生c语言实现而采用了go语言实现,对gc带来的一大改变是可以是实现精确的gc。c语言实现在gc时无法获取到内存的对象信息,因此无法准确区分普通变量和指针,只能将普通变量当做指针,如果碰巧这个普通变量指向的空间有其他对象,那这个对象就不会被回收。而go语言实现是完全知道对象的类型信息,在标记时只会遍历指针指向的对象,这样就避免了C实现时的堆内存浪费(解决约10-30%)。
- 1.5版本go team对gc又进行了比较大的改进(1.4中已经埋下伏笔如write barrier的引入),官方的主要目标是减少延迟。go 1.5正在实现的垃圾回收器是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。分代算法上文已经提及,是一种比较好的垃圾回收管理策略,然1.5版本中并未考虑实现;我猜测的原因是步子不能迈太大,得逐步改进,go官方也表示会在1.6版本的gc优化中考虑。同时引入了上文介绍的三色标记法,这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间。 由此可以看到,一路走来直到1.5版本,go的垃圾回收性能也是一直在提升,但是相对成熟的垃圾回收系统(如java jvm和javascript v8),go需要优化的路径还很长(但是相信未来一定是美好的~)。
实践经验
团队在实践go语言时同样碰到最多和最棘手的问题也是内存问题(其中gc为主),这里把遇到的问题和经验总结下,欢迎大家一起交流探讨。
go程序内存占用大的问题
这个问题在我们对后台服务进行压力测试时发现,我们模拟大量的用户请求访问后台服务,这时各服务模块能观察到明显的内存占用上升。但是当停止压测时,内存占用并未发生明显的下降。花了很长时间定位问题,使用gprof等各种方法,依然没有发现原因。最后发现原来这时正常的…主要的原因有两个,
一是go的垃圾回收有个触发阈值,这个阈值会随着每次内存使用变大而逐渐增大(如初始阈值是10MB则下一次就是20MB,再下一次就成为了40MB…),如果长时间没有触发gc go会主动触发一次(2min)。高峰时内存使用量上去后,除非持续申请内存,靠阈值触发gc已经基本不可能,而是要等最多2min主动gc开始才能触发gc。
第二个原因是go语言在向系统交还内存时只是告诉系统这些内存不需要使用了,可以回收;同时操作系统会采取“拖延症”策略,并不是立即回收,而是等到系统内存紧张时才会开始回收这样该程序又重新申请内存时就可以获得极快的分配速度。
gc时间长的问题
对于对用户响应事件有要求的后端程序,golang gc时的stop the world兼职是噩梦。根据上文的介绍,1.5版本的go再完成上述改进后应该gc性能会提升不少,但是所有的垃圾回收型语言都难免在gc时面临性能下降,对此我们对于应该尽量避免频繁创建临时堆对象(如&abc{}, new, make等)以减少垃圾收集时的扫描时间,对于需要频繁使用的临时对象考虑直接通过数组缓存进行重用;很多人采用cgo的方法自己管理内存而绕开垃圾收集,这种方法除非迫不得已个人是不推荐的(容易造成不可预知的问题),当然迫不得已的情况下还是可以考虑的,这招带来的效果还是很明显的~
goroutine泄露的问题
我们的一个服务需要处理很多长连接请求,实现时,对于每个长连接请求各开了一个读取和写入协程,全部采用endless for loop不停地处理收发数据。当连接被远端关闭后,如果不对这两个协程做处理,他们依然会一直运行,并且占用的channel也不会被释放…这里就必须十分注意,在不使用协程后一定要把他依赖的channel close并通过再协程中判断channel是否关闭以保证其退出。
http://wangzhezhe.github.io/blog/2016/04/30/golang-gc/
Golang-gc基本知识
这一部分主要介绍golang gc的一些入门的相关知识,由于gc内容涉及比较多,一点一点慢慢整理。
Golang GC的背景
- golang是基于garbage collection的语言,这是它的设计原则。
- 作为一个有垃圾回收器的语言,gc与程序交互时候的效率会影响到整个程序的运行效率。
- 通常程序本身的内存管理会影响gc和程序之间的效率,甚至造成性能瓶颈。
Golang GC的相关问题
主要参的这个:
是14年写的,估计那个时候的gc机制还比较simple,新版本的golang对gc的改动应该会比较大
还有那个go语言读书笔记中关于golang gc 的相关部分
关于内存泄露
“内存泄露”(Memory Leak)这个词看似自己很熟悉,可实际上却也从没有看过它的准确含义。
内存泄露,是从操作系统的角度上来阐述的,形象的比喻就是“操作系统可提供给所有进程的存储空间(虚拟内存空间)正在被某个进程榨干”,导致的原因就是程序在运行的时候,会不断地动态开辟的存储空间,这些存储空间在在运行结束之后后并没有被及时释放掉。应用程序在分配了某段内存之后,由于设计的错误,会导致程序失去了对该段内存的控制,造成了内存空间的浪费。
如果程序在内存空间内申请了一块内存,之后程序运行结束之后,没有把这块内存空间释放掉,而且对应的程序又没有很好的gc机制去对程序申请的空间进行回收,这样就会导致内存泄露。
从用户的角度来说,内存泄露本身不会有什么危害,因为这不是对用户功能的影响,但是“内存泄露”如果进
对于C和C++这种没有Garbage Collection 的语言来讲,我们主要关注两种类型的内存泄漏:
-
堆内存泄漏(Heap leak)。对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生Heap Leak.
-
系统资源泄露(Resource Leak).主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
内存泄露涉及到的相关问题还有很多,这里暂不展开讨论。
常见的GC模式
具体的优缺点可以参考,这里只是进行大致介绍。
-
引用计数(reference counting)每个对象维护一个引用计数器,当引用该对象的对象被销毁或者更新的时候,被引用对象的引用计数器自动减1,当被应用的对象被创建,或者赋值给其他对象时,引用+1,引用为0的时候回收,思路简单,但是频繁更新引用计数器降低性能,存在循环以引用(php,Python所使用的)
-
标记清除(mark and sweep)就是golang所使用的,从根变量来时遍历所有被引用对象,标记之后进行清除操作,对未标记对象进行回收,缺点:每次垃圾回收的时候都会暂停所有的正常运行的代码,系统的响应能力会大大降低,各种mark&swamp变种(三色标记法),缓解性能问题。
-
分代搜集(generation)jvm就使用的分代回收的思路。在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。
因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生(先分类,之后再对症下药),分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。
golang中的gc通常是如何工作的
golang中的gc基本上是标记清除的思路:
在内存堆中(由于有的时候管理内存页的时候要用到堆的数据结构,所以称为堆内存)存储着有一系列的对象,这些对象可能会与其他对象有关联(references between these objects) a tracing garbage collector 会在某一个时间点上停止原本正在运行的程序,之后它会扫描runtime已经知道的的object集合(already known set of objects),通常它们是存在于stack中的全局变量以及各种对象。gc会对这些对象进行标记,将这些对象的状态标记为可达,从中找出所有的,从当前的这些对象可以达到其他地方的对象的reference,并且将这些对象也标记为可达的对象,这个步骤被称为mark phase,即标记阶段,这一步的主要目的是用于获取这些对象的状态信息。
一旦将所有的这些对象都扫描完,gc就会获取到所有的无法reach的对象(状态为unreachable的对象),并且将它们回收,这一步称为sweep phase,即是清扫阶段。
gc仅仅搜集那些未被标记为可达(reachable)的对象。如果gc没有识别出一个reference,最后有可能会将一个仍然在使用的对象给回收掉,就引起了程序运行错误。
可以看到主要的三个步骤:扫描,回收,清扫。
感觉比起其他的语言,golang中的垃圾回收模型还是相对简单的。
gc中的问题
gc的引入可以说就是为了解决内存回收的问题。新开发的语言(java,python,php等等),在使用的时候,可以使用户不必关心内存对象的释放,只需要关心对象的申请即可,通过在runtime或者在vm中进行相关的操作,达到自动管理内存空间的效果,这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。
根据前面的表述,能否正常识别一个reference是gc能够正常工作的基础,因此第一个问题就是gc应该如何识别一个reference?
最大的问题:对于reference的识别比较难,machine code 很难知道,怎样才算是一个reference。如果错漏掉了一个reference,就会使得,原本没有准备好要被free掉的内存现在被错误地free掉,所以策略就是宁多勿少。
一种策略是把所有的memory空间都看做是有可能的references(指针值)。这种被称为保守型垃圾回收器(conservative garbage collector)。C 中的Boehm garbage collector就是这样工作的。就是说把内存中的普通变量也当做指针一样去处理,尽量cover到所有的指针的情况,如果碰巧这个普通的变量值所指向的空间有其他的对象,那么这个对象是不会被回收的。而go语言实现是完全知道对象的类型信息,在标记时只会遍历指针指向的对象,这样就避免了C实现时的堆内存浪费(解决约10-30%)。
三色标记
2014/6 1.3 引入并发清理(垃圾回收和用户逻辑并发执行?)
2015/8 1.5 引入三色标记法
关于并发清理的引入,参照的是在1.3版本中,go runtime分离了mark和sweep的操作,和以前一样,也是先暂停所有任务执行并启动mark(mark这部分还是要把原程序停下来的),mark完成后就马上就重新启动被暂停的任务了,并且让sweep任务和普通协程任务一样并行,和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行,go team自己的说法是减少了50%-70%的暂停时间。
基本算法就是之前提到的清扫+回收,Golang gc优化的核心就是尽量使得STW(Stop The World)的时间越来越短。
如何测量GC
之前说了那么多,那如何测量gc的之星效率,判断它到底是否对程序的运行造成了影响呢? 第一种方式是设置godebug的环境变量,具体可以参考这一篇,真的是讲的很好的文章:,比如运行GODEBUG=gctrace=1 ./myserver
,如果要想对于输出结果了解,还需要对于gc的原理进行更进一步的深入分析,这篇文章的好处在于,清晰的之处了golang的gc时间是由哪些因素决定的,因此也可以针对性的采取不同的方式提升gc的时间:
根据之前的分析也可以知道,golang中的gc是使用标记清楚法,所以gc的总时间为:
Tgc = Tseq + Tmark + Tsweep
(T表示time)
- Tseq表示是停止用户的 goroutine 和做一些准备活动(通常很小)需要的时间
- Tmark 是堆标记时间,标记发生在所有用户 goroutine 停止时,因此可以显著地影响处理的延迟
- Tsweep 是堆清除时间,清除通常与正常的程序运行同时发生,所以对延迟来说是不太关键的
之后粒度进一步细分,具体的概念还是有些不太懂:
- 与Tmark相关的:1 垃圾回收过程中,堆中活动对象的数量,2 带有指针的活动对象占据的内存总量 3 活动对象中的指针数量。
- 与Tsweep相关的:1 堆内存的总量 2 堆中的垃圾总量
如何进行gc调优(gopher大会 Danny)
硬性参数
涉及算法的问题,总是会有些参数。GOGC参数主要控制的是下一次gc开始的时候的内存使用量。
比如当前的程序使用了4M的对内存(这里说的是堆内存),即是说程序当前reachable的内存为4m,当程序占用的内存达到reachable*(1+GOGC/100)=8M的时候,gc就会被触发,开始进行相关的gc操作。
如何对GOGC的参数进行设置,要根据生产情况中的实际场景来定,比如GOGC参数提升,来减少GC的频率。
小tips
想要有深入的insights,使用gdb时必不可少的了,这篇文章里面整理了一些gdb使用的入门技巧。
减少对象分配 所谓减少对象的分配,实际上是尽量做到,对象的重用。 比如像如下的两个函数定义:
12 | func(r*Reader)Read()([]byte,error)func(r*Reader)Read(buf[]byte)(int,error) |
第一个函数没有形参,每次调用的时候返回一个[]byte,第二个函数在每次调用的时候,形参是一个buf []byte 类型的对象,之后返回读入的byte的数目。
第一个函数在每次调用的时候都会分配一段空间,这会给gc造成额外的压力。第二个函数在每次迪调用的时候,会重用形参声明。
老生常谈 string与[]byte转化 在stirng与[]byte之间进行转换,会给gc造成压力 通过gdb,可以先对比下两者的数据结构:
123 | type = struct []uint8 { uint8 *array; int len; int cap;}type = struct string { uint8 *str; int len;} |
两者发生转换的时候,底层数据结结构会进行复制,因此导致gc效率会变低。解决策略上,一种方式是一直使用[]byte,特别是在数据传输方面,[]byte中也包含着许多string会常用到的有效的操作。另一种是使用更为底层的操作直接进行转化,避免复制行为的发生。可以参考微信“雨痕学堂”中性能优化的第一部分,主要是使用unsafe.Pointer直接进行转化。
对于unsafe的使用,感觉可以单独整理一出一篇文章来了,先把相关资料列在这里 直观上,可以把unsafe.Pointer理解成c++中的void*,在golang中,相当于是各种类型的指针进行转化的桥梁。
关于uintptr的底层类型是int,它可以装下指针所指的地址的值。它可以和unsafe.Pointer进行相互转化,主要的区别是,uintptr可以参与指针运算,而unsafe.Pointer只能进行指针转化,不能进行指针运算。想要用golang进行指针运算,可以参考。具体指针运算的时候,要先转成uintptr的类型,才能进一步计算,比如偏移多少之类的。
少量使用+连接string 由于采用+来进行string的连接会生成新的对象,降低gc的效率,好的方式是通过append函数来进行。
但是还有一个弊端,比如参考如下代码:
1 | b := make([]int, 1024) b = append(b, 99) fmt.Println("len:", len(b), "cap:", cap(b)) |
在使用了append操作之后,数组的空间由1024增长到了1312,所以如果能提前知道数组的长度的话,最好在最初分配空间的时候就做好空间规划操作,会增加一些代码管理的成本,同时也会降低gc的压力,提升代码的效率。
参考资料
golang gc 中文入门(总结比较全面 包括golang gc 在不同版本的比较 赞) ()
其他垃圾回收相关文章
这个介绍的gc较为系统:
1.5版本的垃圾回收器
内存泄露参考
Go1.5源码剖析
手动管理golang gc的一个例子(比较深层次的内容)