Golang 重点问题
洪笳淏 Lv4

Golang Map 的底层实现

一般的 map 的实现

  一般的Map会包含两个主要结构:

  • 数组:数组里的值指向一个链表,一般称为桶🪣位
  • 链表:目的解决hash冲突的问题,并存放键值

大致结构如下:
avatar
读取一个key值的过程大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
 				  key
|
v
+------------------------------------+
| key通过hash函数得到key的hash |
+------------------+-----------------+
|
v
+------------------------------------+
| key的hash通过取模或者位操作 |
| 得到key在数组上的索引 |
+------------------------------------+
|
v
+------------------------------------+
| 通过索引找到对应的链表 |
+------------------+-----------------+
|
v
+------------------------------------+
| 遍历链表对比key和目标key |
+------------------+-----------------+
|
v
+------------------------------------+
| 相等则返回value |
+------------------+-----------------+
|
v
value

Go 语言中 Map 的实现思路

  Go语言解决hash冲突不是链表,实际主要用的数组(内存上的连续空间),如下图所示:

avatar

  但是并不是只使用一个数组(连续内存空间)存放键和值,而是使用了两个数组分别存储键和值,图示如下:
avatar
  上图中:- 分别对应的是两个核心的结构体hmapbmapbmap里有两个数组分别存放key和value

  我们通过一次读操作为例,看看读取某个key的值的一个大致过程

  1. 通过hash函数获取目标key的哈希,哈希和数组的长度通过位操作获取数组位置的索引(备注:获取索引值的方式一般有取模或位操作,位操作的性能好些)
  2. 遍历bmap里的键,和目标key对比获取key的索引(找不到则返回空值)
  3. 根据key的索引通过计算偏移量,获取到对应value
    avatar

实现细节

核心结构体 hmap

  hmap的结构其实刚开始看起来其实还是比较复杂的,有不少的字段,具体字段如下图所示:
avatar
字段释义如下:

  1. count:键值对的数量
  2. B:2^B=len(buckets)
  3. hash0:hash因子
  4. buckets:指向一个数组(连续内存空间),数组的类型为[]bmap,bmap类型就是存在键值对的结构下面会详细介绍,这个字段我们可以称之为正常桶。
  5. oldbuckets:扩容时,存放之前的buckets(Map扩容相关字段)
  6. extra:溢出桶结构,正常桶里面某个bmap存满了,会使用这里面的内存空间存放键值对
  7. noverflow:溢出桶里bmap大致的数量
  8. nevacuate:分流次数,成倍扩容分流操作计数的字段(Map扩容相关字段)
  9. flags:状态标识,比如正在被写、buckets和oldbuckets在被遍历、等量扩容(Map扩容相关字段)

avatar
  buckets指向了一个数组(连续的内存空间),数组的元素是bmap类型,这个字段我们称之为正常桶。

核心结构体 bmap

  正常桶hmap.buckets的元素是一个bmap结构。bmap的具体字段如下图所示:
avatar
字段释义如下:

  1. topbits:长度为8的数组,[]uint8,元素为:key获取的hash的高8位,遍历时对比使用,提高性能。如下图所示
  2. keys:长度为8的数组,[]keytype,元素为:具体的key值。如下图所示
  3. elems:长度为8的数组,[]elemtype,元素为:键值对的key对应的值。
  4. overflow:指向的hmap.extra.overflow溢出桶里的bmap,上面的字段topbitskeyselems长度为8,最多存8组键值对,存满了就往指向的这个bmap里存
  5. pad:对齐内存使用的,不是每个bmap都有会这个字段,需要满足一定条件
    avatar

  结论:每个bmap结构最多存放8组键值对。

hmap 和 bmap 的基本结构合起来

  分别了解了hmapbmap的基本结构后,我们把上面的内容合并起来,就得到如下的Map结构图:
avatar

溢出桶

  上面讲bmap的时候,我们不是得到了个结论么“每个bmap结构最多存放8组键值对。”,所以问题来了:

正常桶里的bmap存满了怎么办?

  解决这个问题我们就要说到hmap.extra结构了,hmap.extra是个结构体,结构图示和字段释义如下:
avatar

  1. overflow:称之为溢出桶。和hmap.buckets的类型一样也是数组[]bmap,当正常桶bmap存满了的时候就使用hmap.extra.overflowbmap
  2. oldoverflow:扩容时存放之前的overflow(Map扩容相关字段)
  3. nextoverflow:指向溢出桶里下一个可以使用的bmap

问题:正常桶hmap.buckets里的bmap怎么关联上溢出桶hmap.extra.overflowbmap呢?

答:就是我们介绍bmap结构时里的bmap.overflow字段(如下图所示)。bmap.overflow是个指针类型,存放了对应使用的溢出桶hmap.extra.overflow里的bmap的地址。

问题又来了

正常桶hmap.buckets里的bmap什么时候关联上溢出桶hmap.extra.overflowbmap呢?

答:Map写操作的时候。

  hmap存在溢出桶时,且当前溢出桶只被使用了一个bmap时,我们可以得到如下的关系图:
avatar
  同时我们可以看出正常桶的bmap和溢出桶的bmap实际构成了链表关系,所以这也解释了开篇我们说到的“Go里面Map的实现主要用到了数组”,其次还用到了链表。

再次分析Map的读

  我们再次通过一次读操作为例,看看读取某个key的值的一个大致过程:
