浅谈: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之间的循环引用因为没有外界的引用指向它们了,所以它们就是垃圾对象,但是循环引用导致他们都不能释放

alt

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模块会执行一次标记清除。具体会进行四个步骤,前三步如图所示:

alt

(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 cacheCentral cache 以及 Page heap 三个级别的管理器来管理内存,可以充分利用不同级别下的性能优势。具体的缓存机制与调用链示意图如下:

alt

alt

在 Go 的内存管理机制中,有几个重要的数据结构需要关注,分别是 mspanheapArenamcachemcentral 以及 mheap。其中,mspanheapArena 维护了 Go 的虚拟内存布局,而 mcachemcentral 以及 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)
    ...
}

alt

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 的大小。 alt

这里我们又会发现,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 时的碎片率的。

alt

另外,spanClass 的 ID 中还会通过最后一位来存放 noscan 的属性。这个标志位是用来告诉 Collectorspan 中是否需要扫描。如果当前 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 种 spanClassmspan 数组,存放在 mcachealloc 中,包括 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
    ...
}

alt

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 维护着固定一种 spanClassmspanspanClass 下面是两个 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

alt

alt

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 中提到的三个字段:tinytinyoffsettinyAllocs 就是用来维护微小对象分配器的。tiny 是指向当前在使用的 16 字节内存块的地址,tinyoffset 则是指新分配微小对象需要的起始偏移,tinyAllocs 则存放了目前这个 mcache ***存放了多少微小对象。

alt

(1). Tiny的分配流程:

alt

alt

(2). Small对象的分配流程:

对于小对象的分配,整体逻辑跟 TCMalloc 是保持一致的,就是依次向三级内存管理器请求内存,一旦内存请求成功则返回。

alt

(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

alt

图·半空间复制GC

alt

图·分代GC

alt

3.5.2 三色标记法与write barrier:

(1)在GC线程进行mark的同时,业务线程也在不断地创建新的对象,修改对象之间的引用关系(业务线程无法删除对象)。

为了保证不漏标,三色标记算法可以比较直观地描述这个过程。(三色标记法只是一种抽象概念,并不会真正添加color属性):

  • 白色: 还未搜索的对象

  • 灰色: 正在搜索的对象

  • 黑色: 已经搜索完成的对象

在使用广度优先搜索的标记过程中,白色对象就是未被访问过的对象,灰色则是已经被访问到的,但是还没有完全拓展的对象,即还在队列中的对象,黑色则是已经完成拓展的对象,即从队列中出队的对象。

(2)三色标记存在的问题与漏标实例:

漏标实例(I) 发生在黑色对象向白色对象的引用: alt

漏标实例(II) 业务线程对对象引用的修改:

alt

alt

alt

(3)解决方案:

  • 强三色不变性(Dijkstra barrier): alt
// 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): alt

// 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 具体回收阶段

alt

  • 清除终止阶段

这个阶段会进行 STW,让所有的线程都进入安全点。如果此次 GC 是被强制触发的话,那么这个阶段还需要清除上次 GC 尚未清除的 mspan

  • 标记阶段

在进行标记阶段之前,需要先做一些准备工作,也就是将 gcphase 状态从 _GCoff 切换到 _GCmark,并打开 write barriermutator assists,然后将根对象压入扫描队列中。此时,所有的 mutator 还处于 STW 状态。

准备就绪后重启 mutator 的运行,此时后台中的标记线程和 mutator assists 可以共同帮助 GC 进行标记。在标记过程中,write barrier 会把所有被覆盖的指针以及新指针都标记为灰色,而新分配的对象指针则直接标记为黑色。

标记线程开始进行根对象扫描,包括所有的栈对象、全局变量的对象,还有所有堆外的运行时数据结构。此时要注意的是,当对栈进行扫描的时候需要暂停当前的线程。扫描完根对象后,标记线程会继续扫描灰色队列中的对象,将对象标记为黑色并依次将其引用的对象标灰入队。

由于 Go 中每个线程都有一个 Thread Localcache,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 新闻概要文档里面的频率。这个追踪程序包含不同版本的查找算法来演示不同的并发模式。本文主要聚焦在freqfreqConcurrentfreqProcessors这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 

alt

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 

alt

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 

alt

不同的方法对比:

| 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

全部评论

相关推荐

HaxyBT:那我提前下班总可以了吧
点赞 评论 收藏
分享
04-09 09:47
门头沟学院 Java
Arbelite_:2-3k,这工资还不如去摇奶茶
点赞 评论 收藏
分享
评论
点赞
1
分享

创作者周榜

更多
牛客网
牛客企业服务