浅谈:Python与Go的内存管理机制
1. 静态语言与动态语言对比
动态语言的特征是可以在运行时,为对象甚至是类添加新的属性和方法,而静态语言不能在运行期间做这样的修改。
动态语言比较常见的如Python和JavaScript。
静态语言的代表是 C++,Java(除了反射reflection
这个特例)。
从实现的层面讲,静态语言往往在编译时就能确定各个属性的偏移值,所以编译器能确定某一种类型的对象,它的大小是多少。这就方便了在分配和运行时快速定位对象属性。
而动态语言往往会将对象组织成一个字典结构,例如下面这个 Python 的例子:
>>> class A(object):
... pass
...
>>> a = A()
>>> a.b = 1
>>> c = A()
>>> c.d = 2
>>> a.__dict__
{'b': 1}
>>> c.__dict__
{'d': 2}
可以看到在A类中,a,b对象的属性不同。在静态语言中是难以想象的,而在动态语言中这样的操作却司空见惯。所以,在本文中,将选取动态语言Python,与静态语言Go的具体实现进行解释,分析两者的内存管理机制。
在Python中,内存分配与malloc
实现相似,所以本文重点介绍Python的垃圾回收机制。Go语言的垃圾回收机制采用的是经典的CMS
,因此分配过程将会作为重点介绍。
2. Python的内存管理机制
在Python的垃圾回收机制中,采用的是引用计数机制为主,标记-清除和分代收集两种机制为辅的策略。
2.1 策略一:引用计数
在Python中,因为万物皆对象,所以我们可以看到Python中每一个对象的核心就是一个结构体PyObject
,它的内部有一个引用计数器(ob_refcnt
)
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;
在Python中引用计数的维护通过两个宏实现,如下:
#define Py_INCREF(op) ( \
_Py_INC_REFTOTAL _Py_REF_DEBUG_COMMA \
((PyObject*)(op))->ob_refcnt++)
#define Py_DECREF(op) \
do { \
if (_Py_DEC_REFTOTAL _Py_REF_DEBUG_COMMA \
--((PyObject*)(op))->ob_refcnt != 0) \
_Py_CHECK_REFCNT(op) \
else \
_Py_Dealloc((PyObject *)(op)); \
} while (0)
# 其对于ob_refcnt 增一和减一的操作,和如下代码类似:
void do_oop_store(Value * obj, Value value) {
inc_ref(&value);
dec_ref(obj);
obj = &value;
}
void inc_ref(Value * ptr) {
ptr->ref_cnt++;
}
void dec_ref(Value * ptr) {
ptr->ref_cnt--;
if (ptr->ref_cnt == 0) {
collect(ptr);
for (Value * ref = ptr->first_ref; ref != null; ref=ref->next)
dec_ref(ref);
}
}
但是,在引用计数的使用过程中,会存在一些其他的问题:
(1). 在并发场景下,对引用计数的修改需要和对象指针的修改同步,这往往需要加锁或者非常复杂的无锁算法;
(2). 有时候会引发连锁式的回收;
(3). 无法有效解决循环引用;
其中,考虑循环引用的情况,如下图所示,不同于A和B之间的循环引用,C和D之间的循环引用因为没有外界的引用指向它们了,所以它们就是垃圾对象,但是循环引用导致他们都不能释放
2.2 策略二:标记-清除(双向链表)
Python虚拟机为了循环引用的问题,采用了Mark-sweep
的策略,它分为两个阶段:第一阶段是标记阶段,GC会把所有的『活动对象』打上标记,第二阶段是把那些没有标记的对象『非活动对象』进行回收。
在具体实现上, Python 的每个对象头上都有一个名为 PyGC_Head 的结构,并且将所有对象把都放到链表里:
/* GC information is stored BEFORE the object structure. */
typedef union _gc_head {
struct {
union _gc_head *gc_next; # 把对象关联到链表里
union _gc_head *gc_prev; # 把对象关联到链表里
Py_ssize_t gc_refs; # 用于消除循环引用
} gc;
long double dummy; /* force worst-case alignment */
} PyGC_Head;
当链表中的对象达到一定数目之后,GC模块会执行一次标记清除。具体会进行四个步骤,前三步如图所示:
(1). 将ob_refcnt
的值复制到gc_refs
中;
(2). 遍历整个链表,对每个对象,将它直接引用的对象的gc_refs
的值减一。比如遍历到A对象时,只把B对象的gc_refs
值减一;遍历到B对象时,再把它直接引用的A对象的gc_refs
值减一;
(3). 将gc_refs
值为0的对象,从对象链表中摘下来,放入一个名为“临时不可达”的链表中。之所以使用“临时”,是因为有循环引用的垃圾对象的gc_refs
在此时一定为0,比如C和D。但gc_refs
值为0的对象不一定是垃圾对象,比如B对象。
(4). 以可达对象链表中的对象为根开始深度优先搜索,将所有访问到gc_refs
为0的对象,再从临时不可达链表中移回可达链表中。最后留在临时不可达链表中的对象,就是真正的垃圾对象了。
最后,_Py_Dealloc
会逐个释放链表中的对象了,对于上面的例子,就是把 B 对象重新加回到可达对象链表中,然后将 C 和 D 分别释放。
2.3 策略三:分代回收
由于标记清除算法需要扫描所有堆上的对象,因此性能会有很大损耗,并且可回收对象数量越少,性能损耗越高。因此 Python 引入了分代回收算法,采用了空间换时间
的方法。基于的思想是:对象存在时间越长,越可能不是垃圾,应该越少去收集。这样在执行标记-清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度。
因此,Python GC将系统中存活时间不同的对象划分到不同的内存区域,共三代,分别是 0 代,1 代 和 2 代。新生成的对象是 0 代,经过一次垃圾回收之后,还存活的对象将会升级到 1 代,以此类推,2 代中的对象是存活最久的对象。
在分代回收中, 当某一世代中被分配的对象与被释放的对象之差达到某一阈值的时候,就会触发GC对某一世代的扫描。并且当某一世代的扫描被触发时,比他年轻的世代扫描也会被触发。阈值可以通过如下的函数查看和调整:
# gc.get_threshold() # (threshold0, threshold1, threshold2).
# gc.set_threshold(threshold0[, threshold1[, threshold2]])
(base) liuyue@LYNDONYLIU-MB0 python_learning % python
Python 2.7.18 (v2.7.18:8d21aa21f2, Apr 19 2020, 20:48:48)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import gc
>>> gc.get_threshold()
(700, 10, 10)
GC会记录自从上次收集以来新分配的对象数量与释放的对象数量的差值,当其超过threshold0
的值时,GC的扫描就会启动,初始的时候只有世代0被检查。如果自从世代1最近一次被检查以来,世代0被检查超过threshold1
次,那么对世代1的检查将被触发。相同的,如果自从世代2最近一次被检查以来,世代1被检查超过threshold2
次,那么对世代2的检查将被触发。
2.4 垃圾收集何时进行?
1.被引用为0时,立即回收当前对象
2.达到了垃圾回收的阈值,触发标记-清除
3.手动调用gc.collect()
4.Python虚拟机退出的时候
3. Go的内存管理机制
在介绍Go的垃圾回收算法之前,我们需要先了解一下Go的内存分配机制以及对应的分配器。
3.1 TCMalloc的分配思想
Go的分配器,参考了TCMalloc
的核心思想。在 TCMalloc
中,“TC”是 Thread Cache
的意思,其核心思想是:TCMalloc
会给每个线程分配一个 Thread-Local Cache
,对于每个线程的分配请求,就可以从自己的 Thread-Local Cache
区间来进行分配。此时因为不会涉及多线程操作,所以并不需要进行加锁,从而减少了因为锁竞争而引起的性能损耗。
当 Thread-Local Cache
空间不足的时候,才向下一级的内存管理器请求新的空间。TCMalloc
引入了 Thread cache
、Central cache
以及 Page heap
三个级别的管理器来管理内存,可以充分利用不同级别下的性能优势。具体的缓存机制与调用链示意图如下:
在 Go 的内存管理机制中,有几个重要的数据结构需要关注,分别是 mspan
、heapArena
、mcache
、mcentral
以及 mheap
。其中,mspan
和 heapArena
维护了 Go 的虚拟内存布局,而 mcache
、mcentral
以及 mheap
则构成了 Go 的三层内存管理器。
3.2 虚拟内存布局
Go 的内存管理基本单元是 mspan
,每个 mspan
中会维护着一块连续的虚拟内存空间,内存的起始地址由 startAddr
来记录。每个 mspan
存储的内存空间大小都是内存页的整数倍,由 npages
来保存。其中,Go的内存页大小为8KB。
type mspan struct {
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
startAddr uintptr // address of first byte of span aka s.base()
npages uintptr // number of pages in span
...
spanclass spanClass // size class and noscan (uint8)
...
}
heapArena
的结构相当于 Go 的一个内存块,在 x86-64 架构下的 Linux 系统上,一个 heapArena
维护的内存空间大小是 64MB。该结构中存放了 ArenaSize/PageSize
长度的 mspan 数组,heapArena
结构的 spans 变量,用来精确管理每一个内存页。而整个 arena 内存空间的基址则存放在 zeroedBase
中。heapArena
结构的部分定义如下:
type heapArena struct {
...
spans [pagesPerArena]*mspan
zeroedBase uintptr
}
有了这两个结构,我们就可以整体看下 Go 的虚拟内存布局了。Go 整体的虚拟内存布局是存放在 mheap
中的一个 heapArena
的二维数组。定义如下:
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
这里二维数组的大小在不同架构跟操作系统上有所不同,对于 x86-64 架构下的 Linux 系统,第一维数组长度是 1,而第二维数组长度是 4194304。这样每个 heapArena
管理的内存大小是 64MB,由此可以算出 Go 的整个堆空间最多可以管理 256TB 的大小。
这里我们又会发现,Go 通过 heapArena
来对虚拟内存进行管理的方式其实跟操作系统通过页表来管理物理内存是一样的。了解了 Go 的虚拟内存布局之后,我们再来看下 Go 的三级内存管理器。
3.3 三级内存管理
在 Go 的三级内存管理器中,维护的对象都是小于 32KB 的小对象。对于这些小对象,Go 又将其按照大小分成了 67 个类别,称为 spanClass
。每一个 spanClass
都用来存储固定大小的对象。这 67 个 spanClass
的信息在 runtime.sizeclasses.go
中可以看到详细的说明,具体如下所示:
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
// 11 144 8192 56 128 11.82% 16
// 12 160 8192 51 32 9.73% 32
// 13 176 8192 46 96 9.59% 16
// 14 192 8192 42 128 9.25% 64
// 15 208 8192 39 80 8.12% 16
// 16 224 8192 36 128 8.15% 32
// 17 240 8192 34 32 6.62% 16
// 18 256 8192 32 0 5.86% 256
// 19 288 8192 28 128 12.16% 32
// 20 320 8192 25 192 11.80% 64
// 21 352 8192 23 96 9.88% 32
// 22 384 8192 21 128 9.51% 128
// 23 416 8192 19 288 10.71% 32
// 24 448 8192 18 128 8.37% 64
// 25 480 8192 17 32 6.82% 32
// 26 512 8192 16 0 6.05% 512
// 27 576 8192 14 128 12.33% 64
// 28 640 8192 12 512 15.48% 128
// 29 704 8192 11 448 13.93% 64
// 30 768 8192 10 512 13.94% 256
// 31 896 8192 9 128 15.52% 128
// 32 1024 8192 8 0 12.40% 1024
// 33 1152 8192 7 128 12.41% 128
// 34 1280 8192 6 512 15.55% 256
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
// 39 2304 16384 7 256 12.46% 256
// 40 2688 8192 3 128 15.59% 128
// 41 3072 24576 8 0 12.47% 1024
// 42 3200 16384 5 384 6.22% 128
// 43 3456 24576 7 384 8.83% 128
// 44 4096 8192 2 0 15.60% 4096
// 45 4864 24576 5 256 16.65% 256
// 46 5376 16384 3 256 10.92% 256
// 47 6144 24576 4 0 12.48% 2048
// 48 6528 32768 5 128 6.23% 128
// 49 6784 40960 6 256 4.36% 128
// 50 6912 49152 7 768 3.37% 256
// 51 8192 8192 1 0 15.61% 8192
// 52 9472 57344 6 512 14.28% 256
// 53 9728 49152 5 512 3.64% 512
// 54 10240 40960 4 0 4.99% 2048
// 55 10880 32768 3 128 6.24% 128
// 56 12288 24576 2 0 11.45% 4096
// 57 13568 40960 3 256 9.99% 256
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
以 class 5
为例, class 5
是说在 spanClass
为 5 的 span
结构中,存储的对象的大小是 48 字节,整个 span
的大小是 8192 字节,也就是一个内存页的大小,可以存放的对象数目最多是 170。tail waste
这里是 32 字节,这个 32 是通过 8192 mod 48 计算得到,意思是,当这个 span
填满了对象后,会有 32 字节大小的外部碎片。而 max waste
的计算方式则是 [(48−33)×170+32]÷8192 得到,意思是极端场景下,该 span 上分配的所有对象大小都是 33 字节,此时的内存浪费率为 31.52%。
以上 67 个存储小对象的 spanClass
级别,再加上 class 为 0 时用来管理大于 32KB 对象的 spanClass
,共总是 68 个 spanClass
。这些数据都是通过在 runtime.mksizeclasses.go
中计算得到的。我们从上边的注释可以看出,Go 在分配的时候,是通过控制每个 spanClass
场景下的最大浪费率,来保障堆内存在 GC 时的碎片率的。
另外,spanClass
的 ID 中还会通过最后一位来存放 noscan
的属性。这个标志位是用来告诉 Collector
该 span
中是否需要扫描。如果当前 span
中并不存放任何堆上的指针,就意味着 Collector
不需要扫描这段 span
区间。
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
func (sc spanClass) sizeclass() int8 {
return int8(sc >> 1)
}
func (sc spanClass) noscan() bool {
return sc&1 != 0
}
接下来我们继续看下 mcache
的结构。mcache
是 Go 的线程缓存,对应于 TCMalloc
中的 Thread cache
结构。mcache
会与线程绑定,每个 goroutine
在向 mcache
申请内存时,都不会与其他 goroutine
发生竞争。mcache
中会维护上述 68×2 种 spanClass
的 mspan
数组,存放在 mcache
的 alloc
中,包括 scan
以及 noscan
两个队列。mcache
的主要结构如下,其中 tiny
相关的三个 field 涉及到 tiny
对象的分配,我们稍后在对象分配机制中再进行介绍。
type mcache struct {
...
tiny uintptr
tinyoffset uintptr
tinyAllocs uintptr
alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
...
}
当 mcache
中的内存不够需要扩容时,需要向 mcentral
请求,mcentral
对应于 TCMalloc
中的 Central cache
结构。mcentral
的主要结构如下:
type mcentral struct {
spanclass spanClass
partial [2]spanSet // list of spans with a free object
full [2]spanSet // list of spans with no free objects
}
可以看到,mcentral
中也存有 spanClass
的 ID 标识符,这表示说每个 mcentral
维护着固定一种 spanClass
的 mspan
。spanClass
下面是两个 spanSet
,它们是 mcentral
维护的 mspan
集合。partial
里存放的是包含着空闲空间的 mspan
集合,full
里存放的是不包含空闲空间的 span
集合。这里每种集合都存放两个元素,用来区分集合中 mspan
是否被清理过。
mcentral
不同于 mcache
,每次请求 mcentral
中的 mspan
时,都可能发生不同线程直接的竞争。因此,在使用 mcentral
时需要进行加锁访问,具体来讲,就是 spanSet
的结构中会有一个 mutex
的锁的字段。
我们最后看下 mheap
的结构。mheap
在 Go 的运行时里边是只有一个实例的全局变量。上面我们讲到,维护 Go 的整个虚拟内存布局的 heapArena
的二维数组,就存放在 mheap
中。mheap
结构对应于 TCMalloc
中的 Page heap
结构。mheap
的主要结构如下:
type mheap struct {
lock mutex
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
}
var mheap_ mheap
3.4 对象分配机制
Go 根据对象大小分成了三个级别,分别是微小对象、小对象和大对象。微小对象,也就是指大小在 0 ~ 16 字节的非指针类型对象;小对象,指的是大小在 16 ~ 32KB 的对象以及小于 16 字节的指针对象;大对象,也就是上文提到的 spanClass
为 0 的类型。
对于这三种类型对象,Go 的分配策略也不相同,对象分配的主要逻辑如下:
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微小对象分配
} else {
// 小对象分配
}
} else {
// 大对象分配
}
微小对象会被放到 spanClass
为 2 的 mspan
中,由于每个对象最大是 16 字节,在分配时会尽量将多个小对象放到同一个内存块内(16 字节),这样可以更进一步的降低这种微小对象带来的碎片化。
我们在 mcache
中提到的三个字段:tiny
、tinyoffset
和 tinyAllocs
就是用来维护微小对象分配器的。tiny
是指向当前在使用的 16 字节内存块的地址,tinyoffset
则是指新分配微小对象需要的起始偏移,tinyAllocs
则存放了目前这个 mcache
***存放了多少微小对象。
(1). Tiny的分配流程:
(2). Small对象的分配流程:
对于小对象的分配,整体逻辑跟 TCMalloc
是保持一致的,就是依次向三级内存管理器请求内存,一旦内存请求成功则返回。
(3). 对于大对象的分配,会直接越过 mcache、mcentral, 直接从 mheap 进行相应数量的 page 分配。
pageAlloc 结构经过多个版本的变化,从:freelist -> treap >radix tree,查找时间复杂度越来越低,结构越来越复杂。
3.5 内存回收机制
Go 的 GC 算法采用的是经典的 CMS 算法,在并发标记的时候使用的是三色标记清除算法。而在 write barrier
上则采用了 Dijkstra
的插入 barrier
,以及汤浅太一提出的删除 barrier
混合的 barrier
算法。
3.5.1 为什么采用CMS:
- Go 语言通过内存分配策略缓解了 CMS 容易产生内存碎片的缺陷
(1)标记压缩与半空间复制的GC算法对于CMS来说最大优势在于降低内存的碎片率,但是Go因为采用的TCMalloc的内存分配思路,也同样可以降低碎片率
(2)Go因为采用了TCMalloc的内存分配思路,可以在分配的大部分场景下避免加锁,有利于发挥Go的高并发场景下的性能优势
- Go将生命周期很短的对象安排在栈上分配
因为Go中有值类型数据的,即 struct 类型,所以编译器只需要关注函数内部的逃逸分析(intraprocedural escape analysis),而不用关注函数间的逃逸分析(interprocedural analysis),因此可以将生命周期较短的对象安排在栈上分配。所以Go中生命周期较短的对象并不多。采用分代GC算法的收益较低。
图·标记压缩GC
图·半空间复制GC
图·分代GC
3.5.2 三色标记法与write barrier:
(1)在GC线程进行mark的同时,业务线程也在不断地创建新的对象,修改对象之间的引用关系(业务线程无法删除对象)。
为了保证不漏标,三色标记算法可以比较直观地描述这个过程。(三色标记法只是一种抽象概念,并不会真正添加color属性):
-
白色: 还未搜索的对象
-
灰色: 正在搜索的对象
-
黑色: 已经搜索完成的对象
在使用广度优先搜索的标记过程中,白色对象就是未被访问过的对象,灰色则是已经被访问到的,但是还没有完全拓展的对象,即还在队列中的对象,黑色则是已经完成拓展的对象,即从队列中出队的对象。
(2)三色标记存在的问题与漏标实例:
漏标实例(I) 发生在黑色对象向白色对象的引用:
漏标实例(II) 业务线程对对象引用的修改:
(3)解决方案:
- 强三色不变性(Dijkstra barrier):
// Dijkstra barrier
// slot is the destination in Go code,
// ptr is the value that goes intl the slot in Go code.
func DijkstraWB(slot *unsafe. Pointer, ptr unsafe. Pointer) {
shade (ptr)
*slot = ptr
}
- 弱三色不变性(Yuasa barrier):
// Yuasa barrier
// slot is the destination in Go code.
// ptr is the value that goes into the slot in Go code.
func YuasaB(slot *unsafe.Pointer, ptr unsafe. Pointer) f
shade(*slot)
*slot = ptr
}
- Go中的实现(Reality in Go):
// slot is the destination in Go code.
// ptr is the value that goes into the slot in Go code.
func RealityWR(slot *unsafe.Pointer, ptr unsafe. Pointer) f
shade(*slot)
if current stack is grey:
shade(ptr)
*slot = ptr
}
另外,为了降低 GC 在回收时 STW
的最长时间,Go 在内存回收时并不是一次性回收完全部的垃圾,而是采用增量回收的策略,将整个垃圾回收的过程切分成多个小的回收 cycle
。具体来讲,Go 的内存回收主要分为这几个阶段:
3.5.3 具体回收阶段
- 清除终止阶段
这个阶段会进行 STW,让所有的线程都进入安全点。如果此次 GC 是被强制触发的话,那么这个阶段还需要清除上次 GC 尚未清除的 mspan
。
- 标记阶段
在进行标记阶段之前,需要先做一些准备工作,也就是将 gcphase 状态从 _GCoff
切换到 _GCmark
,并打开 write barrier
和 mutator assists
,然后将根对象压入扫描队列中。此时,所有的 mutator
还处于 STW 状态。
准备就绪后重启 mutator 的运行,此时后台中的标记线程和 mutator assists
可以共同帮助 GC 进行标记。在标记过程中,write barrier
会把所有被覆盖的指针以及新指针都标记为灰色,而新分配的对象指针则直接标记为黑色。
标记线程开始进行根对象扫描,包括所有的栈对象、全局变量的对象,还有所有堆外的运行时数据结构。此时要注意的是,当对栈进行扫描的时候需要暂停当前的线程。扫描完根对象后,标记线程会继续扫描灰色队列中的对象,将对象标记为黑色并依次将其引用的对象标灰入队。
由于 Go 中每个线程都有一个 Thread Local
的 cache
,GC 采用的是分布式终止算法来检查各个 mcache
中是否标记完成。
- 标记终止步骤
在标记终止阶段会进行 STW,暂停所有的线程。STW 之后将 gcphase
的状态切换到 _GCmarktermination
,并关闭标记进程和 mutator assists
。此外还会进行一些清理工作,刷新 mcache
。
- 清除阶段
在开始清除阶段之前,GC 会先将 gcphase 状态切换到 _GCoff
,并关闭 write barrier
。接着再恢复用户线程执行,此时新分配的对象只会是白色状态。并且清除工作是增量是进行的,所以在分配时,也会在必要的情况下先进行内存的清理。这个阶段的内存清理工作会在后台并发完成。
附录:GC性能观测:
参考 https://www.ardanlabs.com/blog/2019/07/garbage-collection-in-go-part3-gcpacing.html,
这个程序计算特定话题在一堆 RSS 新闻概要文档里面的频率。这个追踪程序包含不同版本的查找算法来演示不同的并发模式。本文主要聚焦在freq
、freqConcurrent
和freqProcessors
这3个算法
1、串行版本 (freq)
func freq(topic string, docs []string) int {
var found int
for _, doc := range docs {
file := fmt.Sprintf("%s.xml", doc[:8])
f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
return 0
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
return 0
}
var d document
if err := xml.Unmarshal(data, &d); err != nil {
log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
return 0
}
for _, item := range d.Channel.Items {
if strings.Contains(item.Title, topic) {
found++
continue
}
if strings.Contains(item.Description, topic) {
found++
}
}
}
return found
}
(base) liuyue@LYNDONYLIU-MB0 trace % go build
(base) liuyue@LYNDONYLIU-MB0 trace % time ./trace > t.out
2022/06/04 20:44:57 Searching 4000 files, found president 28000 times.
./trace > t.out 7.52s user 0.73s system 100% cpu 8.200 total
# 将追踪数据传给追踪工具,并在浏览器中观测
(base) liuyue@LYNDONYLIU-MB0 trace % go tool trace t.out
2、一个task一个goroutine(freqConcurrent)
func freqConcurrent(topic string, docs []string) int {
var found int32
g := len(docs)
var wg sync.WaitGroup
wg.Add(g)
for _, doc := range docs {
go func(doc string) {
var lFound int32
defer func() {
atomic.AddInt32(&found, lFound)
wg.Done()
}()
file := fmt.Sprintf("%s.xml", doc[:8])
f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
return
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
return
}
var d document
if err := xml.Unmarshal(data, &d); err != nil {
log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
return
}
for _, item := range d.Channel.Items {
if strings.Contains(item.Title, topic) {
lFound++
continue
}
if strings.Contains(item.Description, topic) {
lFound++
}
}
}(doc)
}
wg.Wait()
return int(found)
}
(base) liuyue@LYNDONYLIU-MB0 trace % go build
(base) liuyue@LYNDONYLIU-MB0 trace % time ./trace > t.out
2022/06/04 20:38:23 Searching 4000 files, found president 28000 times.
./trace > t.out 11.58s user 1.06s system 384% cpu 3.289 total
# 将追踪数据传给追踪工具,并在浏览器中观测
(base) liuyue@LYNDONYLIU-MB0 trace % go tool trace t.out
3、生产者消费者模型,goroutine的worker数量等于CPU个数 (freqProcessors)
func freqNumCPU(topic string, docs []string) int {
var found int32
g := runtime.NumCPU()
var wg sync.WaitGroup
wg.Add(g)
ch := make(chan string, g)
for i := 0; i < g; i++ {
go func() {
var lFound int32
defer func() {
atomic.AddInt32(&found, lFound)
wg.Done()
}()
for doc := range ch {
file := fmt.Sprintf("%s.xml", doc[:8])
f, err := os.OpenFile(file, os.O_RDONLY, 0)
if err != nil {
log.Printf("Opening Document [%s] : ERROR : %v", doc, err)
return
}
data, err := ioutil.ReadAll(f)
if err != nil {
f.Close()
log.Printf("Reading Document [%s] : ERROR : %v", doc, err)
return
}
f.Close()
var d document
if err := xml.Unmarshal(data, &d); err != nil {
log.Printf("Decoding Document [%s] : ERROR : %v", doc, err)
return
}
for _, item := range d.Channel.Items {
if strings.Contains(item.Title, topic) {
lFound++
continue
}
if strings.Contains(item.Description, topic) {
lFound++
}
}
}
}()
}
for _, doc := range docs {
ch <- doc
}
close(ch)
wg.Wait()
return int(found)
}
(base) liuyue@LYNDONYLIU-MB0 trace % go build
(base) liuyue@LYNDONYLIU-MB0 trace % time ./trace > t.out
2022/06/04 20:23:17 Searching 4000 files, found president 28000 times.
./trace > t.out 8.12s user 1.01s system 140% cpu 6.476 total
# 将追踪数据传给追踪工具,并在浏览器中观测
(base) liuyue@LYNDONYLIU-MB0 trace % go tool trace t.out
不同的方法对比:
| Algorithm | Program | GC Time | % Of GC | # of GC’s | Avg GC | Max Heap
|------------|---------|----------|---------|-----------|----------|----------
| freq | 8200 ms | 253.9 ms | ~3% | 272 | 933 μs | 4 meg
| concurrent | 3289 ms | 1364.8ms | ~41% | 69 | 19.8ms | 200 meg
| Processors | 6476 ms | 457.7 ms | ~7% | 438 | 1044.9μs | 4 meg
1、仅有一个协程时,基本 4MB 就足够了。程序一下子把所有任务都扔给运行时的情况下,GC 任由堆内存增长,减少了回收次数但是拉长了回收时间。程序控制任何时间能够处理的任务数目时,GC 能够再次保持堆小规模,增加回收次数而执行短回收。GC 采用的每种方式本质上都是使得程序运行收到 GC 的影响最小化。
2、freqProcessors
版本考虑到worker数量等于CPU个数,能够更好地处理缓存一致性,也能够很好地提高程序性能,GC时间较concurrent
也会更短。
参考文献
《go语言底层原理剖析》
https://zhuanlan.zhihu.com/p/83251959
https://juejin.cn/post/6844903629556547598
https://www.bilibili.com/video/BV1f34y1b7GP?spm_id_from=333.880.my_history.page.click