目录

Go GPM 模型

基本概念

  • 普通栈:需要调度的 Goroutine 组成的函数栈,是可增长的栈,因为 Goroutine 可以越开越多;
  • 线程栈:需要将 Goroutine 放置到线程上的 M 们组成,实质上 M 也是由 Goroutine 生成的,线程栈大小固定(设置了 M 的数量)。所有调度相关的代码,会先切换到该 Goroutine 的栈中再执行。也就是说线程的栈也是用的 G 实现,而不是使用的 OS 的;
  • 全局队列:该队列存储的 G 将被所有的 M 全局共享,为保证数据竞争问题,需加锁处理;
  • 本地队列:该队列存储数据资源相同的任务,每个本地队列都会绑定一个 M ,指定其完成任务,没有数据竞争,无需加锁处理,处理速度远高于全局队列;
  • 上下文切换:
    • 对于代码中某个值来说,上下文是指这个值所在的局部(全局)作用域对象;
    • 对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等;
  • 线程清理:由于每个 P 都需要绑定一个 M 进行任务执行,所以当清理线程的时候,只需要将 P 释放,即解除绑定(M 就没有任务)即可。P 被释放主要由两种情况:
    • 主动释放:最典型的例子是,当执行 G 任务时有系统调用,当发生系统调用时 M 会处于阻塞状态。调度器会设置一个超时时间,当超时时会将 P 释放;
    • 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的 PM 组合。当超过系统程序设置的超时时间,会自动将 P 资源抢走。去执行队列的其它 G 任务;

调度器演进

Go 程序是用户层程序,它本身就是整体运行在一个或多个操作系统线程上的。所以 Goroutine 们要竞争的「CPU」资源就是操作系统线程。因此,调度器的任务就是将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。

  • 单线程调度器 - 0.x
    • 包含少量的代码;
    • 程序中只能存在一个活跃线程,由 G-M 模型组成;
  • 多线程调度器 - 1.0
    • 允许运行多线程的程序;
    • 全局锁导致竞争严重;
  • 任务窃取调度器 - 1.1
    • 引入了处理器 P,构成了目前的 G-M-P 模型;
    • 在处理器 P 的基础上实现了基于工作窃取的调度器;
    • 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;
    • 时间过长的垃圾回收(STW 问题)会导致程序长时间无法工作;
  • 抢占式调度器 - 1.2~至今
    • 基于协作的抢占式调度器 - 1.2~1.3
      • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
      • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
    • 基于信号的抢占式调度器 - 1.14~至今
      • 实现基于信号的真抢占式调度;
      • 垃圾回收在扫描栈时会触发抢占调度;
      • 抢占的时间点不够多,还不能覆盖全部的边缘情况;
  • 非均匀存储访问(NUMA - Non-Uniform Memory Access)调度器(仅有提案)
    • 对运行时的各种资源进行分区;
    • 实现非常复杂,未提上日程;

德米特里·维尤科夫 - Dmitry Vyukov 在其 Scalable Go Scheduler Design Doc 一文中指出了 G-M 模型的一个重要不足:限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。

为了解决这些问题,德米特里·维尤科夫又亲自操刀改进了 Go 调度器,在 Go 1.1 版本中实现了 GPM 调度模型和 Work Stealing 算法,这个模型一直沿用至今。模型如下图所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
                           +---------------------sysmon-------------------+
                           |                                              |
                           |                                              |
               +---+   +---|-------+                 +--------+       +---|---+
go func() ---->| G |-->| P | Local | <== balance ==> | global | <---->| P | M |
               +---+   +-----------+                 +--------+       +---|---+
                 |                                        |               |
                 |    +---+                               |               |
                 +--->| M | <--- find runnable -----------+----- steal <--+
                      +---+
                        |
                        |
  +----execute <----schedule--+
  |                           |
  |                           |
  +---> G.fn ---> goexit -----+

1. go creates a new goroutine
2. newly created goroutine being put into local or global queue
3. a M is being waken or created to execute goroutine
4. schedule loop
5. try its best to get a goroutine to execute
6. clear, reenter schedule loop

GPM 模型的实现算是 Go 调度器的一大进步,但调度器仍然有一个令人头疼的问题,那就是不支持抢占式调度,这导致一旦某个 G 中出现死循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,而位于同一个 P 中的其他 G 将得不到调度,出现「饿死」的情况。

更为严重的是,当只有一个 P(GOMAXPROCS=1)时,整个 Go 程序中的其他 G 都将「饿死」。于是德米特里·维尤科夫又提出了 Go Preemptive Scheduler Design 并在 Go 1.2 中实现了 基于协作的抢占式 调度。

抢占式调度的原理:Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。

这种解决方案只能说局部解决了「饿死」问题,只在有函数调用的地方才能插入「抢占」代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。比如,死循环等并没有给编译器插入抢占代码的机会,这就会导致 GC 在等待所有 Goroutine 停止时的等待时间过长,从而 导致 GC 延迟,内存占用瞬间冲高;甚至在一些特殊情况下,导致在 STW 时死锁。

