本文使用 golang 1.17 代码,如有任何问题,还望指出。

线程、内核线程和用户线程区别

task_structpthread_create()clone()

三者的关系如下所示:

在 Golang 中 goroutine 与线程的关系如下所示:

Golang 程序启动时首先会创建进程,然后创建主线程,主线程会执行 runtime 初始化的一些代码,包括调度器的初始化,然后会启动调度器,调度器会不断寻找需要运行的 goroutine 与内核线程绑定运行。

Golang 使用协程的原因

操作系统中虽然已经有了多线程、多进程来解决高并发的问题,但是在当今互联网海量高并发场景下,对性能的要求也越来越苛刻,大量的进程/线程会出现内存占用高、CPU消耗多的问题,很多服务的改造与重构也是为了降本增效。

一个进程可以关联多个线程,线程之间会共享进程的一些资源,比如内存地址空间、打开的文件、进程基础信息等,每个线程也都会有自己的栈以及寄存器信息等,线程相比进程更加轻量,而协程相对线程更加轻量,多个协程会关联到一个线程,协程之间会共享线程的一些信息,每个协程也会有自己的栈空间,所以也会更加轻量级。从进程到线程再到协程,其实是一个不断共享,减少切换成本的过程。

Golang 使用协程主要有以下几个原因:

  • (1)内核线程创建与切换太重的问题:创建和切换都要进入到内核态,进入到内核态开销较大,性能代价大,而协程切换不需要进入到内核态;

  • (2)线程内存使用太重:创建一个内核线程默认栈大小为8M,而创建一个用户线程即 goroutine 只需要 2K 内存,当 goroutine 栈不够用时也会自动增加;

  • (3)goroutine 的调度更灵活,所有协程的调度、切换都发生在用户态,没有创建线程的开销,即使出现某个协程运行阻塞时,线程上的其他协程也会被调度到其他线程上运行;

Goroutine 在进程内存空间中的分布

协程的本质其实就是可以被暂停以及可以被恢复运行的函数,创建一个 goroutine 时会在进程的堆区中分配一段空间,这段空间是用来保存协程栈区的,当需要恢复协程的运行时再从堆区中出来复制出来恢复函数运行时状态。

GPM 模型分析

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
commit b2cdf30eb6c4a76504956aaaad47df969274296b
Author: Russ Cox <rsc@golang.org>
Date: Tue Nov 11 17:08:33 2014 -0500

[dev.cc] runtime: convert scheduler from C to Go



commit 15ced2d00832dd9129b4ee0ac53b5367ade24c13
Author: Russ Cox <rsc@golang.org>
Date: Tue Nov 11 17:06:22 2014 -0500

[dev.cc] runtime: convert assembly files for C to Go transition

The main change is that #include "zasm_GOOS_GOARCH.h"
is now #include "go_asm.h" and/or #include "go_tls.h".

Also, because C StackGuard is now Go _StackGuard,
the assembly name changes from const_StackGuard to
const__StackGuard.

In asm_$GOARCH.s, add new function getg, formerly
implemented in C.

本文主要介绍当前调度器中的 GPM 模型,首先了解下 GPM 模型中三个组件的作用与联系:

go

G 运行时需要与 M 进行绑定,M 需要与 P 绑定,M 在数量上并不与 P 相等,这是因为 M 在运行 G 时会陷入系统调用或者因其他事情会被阻塞,M 不够用时在 runtime 中会创建新的 M,因此随着程序的执行,M 的数量可能增长,而 P 在没有用户干预的情况下,则会保持不变,G 的数量是由用户代码决定的。

GPM 三者的关联如下所示:

9

GOMAXPROCS

GPM 生命周期

1、P 的生命周期

