这是我参与「第五届青训营」伴学笔记创作活动的第 5 天
今天的掘金课程主要讲的是go语言的运行时内存管理机制和编译时性能优化。
老师首先讲解了一下手动内存管理的弊端和安全隐患和传统的垃圾回收机制是怎样工作的。其中着重讲述了追踪式垃圾回收,即通过存活的对象根扫描所有存活的对象并对已经不可及的对象占用的内存空间进行回收,通过复制回收的方式将存活的对象集中到一起来利用剩下的可用空间。这种复制回收的方式基于对象的存活时间一般较短的理论依据,因此需要移动的对象数量应该较少并可以回收较多的空余空间。同时,新老代垃圾回收可以减少追踪式垃圾回收需要追踪的对象数量,可以通过cardtable结构减少对老年代对象的扫描,因为分代gc一般将老年代对象放到另一部分空间,不经常取回收它们。目前市面上大部分高性能的语言一般都会使用追踪式gc。
老师还讲了另一种普遍存在的gc方法也就是引用计数方法。这种方法使用用过C++11的应该比较眼熟,就是类似智能指针shared_ptr,通过保存指针和一个引用计数值来检查对象的被引用情况,当最后一个引用该对象的指针失效时,对象就可以回收了,引用计数管理内存的好处显而易见,就是将内存管理的操作均摊到程序执行中,不需要像追踪式垃圾回收需要专门的时间进行根标记,可及性遍历和回收。但是引用计数的缺点也是显而易见的,那就是每次对象的引用状况发生改变时都需要改变引用计数,这样会影响程序执行的吞吐量,同时增加的引用计数内存空间对于缓存也不友好;而且引用计数无法解决对象发生循环引用时的问题,在这种情况下即使对象已经不可达,由于不可达对象之间的循环引用仍使引用计数非零导致不能自动回收它们,这时还是需要借助追踪式gc来清理不可及对象,也可以像C++11中提供的weak_ptr这种只保留一个引用的视图但是不真正占有对象的指针来破环保证没有循环引用。目前仍在使用引用计数语言最有代表性的应该是Python,它就是使用引用计数管理对象,同时在空余空间不足时再通过full gc的追踪式gc回收循环引用的不可及对象。
老师接下来介绍了go语言运行时的内存管理模型,go在分配内存时类似TCmalloc,为了减少在不同线程内存分配时的竞争使用类似Java中TLAB(Thread Local Allocation Buffer)为每个线程分配一个mcache方便快速分配内存。而在内存管理方面,go又有些类似Python中为不同大小的空间分配pool每次在相应大小空间的pool中获得一个内存空间,这里这种空间池叫做mspan并将mspan分类成有引用关系的scan mspan和没有引用关系的noscan mspan。
经过字节对程序进行性能分析发现,大部分的对象尺寸都在80 bytes以内,而上面使用mspan进行内存分配对于小尺寸内存需要经过很多操作开销较大。按字节的profile,内存分配消耗的cpu时间能够到达总cpu时间的3%,为了减少小内存分配时所需的操作,字节采用的优化策略是直接申请较大的一个内存段,然后按指针单调递增的方式在这个段上进行内存分配,以此减少小对象的分配开销。同时,在垃圾回收时如果一个段里的存活对象较小,就可一个通过复制回收将存活对象合并到一个堆里,以释放占用的段。字节将这个内存段叫做goroutine allocation buffer(GAB),将这种内存分配方法叫做Balanced GC。这个相应的合并操作让我想到了g1 gc中的arena合并机制。据字节测试,使用了Balanced GC后应用的高峰期CPU usage 降低了4.6%,核心接口时延下降4.5%~7.7%。
老师在讲解go的编译期优化时主要讲的是,通过更加细致的函数inline和在函数成功inline后使部分变量不再在逃逸分析时被认为是需要建立在对空间上,减少函数调用开销的同时,减少内存空间分配的开销。当然这种函数inline的优化带来的是编译时间增加和二进制文件变大。但是,使用了这些优化后高峰期的CPU usage降低了9%,实验降低了10%,内存利用率降低了3%。
由于我本身接触过一些编译原理相关的知识,所以听得比较开心,接下来我可能会看一些go的编译器代码进一步进行学习。