目录
GMP 模型?
G:Goroutine
G 是 golang 中 goroutine 的缩写。goroutine,也称作协程,它的行为类似于 OS 当中的一个进程控制块,不过协程是完全运行在用户态的,不需要与内核态交互。一个 goroutine 通过结构体 g 进行管理,g 中存放着 goroutine 运行时的栈信息,CPU 一些寄存器的值以及要执行的函数指令。sched
字段的类型是 gobuf
,它保存着 goroutine 的上下文。goroutine 在切换的时候,不依赖 OS 提供上下文保存,而是直接将上下文保存在了 gobuf 这个结构当中:
type g struct {
stack stack // 描述真实的栈内存,包括上下界
m *m // 当前的 m
sched gobuf // goroutine 切换时,用于保存 g 的上下文
param unsafe.Pointer // 用于传递参数,睡眠时其他 goroutine 可以设置 param,唤醒时该goroutine可以获取
atomicstatus uint32
stackLock uint32
goid int64 // goroutine 的 ID
waitsince int64 // g 被阻塞的大体时间
lockedm *m // G 被锁定只在这个 m 上运行
}
gobuf
类型保存了当前栈的指针、计数器以及 g 本身,此处对 g 的指针进行保存的目的是为了能够快速访问到 goroutine 中的信息。gobuf 的结构如下:
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret sys.Uintreg
lr uintptr
bp uintptr // for goEXPERIMENT=framepointer
}
M:Machine
M 代表的是操作系统的主线程,是对内核级线程的封装,其运行的数量对应着运行机器上真实的 CPU 数。一个 M 直接关联一个 OS 的内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了其自身要使用的栈信息、当前正在 M 上执行的 G 的信息,以及与之绑定的 P 的信息。
在 M 的结构定义中,curg
代表结构体 M 当前绑定的结构体 G。g0
是带有调度栈的 goroutine,普通的 goroutine 的栈是在堆上分配的可增长的栈,但 g0
的栈式 M 对应的线程的栈。
type m struct {
g0 *g // 带有调度栈的goroutine
gsignal *g // 处理信号的goroutine
tls [6]uintptr // thread-local storage
mstartfn func()
curg *g // 当前运行的goroutine
caughtsig guintptr
p puintptr // 关联p和执行的go代码
nextp puintptr
id int32
mallocing int32 // 状态
spinning bool // m是否out of work
blocked bool // m是否被阻塞
inwb bool // m是否在执行写屏蔽
printlock int8
incgo bool
fastrand uint32
ncgocall uint64 // cgo调用的总数
ncgo int32 // 当前cgo调用的数目
park note
alllink *m // 用于链接allm
schedlink muintptr
mcache *mcache // 当前m的内存缓存
lockedg *g // 锁定g在当前m上执行,而不会切换到其他m
createstack [32]uintptr // thread创建的栈
}
P:Processor
Processor 代表了 M 所需的上下文环境,代表着 M 运行 G 所需要的资源。P 是处理用户级代码逻辑的处理器,可以将其视作一个局部调度器,使得 go 的代码可以在线程上运行。当 P 有任务时,需要创建或唤醒一个系统线程来执行其队列当中的任务,所以 P 和 M 是互相绑定的(回忆一下,M 对应的是 golang 代码所运行机器的操作系统的线程,而 P 是 M 运行所需要的上下文,因此二者必然是相互绑定的)。
P 可以根据实际情况去开启协程工作,它包含了运行 goroutine 的资源,如果线程像运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
type p struct {
lock mutex
id int32
status uint32 // 状态,可以为pidle/prunning/...
link puintptr
schedtick uint32 // 每调度一次加1
syscalltick uint32 // 每一次系统调用加1
sysmontick sysmontick
m muintptr // 回链到关联的m
mcache *mcache
racectx uintptr
goidcache uint64 // goroutine的ID的缓存
goidcacheend uint64
// 可运行的goroutine的队列
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 下一个运行的g
sudogcache []*sudog
sudogbuf [128]*sudog
palloc persistentAlloc // per-P to avoid mutex
pad [sys.CacheLineSize]byte
}
GMP 的调度流程?
- 每个 P 有一个局部队列,局部队列保存待执行的 goroutine,当 M 绑定的 P 的局部队列已满时,goroutine 会被追加到全局队列当中;
- 每个 P 和一个 M 绑定,M 是真正执行 P 中 goroutine 的实体,M 会从其绑定的 P 的局部队列取 G 来执行;
- 当 M 所绑定的 P 为空时,会从全局队列取 G,当全局队列中无 G 时,M 会从其它 P 队列中偷取 G 来执行,这种从其它 P 截获 G 来提前执行的行为称作 work stealing;
- 当 G 因系统调用阻塞时会阻塞 M,此时 P 和 M 解绑,并寻找新的理想的 M,若没有理想的 M 则会新创建一个 M【goroutine 中的系统调用会阻塞承载它的进程,此时与进程(M)绑定的资源及 goroutine 队列(一并打包为 P)会寻找一个新的线程(M)或是新建一个线程(M)来绑定】;
- 当 G 因 channel 或其他 network I/O 阻塞时,不会阻塞 M,M 会寻找其它可运行的 G 并执行;当阻塞的 G 恢复后,会重新进入其对应的 P 队列等待执行【goroutine 中的 channel 或 network I/O 不会阻塞线程(M),goroutine 自身陷入阻塞态,而 M 继续执行 P 中其它 G。当阻塞的 G 就绪后,重新插入 P 队列】。
P 和 M 的个数?
P:由启动时环境变量$GOMAXPROCS
或runtime
的GOMAXPROCS()
方法决定。这意味着程序在执行的任意时刻都只有$GOMAXPROCS
个 goroutine 在同时运行【当然,等待被调度执行的 goroutine 可能跟多,它们都在 P 的队列中等待】;
M:
- Golang 本身的限制:Golang 程序启动时,会设置 M 的最大数量,默认是 10000,但是内核很难支持这么多的线程同时运行。不过这条限制本身可以忽略;
- runtime/debug 中的 SetMaxThreads 设置 M 的最大数量;
- 一个 M 阻塞后,会创建新的 M。
M 与 P 的数量没有绝对关系,如果一个 M 阻塞,其承载的 P 就会创建一个新的 M,所以,即使 P 的默认数量为 1,也可能对应多个 M。
P 和 M 何时创建?
- P:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P;
- M:当没有足够的 M 来关联 P 时,M 会创建。比如当前时刻所有 M 都阻塞了,而 P 中还有很多就绪的任务,此时就会去寻找空闲的 M,如果没有空闲的 M,就创建新的 M。
goroutine 创建流程?
调用 go func()
时,golang 会调用 runtime.newproc
来创建一个 goroutine,这个 goroutine 会新建一个自己的栈空间,同时在 G 的 sched
(用于维护 goroutine 上下文的字段)中维护栈地址及程序计数器这些上下文信息。
创建好的 goroutine 会被放到其所对应内核线程 M 所绑定的上下文 P 的 run_queue
当中,等待调度器决定何时取出这个 goroutine 并执行,通常的调度顺序是按时间顺序来调度,即 rune_queue
是一个先进先出的队列。
goroutine 何时被挂起?
- waitReasonChanReceiveNilChan:对未初始化的 channel 进行读操作;
- waitReasonChanSendNilChan:对未初始化的 channel 进行写操作;
- 在 main goroutine 发生 panic 时,会触发;
- 在调用关键字 select 时会触发;
- 在调用关键字 select 时,若一个 case 都没有,会直接触发;
- 在 channel 进行读操作,会触发;
- 在 channel 进行写操作,会触发;
- sleep 行为,会触发;
- IO 阻塞等待时,例如:网络请求等;
- 在垃圾回收时,主要场景是 GC 标记终止和标记阶段时触发;
- GC 清扫阶段中的结束行为,会触发;
- 信号量处理结束时,会触发;
同时开启了一万个 goroutine,应该如何调度?
一万个 G 会按照 P 的设定个数,尽可能地平摊到每个 P 的 run_queue
当中。如果 run_queue
都满了,那么剩余的 G 会分配到 GMP 的全局队列上。完成将 G 分配到 P 的队列之后,便开始 GMP 模型的调度策略:
- 本地队列轮转:每一个 P 维护着一个包含 G 的队列
run_queue
,也称本地队列,不考虑 G 进入系统调用或 I/O 的情况下,P 会周期性地将 G 调度到 M 中执行,执行一小段时间后(即并发),保存上下文并将这个 G 追加到队尾,再从对头取一个 G 调度。 - 系统调用:P 的个数默认等于 CPU 的核数,每个 M 必须持有一个 P 才能执行 G,一般 M 的个数会略大于 P,多出的 M 会在 G 产生系统调用时发挥作用。当 G 即将进入系统调用时,对应的 M 陷入系统调用而被阻塞,这将会使 M 释放 P,进而某个空闲的 M1 将获取 P,继续执行 P 队列剩下的 G。
- 工作量窃取:多个 P 的本地队列中维护的 G 的个数可能是不均衡的,当某个 P 已经执行完其队列中的 G 时,会去全局队列查找 G;如果全局队列也没有新的 G,而其它的某个 P 的本地队列还有很多 G 待运行,此时空闲的 P 会从其它 P 中偷一些 G 来执行,一般每次偷取一半。
goroutine 内存泄漏和处理?
原因:
goroutine 又称作协程,它是轻量级线程,由于 goroutine 完全运行在用户态,需要维护 goroutine 运行的上下文信息。因此,如果一个程序持续不断地产生新的 goroutine,且不结束已经创建的 goroutine 并复用这部分内存,就会产生内存泄露现象。产生内存泄露的原因大致分为以下三种:
- goroutine 内正在进行 channel/mutex 读写操作,但由于逻辑产生了问题,某些情况下 goroutine 被一直阻塞;
- goroutine 业务逻辑进入 dead loop,资源一直无法释放;
- goroutine 内的业务逻辑长时间等待,而此时又有不断新增的 goroutine 进入等待。
解决办法:
- 使用 channel:使用 channel 接收业务完成的通知;业务执行阻塞超过设定的时间(可以通过 select 和
time.After()
来实现),就触发超时退出; - 使用 pprof 排查;