avatar
  Go 语言中 map 采用的是哈希查找表,由一个 key 通过哈希函数得到哈希值,64 位系统中就生成一个 64bit 的哈希值,由这个哈希值将 key 对应到不同的桶 (bucket)中,当有多个哈希映射到相同的的桶中时,使用链表解决哈希冲 突。key 经过 hash 后共 64 位,根据 hmap 中 B 的值,计算它到底要落在哪个桶 时,桶的数量为 2^B,如 B=5,那么用 64 位最后 5 位表示第几号桶,在用 hash 值的高 8 位确定在 bucket 中的存储位置,当前 bmap 中的 bucket 未找到,则查 询对应的 overflow bucket,对应位置有数据则对比完整的哈希值,确定是否 是要查找的数据。 如果两个不同的 key 落在的同一个桶上,hash 冲突使用链表法接近,遍历 bucket 中的 key 如果当前处于 map 进行了扩容,处于数据搬移状态,则优先从 oldbuckets 查找。

Golang Map 如何扩容

装载因子:count/2^B
触发条件:

  1. 装填因子是否大于 6.5
  2. overflow bucket 是否太多

解决方法:

  1. 双倍扩容:扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一 次性搬迁完毕,每次最多只会搬迁 2 个 bucket
  2. 等量扩容:重新排列,极端情况下,重新排列也解决不了,map 成了链表,性能大大降低,此时哈希种子 hash0 的设置,可以降低此类极端场景的发生。

Channel

  在 Go 语言中,不要通过共享内存来通信,而要通过通信来实现内存共享。Go 的 CSP(Communicating Sequential Process)并发模型,中文叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。所以 channel 收发遵循先进先出 FIFO,分为有缓存和无缓存,channel 中大致有 buffer(当缓冲区大小不为 0 时,是个 ring buffer)、sendq、recvq 当前 channel 因为缓冲区不足而阻塞的队列、使用双向链表存储、还有一个 mutex 锁控制并发、其他原属等。

channel 的底层实现

avatar
简单说明:
buf是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环链表
sendxrecvx用于记录buf这个循环链表中的 index
lock是个互斥锁
recvqsendq分别是接收(<-channel)或者发送(channel <- xxx)的goroutine抽象出来的结构体(sudog)的队列。是个双向链表

下面我们来详细介绍hchan中各部分是如何使用的。

  1. 先从创建开始
    我们首先创建一个channel:ch := make(chan int, 3)
    avatar
    创建channel实际上就是在内存中实例化了一个hchan的结构体,并返回一个ch指针,我们使用过程中channel在函数之间的传递都是用的这个指针,这就是为什么函数传递中无需使用channel的指针,而直接用channel就行了,因为channel本身就是一个指针。

  2. channel中发送send(ch <- xxx)和recv(<- ch)接收
    先考虑一个问题,如果你想让goroutine以先进先出(FIFO)的方式进入一个结构体中,你会怎么操作? 加锁!对的!channel就是用了一个锁。hchan本身包含一个互斥锁mutex

  • channel中队列是如何实现的
    channel中有个缓存buf,是用来缓存数据的(假如实例化了带缓存的channel的话)队列。我们先来看看是如何实现“队列”的。当使用send (ch <- xx)或者recv ( <-ch)的时候,首先要锁住hchan这个结构体。
    所以不难看出,Go中那句经典的话:Do not communicate by sharing memory; instead, share memory by communicating.的具体实现就是利用channel把数据从一端copy到了另一端!
    还真是符合channel的英文含义:
    avatar
  1. 当channel缓存满了之后会发生什么?这其中的原理是怎样的?
    使用的时候,我们都知道,当channel缓存满了,或者没有缓存的时候,我们继续send(ch <- xxx)或者recv(<- ch)会阻塞当前goroutine,但是,是如何实现的呢?我们知道,Go的goroutine是用户态的线程(user-space threads),用户态的线程是需要自己去调度的,Go有运行时的scheduler去帮我们完成调度这件事情。goroutine的阻塞操作,实际上是调用send (ch <- xx)或者recv ( <-ch)的时候主动触发的,具体请看以下内容:
    1
    2
    3
    4
    5
    ch := make(chan int, 3)  

    ch <- 1
    ch <- 1
    ch <- 1
    avatar
    avatar
    这个时候G1正在正常运行,当再次进行send操作(ch<-1)的时候,会主动调用Go的调度器(G0),通过 runtime.gopark 函数让G1等待,并从让出M,让其他G去使用
    avatar
    同时G1也会被抽象成含有G1指针和send元素的sudog结构体保存到hchan的sendq中等待被唤醒。
    avatar
    那么,G1什么时候被唤醒呢?这个时候G2隆重登场。
    avatar
    G2执行了recv操作p := <-ch,于是会发生以下的操作:
    avatar
    G2从缓存队列中取出数据,channel会将等待队列中的G1推出,将G1当时send的数据推到缓存中,然后调用Go的scheduler,唤醒G1(runtime.goready),并把G1放到可运行的Goroutine队列中。通过这种协作式的调度来实现 goroutine 的调度。
    avatar
  • 假如是先进行执行recv操作的G2会怎么样?
    avatar
    这个时候G2会主动调用Go的调度器,让G2等待,并从让出M,让其他G去使用。
    G2还会被抽象成含有G2指针和recv空元素的sudog结构体保存到hchan的recvq中等待被唤醒
    avatar
    此时恰好有个goroutine G1开始向channel中推送数据 ch <- 1
    此时,非常有意思的事情发生了:
    avatar
    G1并没有锁住channel,然后将数据放到缓存中,而是直接把数据从G1直接copy到了G2的栈中。这种方式非常的赞!在唤醒过程中,G2无需再获得channel的锁,然后从缓存中取数据。减少了内存的copy,提高了效率。当然这种方法只能在 runtime 下自动执行,需要 runtime 调度 G2 的栈上的变量直接去 G1 的栈上获取数据
    avatar
    之后和之前一样,G2 变成 runnable 的状态,被挂到原始的 P 队列中。新版本进行了优化,为了避免 G2 被放到 P 队列末尾会被其他的 P 窃取(work-stealing),G2 会被优先挂载到当前队列正在执行的 goroutine 后面,很快就能得到执行。