为了解决这些问题,Go 在 1.14 版本中接受了 奥斯汀·克莱门茨(Austin Clements) 的提案,增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。除了这些大的迭代外,Goroutine 的调度器还有一些小的优化改动,比如通过文件 I/O Poller 减少 M 的阻塞等。

Go 运行时已经实现了 Netpoller,这使得即便 G 发起网络 I/O 操作,也不会导致 M 被阻塞(仅阻塞 G),也就不会导致大量线程(M)被创建出来。但是对于文件 I/O 操作来说,一旦阻塞,那么线程(M)将进入挂起状态,等待 I/O 返回后被唤醒。这种情况下 P 将与挂起的 M 分离,再选择一个处于空闲状态(idle)的 M。如果此时没有空闲的 M,就会新创建一个 M(线程),所以,这种情况下,大量 I/O 操作仍然会导致大量线程被创建。

为了解决这个问题,Go 开发团队的 伊恩·兰斯·泰勒(Ian Lance Taylor) 在 Go 1.9 中增加了一个针对文件 I/O 的 Poller 的功能,这个功能可以像 Netpoller 那样,在 G 操作那些支持监听(pollable)的文件描述符时,仅会阻塞 G,而不会阻塞 M。不过这个功能依然不能对常规文件有效,常规文件是不支持监听的(pollable)。但对于 Go 调度器而言,这也算是一个不小的进步了。

从 Go 1.2 以后,Go 调度器就一直稳定在 GPM 调度模型上,尽管有各种优化和改进,但也都是基于这个模型之上的。德米特里·维尤科夫在 2014 年 9 月提出了一个新的设计草案文档:NUMA‐aware scheduler for Go,作为对未来 Goroutine 调度器演进方向的一个提议,不过至今似乎这个提议也没有列入开发计划。

GPM 调度模型

GPM 代表了三个角色,分别是 Goroutine、Processor、Machine。

  • Goroutine:携带任务,使用 go 关键字创建的执行体,它对应一个结构体 g,结构体里保存了 Goroutine 的堆栈信息,即并发任务状态。G 并非执行体,每个 G 需要绑定到 P 才能被调度执行,而且 G 对象是可以重用的。
  • Processor:分配任务,表示处理器,可以被看做运行在线程上的本地调度器,对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态,任务队列等,有了它才能建立 G、M 的联系。
  • Machine:寻找任务,表示操作系统的线程,OS 线程抽象,负责调度任务,和某个有效的 P 绑定,从 P 的 runq 中不断取出 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 本身不具备执行状态,在需要任务切换时,M 将堆栈状态写回 G,任何其它 M 都能据此恢复执行。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

注意:

  • P 的个数由 GOMAXPROCS 指定,是固定的,因此限制最大并发数。
  • M 的个数是不定的,由 Go Runtime 调整,默认最大限制为 10000 个。

Go 使用协程的几个主要原因:

  • 内核线程创建与切换太重:创建和切换都要进入到内核态,进入到内核态开销较大,性能代价大,而协程切换不需要进入到内核态。
  • 线程内存使用太重:创建一个内核线程默认栈大小为 8M,而创建一个用户线程即 Goroutine 只需要 2K 内存,当 Goroutine 栈不够用时也会自动增加。
  • Goroutine 的调度更灵活,所有协程的调度、切换都发生在用户态,没有创建线程的开销,即使出现某个协程运行阻塞时,线程上的其他协程也会被调度到其他线程上运行。

基本调度过程:

  • 创建一个 G 对象。
  • 将 G 保存至 P 中。
  • P 去唤醒一个 M,然后继续执行它的执行序(分配下一个 G)。
  • M 寻找空闲的 P,读取该 P 要分配的 G。
  • 接下来 M 执行一个调度循环,调用 G → 执行 → 清理线程 → 继续找新的 G 执行。

G - Goroutine

Goroutine 是 Go 语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

G 的调度方式:

  • 抢占式调度:如果某个 G 没有进行系统调用(syscall)、没有进行 IO 操作、没有阻塞在一个 channel 操作上,那么,调度器通过抢占式调度让当前 G 停下来并调度下一个可运行的 G。除非极端的无限循环,否则只要 G 调用函数,Go 运行时就有了抢占 G 的机会。Go 程序启动时,运行时会去启动一个名为 sysmon 的 M(一般称为监控线程),这个 M 的特殊之处在于它不需要绑定 P 就可以运行(以 g0 这个 G 的形式存在)。
  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
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// $GOROOT/src/runtime/proc.go

