CPU

  1. 一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程
  2. goroutine 的调度Go Runtime 来完成:『公平』竞争『CPU
    • Go Runtime 如何将众多的 goroutine 按照一定的算法调度到『CPU』上运行
  3. goroutine竞争的『CPU』是操作系统线程
    • goroutine 按照一定算法放到不同的操作系统线程上执行

演进

  1. G-M -> G-P-M
  2. 不支持抢占 -> 支持协作式抢占 -> 支持基于信号异步抢占

G-M

2012.03.28 Go 1.0

  1. 抽象
    • 每个 goroutine 对应于运行时的一个抽象结构 G
    • 被视作『CPU』的操作系统线程,则对应另一个抽象结构 M(machine)
  2. 工作
    • 将 G 调度到 M 上去运行
  3. GOMAXPROCS
    • 调度器可见的最大 M 数

缺陷:限制 Go 并发程序的伸缩性,尤其对于有高吞吐并行计算的服务程序

  1. 由于单一全局互斥锁集中状态存储的存在,导致所有 goroutine 相关操作,都需要上锁
  2. M 之间经常传递可运行的 goroutine,导致调用延迟增大,增加额外的性能开销
  3. 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差
  4. 由于系统调用而形成的频繁的工作线程阻塞解除阻塞,导致额外的性能损耗

G-P-M

Go 1.2 - 计算机领域的任何问题都可以增加一个间接的中间层来解决

在 G-M 模型中增加一个 P,让调度器具有很好的伸缩性

image-20231125152819676

  1. P 是一个逻辑 Processor,每个 G 要真正运行,需要首先被分配一个 P,即进入 P 的本地队列
  2. 对 G 来说,P 就是运行 G 的『CPU』,即 G 只能看见 P
  3. 对 Go 调度器来说,真正的『CPU』是 M,只有将 P 和 M 绑定,才能让 P 的本地队列中 G 真正运行起来

但此时,调度器依然不支持抢占式调度

  1. 一旦某个 G 出现死循环,那么 G 将永久占用分配给它的 P 和 M
  2. 而位于同一个 P 中的其它 G 将得不到调度,出现饿死的情况
  3. 如果只有一个 P(GOMAXPROCS=1)时,整个 Go 程序中的其它 G 都将被饿死

基于协作的抢占式调度