P 对象的结构体如下所示:

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
type p struct {
id int32
status uint32 // P 的状态
link puintptr
schedtick uint32 // 被调度次数
syscalltick uint32 // 执行过系统调用的次数
sysmontick sysmontick // sysmon 最近一次运行的时间
m muintptr // P 关联的 M
mcache *mcache // 小对象缓存,可以无锁访问
pcache pageCache // 页缓存,可以无锁访问
raceprocctx uintptr // race相关

// 与 defer 相关
deferpool [5][]*_defer
deferpoolbuf [5][32]*_defer

// goroutine ids 的缓存
goidcache uint64
goidcacheend uint64

// P 本地 G 队列,可以无锁访问
runqhead uint32 // 本地队列头
runqtail uint32 // 本地队尾
runq [256]guintptr // 本地 G 队列,使用数组实现的循环队列
runnext guintptr // 待运行的 G,优先级高于 runq

// 已运行结束的 G (状态为 Gdead)会被保存在 gFree 中,方便实现对 G 的复用
gFree struct {
gList
n int32
}

sudogcache []*sudog
sudogbuf [128]*sudog

mspancache struct {
len int
buf [128]*mspan
}

tracebuf traceBufPtr
traceSweep bool
traceSwept, traceReclaimed uintptr

palloc persistentAlloc
_ uint32

timer0When uint64

timerModifiedEarliest uint64

// 与 GC 相关的
gcAssistTime int64
gcFractionalMarkTime int64
gcMarkWorkerMode gcMarkWorkerMode
gcMarkWorkerStartTime int64
gcw gcWork
wbBuf wbBuf

......

// 抢占标记
preempt bool
}

(1) 为什么需要 P ?

在 Golang 1.1 版本之前调度器中还没有 P 组件,此时调度器的性能还比较差,社区的 Dmitry Vyukov 大佬针对当前调度器中存在的问题进行了总结并设计引入 P 组件来解决当前面临的问题(Scalable Go Scheduler Design Doc),并在 Go 1.1 版本中引入了 P 组件,引入 P 组件后不仅解决了文档中列的几个问题,也引入了一些很好的机制。

文档中列出了调度器当前主要有 4 个问题,主要有:

sched.Locksched
  • 2、G 切换问题:M 频繁切换可运行的 G 会增加延迟和开销,比如新建的 G 会被被放到全局队列,而不是在 M 本地执行,这会导致不必要的开销和延迟,应该优先在创建 G 的 M 上执行就可以;

    在引入 P 组件后,新建的 G 会优先放在 G 关联 P 的本地队列中。

M.mcachemcachemcachemcachemcachemcachemcachemcachemcachemcachemcachemcachemcache
runtime.GOMAXPROCS()

(2) P 的新增逻辑

GOMAXPROCSruntime.GOMAXPROCS()
sysmonsysmonsysmonGOMAXPROCS

(3) P 的销毁逻辑

GOMAXPROCschedt.pidleGOMAXPROCp.destroy()_Pdead

(4) P 的状态

_Pidle_Prunning_Psyscall_Pgcstop_PdeadGOMAXPROCS_Pdead

2、M 的生命周期

M 对象的的结构体为:

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
type m struct {
// g0 记录工作线程(也就是内核线程)使用的栈信息,在执行调度代码时需要使用
g0 *g

morebuf gobuf // 堆栈扩容使用
......

gsignal *g // 用于信号处理
......

// 通过 tls (线程本地存储)结构体实现 m 与工作线程的绑定
tls [tlsSlots]uintptr

mstartfn func() // 表示m启动时立即执行的函数
curg *g // 指向正在运行的 goroutine 对象
caughtsig guintptr
p puintptr // 当前 m 绑定的 P
nextp puintptr // 下次运行时的P
oldp puintptr // 在执行系统调用之前绑定的P
id int64 // m 的唯一id
mallocing int32
throwing int32
preemptoff string // 是否要保持 curg 始终在这个 m 上运行
locks int32
dying int32
profilehz int32
spinning bool // 为 true 时表示当前 m 处于自旋状态,正在从其他线程偷工作
blocked bool // m 正阻塞在 note 上
newSigstack bool
printlock int8
incgo bool // 是否在执行 cgo 调用
freeWait uint32
fastrand [2]uint32
needextram bool
traceback uint8

// cgo 调用计数
ncgocall uint64
ncgo int32
cgoCallersUse uint32
cgoCallers *cgoCallers

// 没有 goroutine 需要运行时,工作线程睡眠在这个 park 成员上,
// 其它线程通过这个 park 唤醒该工作线程
doesPark bool
park note

alllink *m // 记录所有工作线程的链表
......

startingtrace bool
syscalltick uint32 // 执行过系统调用的次数
freelink *m

......
preemptGen uint32 // 完成的抢占信号数量
......
}