channel 的特性

  1. 给一个 nil channel 发送数据,造成永远阻塞
  2. 从一个 nil channel 接收数据,造成永远阻塞
  3. 给一个已经关闭的 channel 发送数据,引起 panic
  4. 从一个已经关闭的 channel 接收数据,如果缓冲区中为空,则返回一个零值
  5. 无缓冲的 channel 是同步的,而有缓冲的 channel 是非同步的
  6. 关闭一个 nil channel 将会发生 panic
    avatar

Channel 的 ring buffer 实现

  channel 中使用了 ring buffer(环形缓冲区) 来缓存写入的数据。ring buffer 有很多好处,而且非常适合用来实现 FIFO 式的固定长度队列。在 channel 中,ring buffer 的实现如下:
avatar
  有两个与 buffer 相关的变量:recvx 和 sendx。其中 sendx 表示 buffer 中可写的 index,recvx 表示 buffer 中可读的 index。 从 recvx 到 sendx 之间的元素,表示已正常存放入 buffer 中的数据。 我们可以直接使用 buf[recvx]来读取到队列的第一个元素,使用 buf[sendx] = x 来将元素放到队尾。

Go 并发编程

Mutex

Mutex 的几种状态

  1. mutexLocked — 表示互斥锁的锁定状态;
  2. mutexWoken — 表示从正常模式被从唤醒;
  3. mutexStarving — 当前的互斥锁进入饥饿状态;
  4. waitersCount — 当前互斥锁上等待的 Goroutine 个数;

Mutex 正常模式和饥饿模式

  • 正常模式(非公平锁)
      正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒的 goroutine 不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁的拥有。新请求锁的 goroutine 具有优势:它正在 CPU 上执行,而且可能有好几个,所以刚刚唤醒的 goroutine 有很大可能在锁竞争中失败。在这种情况下,这个被 唤醒的 goroutine 会加入到等待队列的前面。 如果一个等待的 goroutine 超过 1ms 没有获取锁,那么它将会把锁转变为饥饿模式。

  • 饥饿模式(公平锁)
      为了解决了等待 G 队列的长尾问题。饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 G(队头),同时,饥饿模式下,新进来的 G 不会参与抢锁也不会进入自旋状态,会直接进入等待队列的尾部,这样很好的解决了老的 G 一直抢不到锁的场景。 饥饿模式的触发条件,当一个 G 等待锁时间超过 1 毫秒时,或者当前队列只剩 下一个 G 的时候,Mutex 切换到饥饿模式。

总结:对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取 锁,饥饿模式解决了取锁公平的问题,但是性能会下降,其实是性能和公平的 一个平衡模式。

Mutex 允许自旋的条件

  在1个协程获取锁时,另一个协程一直尝试,直到能够获取锁(不断循环),这就是自旋锁。

  1. 锁已被占用,并且锁不处于饥饿模式。
  2. 积累的自旋次数小于最大自旋次数(active_spin=4)。
  3. cpu 核数大于 1。
  4. 有空闲的 P。
  5. 当前 goroutine 所挂载的 P 下,本地待运行队列为空。

RWMutex

  • 实现原理
      通过记录 readerCount 读锁的数量来进行控制,当有一个写锁的时候,会将读锁数量设置为负数 1<<30。目的是让新进入的读锁等待写锁之后释放通知读锁。同样的写锁也会等等待之前的读锁都释放完毕,才会开始进行后续的操作。 而等写锁释放完之后,会将值重新加上 1<<30, 并通知刚才新进入的读锁 (rw.readerSem),两者互相限制。

  • 注意事项

  1. RWMutex 是单写多读锁,该锁可以加多个读锁或者一个写锁
  2. 读锁占用的情况下会阻止写,不会阻止读,多个 goroutine 可以同时获取读锁
  3. 写锁会阻止其他 goroutine(无论读和写)进来,整个锁由该 goroutine 独占
  4. 适用于读多写少的场景
  5. RWMutex 的一个写锁 Lock 去锁定临界区的共享资源,如果临界区的共享资源已被(读锁或写锁)锁定,这个写锁操作的 goroutine 将被阻塞直到 解锁。
  6. RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic
  7. RWMutex 在首次被使用之后就不能再被拷贝
  8. 写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并都可以成功锁定读锁。
  9. 读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而被阻塞的 goroutine,其中等待时间最长的一个 goroutine 会被唤醒。

sync 包中的两个对象

sync.Once

  1. Once 可以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。
  2. Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。
  3. sync.Once 只暴露了一个方法 Do,你可以多次调用 Do 方法,但是只有第一次调用 Do 方法时 f 参数才会执行,这里的 f 是一个无参数无返回值的函数。

sync.Pool

  对于很多需要重复分配、回收内存的地方,sync.Pool 是一个很好的选择。频繁地分配、回收内存会给 GC 带来一定的负担,严重的时候会引起 CPU 的毛刺,而 sync.Pool 可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。

Switch 和 Select 的区别

Select

select只能应用于channel的操作,既可以用于channel的数据接收,也可以用于channel的数据发送。如果select的多个分支都满足条件,则会随机的选取其中一个满足条件的分支。
default: select中的default是当select发现没有case满足,要block时的选择

Switch

switch可以为各种类型进行分支操作, 设置可以为接口类型进行分支判断(通过i.(type))。switch 分支是顺序执行的,这和select不同。
default: switch中的default是默认的意思,当所有case不满足的时候,就会执行default

Go runtime

协程调度