// Always runs without a P, so write barriers are not allowed.
//
//go:nowritebarrierrec
func sysmon() {
	lock(&sched.lock)
	sched.nmsys++
	checkdead()
	unlock(&sched.lock)

	lasttrace := int64(0)
	idle := 0 // how many cycles in succession we had not wokeup somebody
	delay := uint32(0)

	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
		}
		usleep(delay)

		// sysmon should not enter deep sleep if schedtrace is enabled so that
		// it can print that information at the right time.
		//
		// It should also not enter deep sleep if there are any active P's so
		// that it can retake P's from syscalls, preempt long running G's, and
		// poll the network if all P's are busy for long stretches.
		//
		// It should wakeup from deep sleep if any P's become active either due
		// to exiting a syscall or waking up due to a timer expiring so that it
		// can resume performing those duties. If it wakes from a syscall it
		// resets idle and delay as a bet that since it had retaken a P from a
		// syscall before, it may need to do it again shortly after the
		// application starts work again. It does not reset idle when waking
		// from a timer to avoid adding system load to applications that spend
		// most of their time sleeping.
		now := nanotime()
		if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {
			lock(&sched.lock)
			if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {
				syscallWake := false
				next := timeSleepUntil()
				if next > now {
					atomic.Store(&sched.sysmonwait, 1)
					unlock(&sched.lock)
					// Make wake-up period small enough
					// for the sampling to be correct.
					sleep := forcegcperiod / 2
					if next-now < sleep {
						sleep = next - now
					}
					shouldRelax := sleep >= osRelaxMinNS
					if shouldRelax {
						osRelax(true)
					}
					syscallWake = notetsleep(&sched.sysmonnote, sleep)
					if shouldRelax {
						osRelax(false)
					}
					lock(&sched.lock)
					atomic.Store(&sched.sysmonwait, 0)
					noteclear(&sched.sysmonnote)
				}
				if syscallWake {
					idle = 0
					delay = 20
				}
			}
			unlock(&sched.lock)
		}

		lock(&sched.sysmonlock)
		// Update now in case we blocked on sysmonnote or spent a long time
		// blocked on schedlock or sysmonlock above.
		now = nanotime()

		// trigger libc interceptors if needed
		if *cgo_yield != nil {
			asmcgocall(*cgo_yield, nil)
		}
		// poll network if not polled for more than 10ms
		lastpoll := int64(atomic.Load64(&sched.lastpoll))
		if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
			atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
			list := netpoll(0) // non-blocking - returns list of goroutines
			if !list.empty() {
				// Need to decrement number of idle locked M's
				// (pretending that one more is running) before injectglist.
				// Otherwise it can lead to the following situation:
				// injectglist grabs all P's but before it starts M's to run the P's,
				// another M returns from syscall, finishes running its G,
				// observes that there is no work to do and no other running M's
				// and reports deadlock.
				incidlelocked(-1)
				injectglist(&list)
				incidlelocked(1)
			}
		}
		if GOOS == "netbsd" && needSysmonWorkaround {
			// netpoll is responsible for waiting for timer
			// expiration, so we typically don't have to worry
			// about starting an M to service timers. (Note that
			// sleep for timeSleepUntil above simply ensures sysmon
			// starts running again when that timer expiration may
			// cause Go code to run again).
			//
			// However, netbsd has a kernel bug that sometimes
			// misses netpollBreak wake-ups, which can lead to
			// unbounded delays servicing timers. If we detect this
			// overrun, then startm to get something to handle the
			// timer.
			//
			// See issue 42515 and
			// https://gnats.netbsd.org/cgi-bin/query-pr-single.pl?number=50094.
			if next := timeSleepUntil(); next < now {
				startm(nil, false)
			}
		}
		if scavenger.sysmonWake.Load() != 0 {
			// Kick the scavenger awake if someone requested it.
			scavenger.wake()
		}
		// retake P's blocked in syscalls
		// and preempt long running G's
		if retake(now) != 0 {
			idle = 0
		} else {
			idle++
		}
		// check if we need to force a GC
		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
			lock(&forcegc.lock)
			forcegc.idle = 0
			var list gList
			list.push(forcegc.g)
			injectglist(&list)
			unlock(&forcegc.lock)
		}
		if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
			lasttrace = now
			schedtrace(debug.scheddetail > 0)
		}
		unlock(&sched.sysmonlock)
	}
}
  • channel 阻塞或网络 IO 情况下的调度:

如果 G 被阻塞在某个 channel 操作或网络 IO 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G。如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。当 IO 操作完成或 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable),并被放入到某 P 的队列中,绑定一个 M 后继续执行。

  • 系统调用阻塞情况下的调度:

如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 runnable,之前的那个挂起的 M 将再次进入挂起状态。

P - Processor

调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。

因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上。

M - Machine

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。

在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以在程序中使用 runtime.GOMAXPROCS 来改变最大的活跃线程数。

在默认情况下,一个四核机器会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。

在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。

M 是 Go 代码运行的真实载体,包括 Goroutine 调度器自身的逻辑也是在 M 中运行的。

注意:如果 G 被阻塞在某个 channel 操作或网络 IO 操作上时,M 可以不被阻塞,这避免了大量创建 M 导致的开销。但如果 G 因慢系统调用而阻塞,那么 M 也会一起阻塞,但在阻塞前会与 P 解绑,P 会尝试与其他 M 绑定继续运行其他 G。但若没有现成的 M,Go 运行时会建立新的 M,这也是系统调用可能导致系统线程数量增加的原因。

参考