(1) M 的新建

clone()debug.SetMaxThreads(n)

在以下两种场景下会新建 M:

_Gwaiting_Grunningstartm()sched.midlenewm()

(2) M 的销毁

stopm()stopm()
spinningsched.nmspinning < (procs- sched.npidle)/2exitsyscall()stopm()
stopm()sched.midle

(3) M 的运行

entersyscall()m.oldpexitsyscall()m.oldp

(4) M0 的作用以及与其他线程关联的 M 区别?

src/runtime/proc.gonew(m)

(5) 为什么要限制 M 的数量?

schedinit()sched.maxmcount = 10000

为什么要限制 M 的数量?在重构调度器的文章中 Potential Further Improvements 一节,Dmitry Vyukov 大佬已经提到过要限制 M 的数量了,在高并发或者反复会创建大量 goroutine 的场景中,需要更多的线程去执行 goroutine,线程过多时会耗尽系统资源或者触发系统的限制导致程序异常,内核在调度大量线程时也要消耗额外的资源,限制 M 的数量主要是防止程序不合理的使用。

Linux 上每个线程栈大小默认为 8M,如果创建 10000 个线程默认需要 78.125 G 内存,对普通程序来说内存使用量已经非常大了,此外,Linux 上下面这三个内核参数的大小也会影响创建线程的上限:

  • /proc/sys/kernel/threads-max:表示系统支持的最大线程数;
  • /proc/sys/kernel/pid_max:表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败;
  • /proc/sys/vm/max_map_count:表示限制一个进程可以拥有的 VMA(虚拟内存区域)的数量;

(6) M 的状态

通过 M 的新建和销毁流程的分析,M 有三种状态:运行、自旋、睡眠,这三种状态之间的转换如下所示:

3、G 的生命周期

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
type g struct {
// 当前 Goroutine 的栈内存范围
stack stack
stackguard0 uintptr
stackguard1 uintptr

_panic *_panic // 当前 g 中与 panic 相关的处理
_defer *_defer // 当前 g 中与 defer 相关的处理
m *m // 绑定的 m

// 存储当前 Goroutine 调度相关的数据,上下方切换时会把当前信息保存到这里
sched gobuf

......

param unsafe.Pointer // 唤醒G时传入的参数
atomicstatus uint32 // 当前 G 的状态
stackLock uint32
goid int64 // 当前 G 的 ID
schedlink guintptr
waitsince int64 // G 阻塞时长
waitreason waitReason // 阻塞原因

// 抢占标记
preempt bool
preemptStop bool
preemptShrink bool


asyncSafePoint bool
paniconfault bool
gcscandone bool
throwsplit bool

// 表示是否有未加锁定的channel指向到了g 栈
activeStackChans bool

// 表示g 是放在chansend 还是 chanrecv,用于栈的收缩
parkingOnChan uint8

raceignore int8 // ignore race detection events
sysblocktraced bool // StartTrace has emitted EvGoInSyscall about this goroutine
tracking bool // whether we're tracking this G for sched latency statistics
trackingSeq uint8 // used to decide whether to track this G
runnableStamp int64 // timestamp of when the G last became runnable, only used when tracking
runnableTime int64 // the amount of time spent runnable, cleared when running, only used when tracking
sysexitticks int64 // cputicks when syscall has returned (for tracing)
traceseq uint64 // trace event sequencer
tracelastp puintptr // last P emitted an event for this goroutine
lockedm muintptr
sig uint32
writebuf []byte
sigcode0 uintptr
sigcode1 uintptr
sigpc uintptr
gopc uintptr // goroutine 当前运行函数的 PC 值
ancestors *[]ancestorInfo // ancestor information goroutine(s) that created this goroutine (only used if debug.tracebackancestors)
startpc uintptr // 触发这个 goroutine 的函数的 PC 值
racectx uintptr
waiting *sudog // sudog structures this g is waiting on (that have a valid elem ptr); in lock order
cgoCtxt []uintptr // cgo traceback context
labels unsafe.Pointer // profiler labels
timer *timer // cached timer for time.Sleep
selectDone uint32 // are we participating in a select and did someone win the race?

// GC 时存储当前 Goroutine 辅助标记的对象字节数
gcAssistBytes int64
}