Gorutine

  Goroutine “Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或更多个 goroutine 组成。它与线程、协程、进 程等不同。它是一个 goroutine” —— Rob Pike
  Goroutines 在同一个用户地址空间里并行独立执行 functions,channels 则用于 goroutines 间的通信和同步访问控制。

GMP 模型

  1. G(Goroutine):我们所说的协程,为用户级的轻量级线程,每个 Goroutine 对象中的 sched 保存着其上下文信息.
  2. M(Machine):对内核级线程的封装,数量对应真实的 CPU 数(真正干活的对象)
  3. P(Processor):即为 G 和 M 的调度对象,用来调度 G 和 M 之间的关联关系, 其数量可通过 GOMAXPROCS()来设置,默认为核心数。

  调度器把 G 都分配到 M 上,不同的 G 在不同的 M 并发运行时,都需要向系统申请资源,比如堆栈内存等,因为资源是全局的,就会因为资源竞争照成很多性能损耗。为了解决这一的问题 go 从 1.1 版本引入,在运行时系统的时候加入 p 对象,让 P 去管理这个 G 对象,M 想要运行 G,必须绑定 P,才能运行 P 所管理的 G。单纯的 GM 模型会引起以下问题:

  1. 单一全局互斥锁(Sched.Lock)和集中状态存储;
  2. Goroutine 传递问题(M 经常在 M 之间传递”可运行”的 goroutine)
  3. 每个 M 做内存缓存,导致内存占用过高,数据局部性较差
  4. 频繁 syscall 调用,导致严重的线程阻塞/解锁,加剧额外的性能损耗。

GMP 调度流程

  在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上
avatar

  1. 全局队列(Global Queue):存放等待运行的 G。
  2. P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  3. P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  4. M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行

有关 P 和 M 的个数问题

  1. P 的数量:由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行
  2. M 的数量:(1) go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
    (2) runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
    (3)一个 M 阻塞了,会创建新的 M

M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

P 和 M 何时被创建

  1. P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
  2. M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
调度器的设计策略
  1. work stealing 机制:当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。
  2. hand off 机制:当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。

avatar

  1. 每个 P 有个局部队列,局部队列保存待执行的 goroutine(流程 2),当 M 绑定的 P 的的局部队列已经满了之后就会把 goroutine 放到全局队列(流程 2-1)
  2. 每个 P 和一个 M 绑定,M 是真正的执行 P 中 goroutine 的实体(流程 3),M 从绑定的 P 中的局部队列获取 G 来执行
  3. 当 M 绑定的 P 的局部队列为空时,M 会从全局队列获取到本地队列来执行 G(流程 3.1),当从全局队列中没有获取到可执行的 G 时候,M 会从其他 P 的局部队列中偷取 G 来执行(流程 3.2),这种从其他 P 偷的方式称为 work stealing
  4. 当 G 因系统调用(syscall)阻塞时会阻塞 M,此时 P 会和 M 解绑即 hand off,并寻找新的 idle 的 M,若没有 idle 的 M 就会新建一个 M(流程 5.1)。
  5. 当 G 因 channel 或者 network I/O 阻塞时,不会阻塞 M,M 会寻找其他 runnable 的 G;当阻塞的 G 恢复后会重新进入 runnable 进入 P 队列等待执行(流程 5.3)
  6. 当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。
特殊的 M0 和 G0

M0:启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。
G0:是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

Go 调度器调度场景过程全解析

  1. 场景 1
    P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
    avatar

  2. 场景 2
    G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。
    avatar

  3. 场景 3
    假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了。
    avatar

  4. 场景 4
    G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)
    avatar

  5. G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列。G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。
    avatar

  6. 场景 6
    规定:在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。
    avatar
    假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)

  7. 场景 7
    M2 尝试从全局队列 (简称 “GQ”) 取一批 G 放到 P2 的本地队列(函数:findrunnable())。至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡
    avatar

  8. 场景 8
    假设 G2 一直在 M1 上运行,经过 2 轮后,M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行,全局队列和 P2 的本地队列都空了,如场景 8 图的左半部分。
    avatar

  9. 场景 9
    G1 本地队列 G5、G6 已经被其他 M 偷走并运行完成,当前 M1 和 M2 分别在运行 G2 和 G8,M3 和 M4 没有 goroutine 可以运行,M3 和 M4 处于自旋状态,它们不断寻找 goroutine。
    avatar

  10. 场景 10
    假定当前除了 M3 和 M4 为自旋线程,还有 M5 和 M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。
    avatar

  11. 场景 11
    G8 创建了 G9,假如 G8 进行了非阻塞系统调用
    avatar
    M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。

调度

协作式的调度

  在 1.14 版本之前,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度,存在问题:

  1. 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿
  2. 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作。

基于信号的抢占式调度

  GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以后,才会发现有一个 P/M 被 syscall 阻塞了。然后,才会用空闲的 M 来强这个 P。通过 sysmon 监控实现的抢占式调度,最快在 20us,最慢在 10-20ms 才 会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调度(一般情况 1ms 可以完成几十次线程调度),Go 发起 IO/syscall 的时候执 行该 G 的 M 会阻塞然后被 OS 调度走,P 什么也不干,sysmon 最慢要 10-20ms 才能发现这个阻塞,说不定那时候阻塞已经结束了,宝贵的 P 资源就这么被阻塞的 M 浪费了。

GMP 调度过程中存在哪些阻塞

  1. I/O,select
  2. block on syscall
  3. channel
  4. 等待锁
  5. runtime.Gosched() (G0 调度 goroutine)

内存分配

Golang 简要内存划分