Go 1.2

  1. 编译器在每个函数或者方法入口,加上一段额外的代码
    • 运行时有机会在这段代码中检查是否需要抢占式调度
  2. 只能局部解决饿死问题,因为只在函数调用的地方才能插入抢占代码埋点
    • 对于没有函数调用而是纯算法循环计算的 G,调度器依然无法抢占
  3. 后果
    • GC 在等待所有 goroutine 停止时的等待时间过长(类似于 JVM 的安全点
    • 从而导致 GC 延迟,内存占用瞬间冲高

基于系统信号的抢占式调度

Go 1.14

  1. 通过向线程发送信号的方式来抢占正在运行的 goroutine

小优化

通过 IO poller 减少 M阻塞等待

  1. 运行时已经实现了 netpoller
    • 即便 G 发起网络 IO 操作,也不会导致 M 被阻塞(只阻塞 G),也不会导致大量线程 M 被创建出来
  2. 对于文件 IO 操作,一旦阻塞,那么线程 M 将进入挂起状态,等待 IO 返回后唤醒
    • 此时 P 将与挂起的 M 解绑,再选择一个处于空闲状态的 M
    • 如果此时没有空闲的 M,就会新创建一个 M(线程)
    • 在这种情况下,大量 IO 操作仍然会导致大量线程被创建
  3. Go 1.9 ,在 G 操作支持监听FD 时,仅会阻塞 G,而不会阻塞 M
    • 但依然对常规文件无效,因为常规文件是不支持监听

G-P-M

G-M 模型已经废弃,NUMA 模型尚未实现

语义

  1. G - 代表 goroutine
    • 存储 goroutine 的执行栈信息状态任务函数
    • G 对象是可重用
  2. P - 代表逻辑 Processor
    • P 的数量决定了系统内最大可并行G 的数量
    • P 拥有各种 G 对象队列、链表、一些缓存和状态
  3. M - 代表真正的执行计算资源
    • 在 M 绑定有效的 P 后,进入调度循环
      • 从 P 的本地队列以及全局队列中获取 G,并切换到 G 的执行栈上并执行 G 的函数
      • 调用 goexit 做清理工作并回到 M,如此反复
    • M 并不保留 G 状态,因此 G 可以跨 M 调度

调度

调度器的目标:公平合理地将 G 调度到 P 上运行

抢占调度

场景:G 没有进行系统调用、没有进行 IO 操作、没有阻塞在一个 channel 操作上

  1. Go 程序启动时,Go Runtime 会去启动一个名为 sysmonM监控线程
  2. 该 M 不需要绑定 P 就可以运行(以 g0 的形式)
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
31
32
33
// The main goroutine.
func main() {
...
systemstack(func() {
newm(sysmon, nil, -1)
})
...
}

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
...
for {
if idle == 0 { // start with 20us sleep...
delay = 20
} else if idle > 50 { // start doubling the sleep after 1ms...
delay *= 2
}
if delay > 10*1000 { // up to 10ms
delay = 10 * 1000
}
...
// retake P's blocked in syscalls
// and preempt long running G's
if retake(now) != 0 {
idle = 0
} else {
idle++
}
...
}

sysmon20us ~ 10ms 启动一次

  1. 释放闲置超过 5 分钟的 span 内存
  2. 如果超过 2 分钟没有执行 GC,强制执行
  3. 将长时间未处理的 netpoll 结果添加到任务队列中
  4. 长时间运行的 G 任务发起抢占式调度 - retake
  5. 回收因系统调用长时间阻塞P
runtime/proc.go
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// forcePreemptNS is the time slice given to a G before it is
// preempted.
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

func retake(now int64) uint32 {
...
// Preempt G if it's running for too long.
t := int64(_p_.schedtick)
if int64(pd.schedtick) != t {
pd.schedtick = uint32(t)
pd.schedwhen = now
} else if pd.schedwhen+forcePreemptNS <= now {
preemptone(_p_)
// In case of syscall, preemptone() doesn't
// work, because there is no M wired to P.
sysretake = true
}
...
}

func preemptone(_p_ *p) bool {
mp := _p_.m.ptr()
if mp == nil || mp == getg().m {
return false
}
gp := mp.curg
if gp == nil || gp == mp.g0 {
return false
}

gp.preempt = true // 设置被抢占标志

// Every call in a go routine checks for stack overflow by
// comparing the current stack pointer to gp->stackguard0.
// Setting gp->stackguard0 to StackPreempt folds
// preemption into the normal stack overflow check.
gp.stackguard0 = stackPreempt

// Request an async preemption of this P.
if preemptMSupported && debug.asyncpreemptoff == 0 {
_p_.preempt = true
preemptM(mp)
}

return true
}
  1. 如果一个 G 任务运行 10 ms,sysmon 会认为其运行时间太久而发起抢占式调度的请求
  2. 一旦 G 的抢占标志位被设为 true,等到 G 的下一次调用函数或者方法时(Go Runtime 注入埋点)
    • Go Runtime 可以将 G 抢占并移除运行状态,然后放入队列中,等待下一次调度

channel / 网络 IO

不阻塞 M,避免大量创建 M 导致的开销

  1. 如果 G 被阻塞在某个 channel 操作或者网络 IO 操作时
    • G 会被放置在某个等待队列中,M 会尝试运行 P 的下一个可运行 G
  2. 如果此时 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态
  3. 当 IO 操作完成或者 channel 操作完成
    • 等待队列中的 G 会被唤醒,标记为可运行(Runnable),并放入到某 P 的队列

系统调用

阻塞 M,但在阻塞前会与 P 解绑,P 会尝试与其它 M 绑定继续运行其它 G

如果没有现成的 M,Go Runtime 会新建 M,因此系统调用可能会导致系统线程数增加

  1. 如果 G 被阻塞在某个系统调用上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,并与 G 一起进入挂起状态

    • 如果此时有空闲的 M,那么 P 会和它绑定,并继续执行其它 G

    • 如果此时没有空闲的 M,但仍然有其他 G 要执行,那么 Go Runtime 会创建一个新 M 线程

  2. 当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P

    • 如果没有可用的 P,那么 G 会被标记为 Runnable,之前的那个挂起的 M 将再次进入挂起状态
    • 因为 G 和 M 需要通过一个 P 来桥接