(1) G 的新建

newproc()
runqput()runnextrunnextrunnextrunnextrunqsched.runqrunnextrunnextrunqrunqatomic.LoadAcqatomic.CasRel

(2) G 的销毁

goexit()_Grunning_Gdeadgfput()gFreegFreesched.gFree

(3) G 的 运行

gobufgogo()

(4) G 有哪些状态?

src/runtime/runtime2.go

每种转态的作用以及状态之间的转换关系如下所示:

_Gidle_Grunnable_Grunning_Gsyscall_Gwaiting_Gdead_Gcopystack_Gpreempted_Gscan

4、g0 的作用
1
2
3
4
type m struct {
g0 *g // goroutine with scheduling stack
......
}

在 runtime 中有两种 g0,一个是 m0 关联的 g0,另一种是其他 m 关联的 g0,m0 关联的 g0 是以全局变量的方式定义的,其内存空间是在系统的栈上进行分配的,大小为 64K - 104 字节,其他 m 关联的 g0 是在堆上分配的栈,默认为 8K。

src/runtime/proc.go#1879
1
2
3
4
5
6
if iscgo || mStackIsSystemAllocated() {
mp.g0 = malg(-1)
} else {
// sys.StackGuardMultiplier 在 linux 系统上值为 1
mp.g0 = malg(8192 * sys.StackGuardMultiplier)
}

每次启动一个 M 时,创建的第一个 goroutine 就是 g0,每个 M 都会有自己的 g0,g0 主要用来记录工作线程使用的栈信息,仅用于负责调度,在执行调度代码时需要使用这个栈。执行用户 goroutine 代码时,使用用户 goroutine 的栈,调度时会发生栈的切换。

systemstack()systemstack()nosplitnosplit

总结

本文主要分析了 Golang GPM 的模型,在阅读 runtime 代码的过程中发现代码中有很多细节需要花大量时间分析,文中仅对其大框架做了一些简单的说明,也有部分细节顺便被带入,在后面的文章中,会对许多细节再次进行分析。

参考:

https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit (Scalable Go Scheduler Design Doc)
https://docs.google.com/document/d/1flyIICFZV_kMfypiaghcZx0BLIC-aIooSALo1S6ZJIY/edit (dev.cc branch plan)
https://learnku.com/articles/41728
https://yizhi.ren/2019/06/03/goscheduler/
https://colobu.com/2020/12/20/threads-in-go-runtime/
https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/mpg/
https://github.com/golang/go/wiki/DesignDocuments
https://github.com/golang/proposal
http://www1.cs.columbia.edu/~aho/cs6998/reports/12-12-11_DeshpandeSponslerWeiss_GO.pdf
https://zhuanlan.zhihu.com/p/339837580
https://www.jianshu.com/p/67b0cb8e8bdc
https://golang.design/go-questions/sched/gpm/
https://github.com/MrYueQ/go-under-the-hood/blob/master/content/4-sched/exec.md
https://hjlarry.github.io/docs/go/goroutine/
https://www.jianshu.com/p/1a50330adf1b