avatar
  可以简单的认为 Golang 程序在启动时,会向操作系统申请一定区域的内存,分为栈(Stack)和堆(Heap)。栈内存会随着函数的调用分配和回收;堆内存由程序申请分配,由垃圾回收器(Garbage Collector)负责回收。性能上,栈内存的使用和回收更迅速一些;尽管Golang 的 GC 很高效,但也不可避免的会带来一些性能损耗。因此,Go 优先使用栈内存进行内存分配。在不得不将对象分配到堆上时,才将特定的对象放到堆中。

栈和堆

avatar
 &emnsp;上图展示了一个进程的虚拟内存划分,代码中使用的内存地址都是虚拟内存地址,而不是实际的物理内存地址。栈和堆只是虚拟内存上2块不同功能的内存区域:

  • 栈在高地址,从高地址向低地址增长。
  • 堆在低地址,从低地址向高地址增长。

栈和堆相比有这么几个好处

  1. 栈的内存管理简单,分配比堆上快。
  2. 栈的内存不需要回收,而堆需要,无论是主动free,还是被动的垃圾回收,这都需要花费额外的CPU。
  3. 栈上的内存有更好的局部性,堆上内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,CPU访问数据的时间可能就上去了。

栈内存分配

  1. 以一段简单的代码作为示例,分析这段代码的内存分配过程。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package main

    import "fmt"

    func main() {
    n := 4
    n2 := square(n)
    fmt.Println(n2)
    }

    func square(n int) int{
    return n * n
    }
      代码的功能很简单,一个 main 函数作为程序入口,定义了一个变量n,定义了另一个函数 squire ,返回乘方操作后的 int 值。最后,将返回的值打印到控制台。程序输出为16。下面开始逐行进行分析,解析调用时,go 运行时是如何对内存进行分配的。
    avatar
      当代码运行到第6行,进入 main 函数时,会在栈上创建一个 Stack frame,存放本函数中的变量信息。包括函数名称,变量等。

avatar
  当代码运行到第7行时,go 会在栈中压入一个新的 Stack Frame,用于存放调用 square 函数的信息;包括函数名、变量 n 的值等。此时,计算4 * 4 的值,并返回。

avatar
  当 square 函数调用完成,返回16到 main 函数后,将16赋值给 n2变量。注意,原来的 stack frame 并不会被 go 清理掉,而是如栈左侧的箭头所示,被标记为不合法。上图夹在红色箭头和绿色箭头之间的横线可以理解为 go 汇编代码中的 SP 栈寄存器的值,当程序申请或释放栈内存时,只需要修改 SP 寄存器的值,这种栈内存分配方式省掉了清理栈内存空间的耗时。

avatar
  接下来,调用 fmt.Println 时,SP 寄存器的值会进一步增加,覆盖掉原来 square 函数的 stack frame,完成 print 后,程序正常退出。

  1. 指针作为参数情况下的栈内存分配
    还是同样的过程,看如下这段代码。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package main

    import "fmt"

    func main() {
    n := 4
    increase(&n)
    fmt.Println(n)
    }

    func increase(i *int) {
    *i++
    }

  main 作为程序入口,声明了一个变量 n,赋值为4。声明了一个函数   increase,使用一个 int 类型的指针 i 作为参数,increase 函数内,对指针 i 对应的值进行自增操作。最后 main 函数中打印了 n 的值。程序输出为5。

avatar
  当程序运行到 main 函数的第6行时,go 在栈上分配了一个 stack frame ,对变量 n 进行了赋值,n 在内存中对应的地址为0xc0008771,此时程序将继续向下执行,调用 increase 函数。

avatar
  这时,increase 函数对应的 stack fream 被创建,i 被赋值为变量 n对应的地址值0xc0008771,然后进行自增操作。

avatar
  当 increase 函数运行结束后,SP 寄存器会上移,将之前分配的 stack freme 标记为不合法。此时,程序运行正常,并没有因为 SP 寄存器的改动而影响程序的正确性,内存中的值也被正确的修改了。

  1. 指针作为返回值情况下的栈内存分配
      之前的部分分别介绍了普通变量作为参数和将指针作为参数情况下的栈内存使用,本部分来介绍将指针作为返回值,返回给调用方的情况下,内存是如何分配的,并引出内存逃逸相关内容。来看这段代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package main

    import "fmt"

    func main() {
    n := initValue()
    fmt.Println(*n/2)
    }

    func initValue() *int {
    i := 4
    return &i
    }
      main 函数中,调用了 initValue 函数,该函数返回一个 int 指针并赋值给 n,指针对应的值为4。随后,main 函数调用 fmt.Println 打印了指针 n / 2对应的值。程序输出为2。

avatar
  程序调用 initValue 后,将 i 的地址赋值给变量 n 。注意,如果这时,变量 i 的位置在栈上,则可能会随时被覆盖掉。

avatar
在调用 fmt.Println 时,Stack Frame 会被重新创建,变量 i 被赋值为*n/2也就是2,会覆盖掉原来 n 所指向的变量值。这会导致及其严重的问题。在面对 sharing up 场景时,go 通常会将变量分配到堆中,如下图所示:

avatar
  通过上面的分析,可以看到在面对被调用的函数返回一个指针类型时将对象分配到栈上会带来严重的问题,因此 Go 将变量分配到了堆上。这种分配方式保证了程序的安全性,但也不可避免的增加了堆内存创建,并需要在将来的某个时候,需要 GC 将不再使用的内存清理掉。

栈上内存分配原则

  • 在调用方创建的变量或对象,通过参数的形式传递给被调用函数,这时,在调用方创建的内存空间通常在栈上。这种在调用方创建内存,在被调用方使用该内存的“内存共享”方式,称之为 Sharing down
  • 在被调用函数内创建的对象,以指针的形式返回给调用方的情况下,通常,创建的内存空间在堆上。这种在被调用方创建,在调用方使用的“内存共享”方式,称之为 Sharing up。
  • 总结:1. 因为栈比堆更高效,不需要 GC,因此 Go 会尽可能的将内存分配到栈上。
    1. 当分配到栈上可能引起非法内存访问等问题后,会使用堆,主要场景有:
      (1)当一个值可能在函数被调用后访问,这个值极有可能被分配到堆上
      (2)当编译器检测到某个值过大,这个值会被分配到堆上
      (3)当编译时,编译器不知道这个值的大小(slice、map…)这个值会被分配到堆上

堆内存管理

avatar
  当我们说内存管理的时候,主要是指堆内存的管理,因为栈的内存管理不需要程序去操心。这小节看下堆内存管理干的是啥,如上图所示主要是3部分:分配内存块,回收内存块和组织内存块
  在一个最简单的内存管理中,堆内存最初会是一个完整的大块,即未分配内存,当来申请的时候,就会从未分配内存,分割出一个小内存块(block),然后用链表把所有内存块连接起来。需要一些信息描述每个内存块的基本信息,比如大小(size)、是否使用中(used)和下一个内存块的地址(next),内存块实际数据存储在data中。
avatar
  一个内存块包含了3类信息,如下图所示,元数据、用户数据和对齐字段,内存对齐是为了提高访问效率。下图申请5Byte内存的时候,就需要进行内存对齐。

avatar
  释放内存实质是把使用的内存块从链表中取出来,然后标记为未使用,当分配内存块的时候,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。上面这个简单的设计中还没考虑内存碎片的问题,因为随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

TCMalloc

  TCMalloc 是 Thread Cache Malloc 的简称,是 Go 内存管理的起源。引入虚拟内存后,让内存的并发访问问题的粒度从多进程级别,降低到多线程级别。同一进程的所有线程共享相同的内存空间,他们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。TCMalloc的做法是什么呢?为每个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存,这样有2个好处:

  1. 为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时,从缓存分配,都是在用户态执行,没有系统调用,缩短了内存总体的分配和释放时间,这是快速分配内存的第二个层次
  2. 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,无需加锁,把内存并发访问的粒度进一步降低了,这是快速分配内存的第三个层次

Go GC(垃圾回收)

垃圾回收

  程序在创建引用类型实体时会在虚拟内存中分配给他们一块空间(堆),如果该内存空间不再被任何引用变量引用就称为需要被回收的垃圾。操作系统会记录一个进程运行时的所占用的内存、CPU和寄存器等资源,当进程结束后便由操作系统能够自动回收资源。但是对于一个运行较长时间的程序,如果使用完内存资源后没有及时释放就会造成内存泄漏甚至系统错误。
  以不支持自动垃圾回收的 C++ 为例:

1
2
3
4
5
6
void foo()
{
char *p = new char[128];
// 对指针的使用
delete[] p; // delete语句释放对象数组
}

  如果由于一场或者其他原因导致 delete 语句没有正常执行,且该函数被频繁调用,那么很容易占用所有的内存从而导致程序崩溃,如果泄漏的时系统资源还会导致系统崩溃。另一方面如果我们在不该释放内存的时候释放内存,那么仍然在使用这块内存的指针就会变成野指针 wild pointer,使用该指针对内存进行读写是未定义的行为。

  1. 垃圾回收过程
      用户程序Mutator通过内存分配器Allocator在堆Heap上申请内存,垃圾回收器Collector会定时清理堆上的内存。
    avatar

  2. 自动垃圾回收与手动垃圾回收
      C语言这种较为传统的语言通过mallocfree手动向操作系统申请和释放内存,这种自由管理内存的方式给予程序员极大的自由度,但是也相应地提高了对程序员的要求。C语言的内存分配和回收方式主要包括三种:

  • 函数体内的局部变量:在栈上创建,函数作用域结束后自动释放内存
  • 静态变量:在静态存储区域上分配内存,整个程序运行结束后释放(全局生命周期)
  • 动态分配内存的变量:在堆上分配,通过malloc申请,free释放

  CC++等较早的语言采用的是手动垃圾回收,需要程序员通过向操作系统申请和释放内存来手动管理内存,程序员极容易忘记释放自己申请的内存,对于一个长期运行的程序往往是一个致命的缺点。PythonJavaGolang等较新的语言采取的都是自动垃圾回收方式,程序员只需要负责申请内存,垃圾回收器会周期性释放结束生命周期的变量所占用的内存空间。

  1. 垃圾回收目标
    垃圾回收器主要包括三个目标:
  • 无内存泄漏:垃圾回收器最基本的目标就是减少防止程序员未及时释放导致的内存泄漏,垃圾回收器会识别并清理内存中的垃圾
  • 自动回收无用内存:垃圾回收器作为独立的子任务,不需要程序员显式调用即可自动清理内存垃圾
  • 内存整理:如果只是简单回收无用内存,那么堆上的内存空间会存在较多内存碎片而无法满足分配较大对象的需求,因此垃圾回收器需要重整内存空间,提高内存利用率

常见的垃圾回收方法

  根据判断对象是否存活的方法,可以简单将GC算法分为“引用计数式”垃圾回收和“追踪回收式”垃圾回收。前者根据每个对象的引用计数器是否为0来判断该对象是否为未引用的垃圾对象,后者先判断哪些对象存活,然后将其余的所有对象作为垃圾进行回收。追踪回收本身包括标记-清除Mark-Sweep、标记-复制Mark-Copy和标记-整理Mark-Compact三种回收算法。

引用计数法

  引用计数Reference counting会为每个对象维护一个计数器,当该对象被其他对象引用时加一,引用失效时减一,当引用次数归零后即可回收对象。使用这类GC方法的语言包括pythonphpobjective-CC++标准库中的std::shared_ptr等。
优点:

  • 原理和实现都比较简单
  • 回收的即时性:当对象的引用计数为0时立即回收,不像其他GC机制需要等待特定时机再回收,提高了内存的利用率
  • 不需要暂停应用即可完成回收

缺点:

  • 无法解决循环引用的回收问题:当ObjA引用了ObjBObjB也引用ObjA时,这两个对象的引用次数使用大于0,从而占用的内存无法被回收
  • 时间和空间成本较高:一方面是因为每个对象需要额外的空间存储引用计数器变量,另一方面是在栈上的赋值时修改引用次数时间成本较高(原本只需要修改寄存器中的值,现在计数器需要不断更新因此不是只读的,需要额外的原子操作来保证线程安全)
  • 引用计数是一种摊销算法,会将内存的回收分摊到整个程序的运行过程,但是当销毁一个很大的树形结构时无法保证响应时间

追踪回收式

追踪基础:可达性分析算法
  尽管前面提到的三种追踪式垃圾回收算法实现起来各不相同,但是第一步都是通过可达性分析算法标记Mark对象是否“可达”。一般可到达的对象主要包括两类:

  • GC Root对象:包括全局对象、栈上的对象(函数参数与内部变量)
  • GC Root对象通过引用链Reference Chain相连的对象

对于“不可达”的对象,我们可以认为该对象为垃圾对象并回收对应的内存空间。
avatar

同引用计数法相比,追踪式算法具有如下优点:

  • 解决了循环引用对象的回收问题
  • 占用空间更少

缺点包括:

  • 同引用计数相比无法立刻识别出垃圾对象,需要依赖GC线程(下文的算法)
  • 算法在标记时必须暂停整个程序,即Stop The World, STW,否则其他线程的代码会修改对象状态从而回收不该回收的对象
  1. 标记-清除算法
    标记-清除Mark-Sweep算法是最基础的追踪式算法,分为“标记”和“清除”两个步骤:
  • 标记:记录需要回收的垃圾对象
  • 清除:在标记完成后回收垃圾对象的内存空间

avatar
优点包括:

  • 算法吞吐量较高,即运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)较高
  • 空间利用率高:同标记-复制相比不需要额外空间复制对象,也不需要像引用计数算法为每个对象设置引用计数器

缺点:

  • 清除后会产生大量的内存碎片空间,导致程序在运行时可能没法为较大的对象分配内存空间,导致提前进行下一次垃圾回收
  1. 标记-复制算法
    标记-复制Mark-Copy算法将内存分成大小相同的两块,当某一块的内存使用完了之后就将使用中的对象挨个复制到另一块内存中,最后将当前内存恢复未使用的状态。
    avatar

优点:

  • 标记-清除法需要在清除阶段对大量垃圾对象进行扫描,标记-复制则只需要从GC Root对象出发,将“可到达”的对象复制到另一块内存后直接清理当前这块的内存,因此提升了垃圾回收的效率
  • 解决了内存碎片化的问题,防止分配较大连续空间时的提前GC问题

缺点:

  • 同标记-清除法相比,在“可达”对象占比较高的情况下有复制对象的开销
  • 内存利用率较低,相当于可利用的内存仅有一半
  1. 标记-整理算法
      标记-整理Mark-Compact算法综合了标记-清除法和标记-复制法的优势,既不会产生内存碎片化的问题,也不会有一半内存空间浪费的问题。该方法首先标记出所有“可达”的对象,然后将存活的对象移动到内存空间的一端,最后清理掉端边界以外的内存。
    avatar
    优点包括:
  • 避免了内存碎片化的问题
  • 在对象存活率较高的情况下,标记-整理算法由于不需要复制对象效率更高,因此更加适合老年代算法

缺点包括:

  • 整理过程较为复杂,需要多次遍历内存导致STW时间比标记-清除算法更长

Golang 的 GC 算法

三色标记法(go 1.3)

  前面提到的“标记”类算法都有一个共同的瑕疵,即在进行垃圾回收的时候会暂停整个程序(STW问题)。三色标记法是对“标记”阶段的改进,在不暂停程序的情况下即可完成对象的可达性分析。GC线程将所有对象分为三类:

  • 白色:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时所有的白色都是垃圾对象
  • 灰色:正在搜索的对象,但是对象身上还有一个或多个引用没有扫描
  • 黑色:已搜索完的对象,所有的引用已经被扫描完

  三色标记法属于增量式GC算法,回收器首先将所有的对象着色成白色,然后从GC Root出发,逐步把所有“可达”的对象变成灰色再到黑色,最终所有的白色对象即是“不可达”对象。

GC Root 对象包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

具体的实现如下:

  1. 初始时所有对象都是白色对象
  2. GC Root对象出发,扫描所有可达对象并标记为灰色,放入待处理队列
  3. 从队列取出一个灰色对象并标记为黑色,将其引用对象标记为灰色放入队列
  4. 重复上一步骤,直到灰色对象队列为空
  5. 此时所有剩下的白色对象就是垃圾对象

gif

优点:不需要暂停整个程序进行垃圾回收
缺点:

  • 如果程序垃圾对象的产生速度大于垃圾对象的回收速度时,可能导致程序中的垃圾对象越来越多而无法及时收集
  • 线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,从而降低系统吞吐量

屏障机制

  1. STW
      STW 可以是Stop The World的缩写,也可以是Start The World的缩写。通常意义上指的是从Stop The World到Start The World这一段时间间隔。垃圾回收过程中为了保证准确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图以完成垃圾回收。STW时间越长,对用户代码造成的影响越大。

  2. No STW 存在的问题
      假设下面的场景,已经被标记为灰色的对象2,未被标记的对象3被对象2用指针p引用;此时已经被标记为黑色的对象4创建指针q 指向未被标记的对象3,同时对象2将指针p移除;对象4已经被标记为黑色,对象3未被引用,对象2删除与对象3的引用,导致最后对象3被误清除;
    avatar

  • 垃圾回收的原则是不应出现对象的丢失,也不应错误的回收还不需要回收的对象。如果同时满足下面两个条件会破坏回收器的正确性:
    • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;(通俗的说就是A突然持有了B的指针,而B在并发标记的过程中已经被判定为白色对象要被清理掉的)
    • 条件 2: 从灰色对象出发,到达白色对象且未经访问过的路径被赋值器破坏;(通俗的说就是A持有B的指针,这个持有关系被释放)

只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:

  • 如果条件 1被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;强三色不变性
  • 如果条件 2 被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。弱三色不变性

可能的解决方法: 整个过程STW,浪费资源,且对用户程序影响较大,由此引入了屏障机制

  1. 屏障机制
      把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:
  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
  • 灰色赋值器:尚未被回收器扫描过或尽管已经扫描过,但仍需要重新扫描。
Dijkstra插入写屏障(go 1.5)

  Dijkstra插入写屏障避免了前面提到的条件1,即防止黑色对象指向白色对象。一个对象可以存储在内存中的“栈”或者“堆”,由于“栈”空间容量小且要求相应速度较高,因此“插入写屏障”不适合用于“栈”空间。在“插入写屏障”保护下的三色标记法执行例子如下:
avatar
avatar
avatar

  尽管Dijkstra插入写屏障可以实现垃圾回收和用户程序的并发执行,但是它存在两个缺点。一方面它是一种比较保守的垃圾回收方法,把有可能存活的对象都标记成灰色了以满足“强三色不变性”。以下图为例,用户程序Mutator将对象A原本指向B对象的指针改成指向C对象,尽管在修改后B对象已经是一个垃圾对象,但是它在本轮垃圾回收过程中不会被回收。
avatra

Yuasa删除屏障

  Yuasa删除写屏障避免了前面提到的条件2,防止丢失灰色对象到白色对象的可达路径。满足了弱三色不变性
avatar

下图简单绘制了Yuasa删除写屏障是如何保证用户程序Mutator和垃圾回收器Collector的并发执行的:

  • 第二步中Mutator将对象A原本指向对象B的指针指向C,由于对象B本身就是灰色的,因此不需要对它重新着色
  • 第三步中Mutator删除了对象B指向对象C的指针,删除写屏障将下游对象C标记为灰色
    avatar
      Yuasa删除写屏障和Dijkstra插入写屏障相比优点在于不需要在一轮三色标记后对栈空间上的对象进行重新扫描,缺点在于Collector会悲观地认为所有被删除的对象都可能被黑色对象引用,比如上图中第三步Mutator删除了对象B指向对象C的指针,如果此时还有一个单独的对象E指向C,那么本该被删除的对象E却可以在本轮垃圾回收中存活。
混合写屏障(go 1.8)

混合写屏障也是仅在堆空间启动的,防止降低栈空间的运行效率

回顾一下之前提到的两种写屏障的劣势:

  • Dijkstra插入写屏障:一轮标记结束后需要STW重新扫描栈上对象
  • Yuasa删除写屏障:回收精度低,在垃圾回收开始前使用STW扫描所有GC Root对象形成初始快照,用户程序Mutator从灰色/白色对象中删除白色指针时会将下游对象标记为灰色,相当于保护了所有初始快照中的白色对象不被删除
具体场景的实现

GC开始阶段会将所有栈空间可达对象都标记为黑色:
avatar

场景一:某个对象从堆对象的下游变成栈对象的下游,这种情况下标记该对象为灰色,该对象就不会被错误地回收
avatar

场景二:某个对象从一个栈对象的下游变成另一个对象的下游,由于对象全都在栈空间对象的可达对象中,因此混合写屏障不会对这些对象着色。
avatar

场景三:某个对象从一个堆对象的下游变成另一个堆对象的下游,比如下图中对象G从F的下游移动到Y的下游,为了避免对象G被错误回收,我们需要将其标记为灰色
avatar

场景四:某个对象从栈对象的下游变成堆对象的下游,对于栈空间对象不触发写屏障,但是对于被删除的堆空间对象G需要标记成灰色以保护它和它的下游对象不被错误删除
avatar

  • 混合写屏障继承了插入写屏障的优点,起始无需 STW 打快照,直接并发扫描垃圾即可;
  • 混合写屏障继承了删除写屏障的优点,赋值器是黑色赋值器,GC 期间,任何在栈上创建的新对象,均为黑色。扫描过一次就不需要扫描了,这样就消除了插入写屏障时期最后 STW 的重新扫描栈;
  • 混合写屏障扫描精度继承了删除写屏障,比插入写屏障更低,随着带来的 是 GC 过程全程无 STW;
  • 混合写屏障扫描栈虽然没有 STW,但是扫描某一个具体的栈的时候,还是 要停止这个 goroutine 赋值器的工作的

GC 触发时机

主动触发:调用 runtime.GC
被动触发: 使用系统监控,该触发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟。当超过两分钟没有产生任何 GC 时,强制触发 GC。 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。如 Go 的 GC 是一种比例 GC, 下一次 GC 结束时的堆大小和上一次 GC 存活堆大小成比例. 由 GOGC 控制, 默认 100, 即 2 倍的关系, 200 就是 3 倍, 当 Go 新创建的对象所占用的内存大小,除以上次 GC 结束后保留下来的对象占用内存大小。

  • Post title:Golang 重点问题
  • Post author:洪笳淏
  • Create time:2022-02-09 23:50:00
  • Post link:https://jiahaohong1997.github.io/2022/02/09/Golang 重点问题/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.
 Comments