聊聊go语言对于协程并发的设计
# 写在文章开头
go语言中的协程按照GMP模型的设计,每个线程都会处理一个协程队列,如何保证协程高效且公平调度成为的go语言高效并发的关键,所以本文会从go语言源码和设计思路的角度来探讨这个问题。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

# 详解协程并发调度
# 协程并发调度流程
在我们之前的文章中就提到过,每一个处理器会绑定一个线程,每个处理器工作签都会从全局队列获取一批协程在进行任务调度存放至本地队列。为了尽可能压榨CPU使用率,当执行的协程主动挂起或者进行系统调用而发生阻塞时,线程就会保存当前协程的程序计数器和栈帧信息将其放回协程队列中,等待下一次schedule轮询队列时再次执行,从而保证的CPU的有效利用避免CPU空转问题。

并且go语言为了保证全局队列中新建的协程能够被及时执行,它设定了从全局队列获取协程的时机,从而保证执行一段时间后会主动到全局队列获取最新协程,这一点笔者会在后文的源码分析中再次提及,这里读者先了解的这个设计思路即可。
# 主动挂起让出CPU
我们先来说说上文所说的主动挂起,对应的源码在proc.go中,其逻辑即确认当前协程确实处于运行中后,调用park_m将当前协程挂起:
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
//原子操作读取当前协程状态,只要处于运行中则调用park_m将协程挂起
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
//......
//调用park_m将协程挂起
mcall(park_m)
}
2
3
4
5
6
7
8
9
10
11
步入park_m即可看到该方法会通过原子操作修改协程状态后,调用schedule再次进行从本地队列获取协程并执行的操作:
func park_m(gp *g) {
mp := getg().m
//......
//通过CAS操作将协程状态设置为等待
casgstatus(gp, _Grunning, _Gwaiting)
//......
//再次回到schedule
schedule()
}
2
3
4
5
6
7
8
9
10
需要注意的是,我们不能直接调用gopark方法,只能通过timeSleep间接调用,这一点我们查看time.go的timeSleep方法的源码即可看到gopark的调用:
func timeSleep(ns int64) {
//......
//timeSleep对于gopark对协程的挂起操作
gopark(resetForSleep, unsafe.Pointer(t), waitReasonSleep, traceEvGoSleep, 1)
}
2
3
4
5
# 系统调用挂起
另一种挂起的情况则是在发起系统级的内核态调用时就会触发,此时协程也会被挂起,每当发起系统调用时协程就会调用proc.go的entersyscall方法记录协程信息,可以看到该方法会通过getcallerpc和getcallersp拿到当前协程程序计数器(pc)和栈帧(sp)然后调用reentersyscall将这些信息保存起来。
完成系统调用后,再调用entersyscall跳转回schedule从本地队列获取其他协程执行:

结合上述的图解我们给出entersyscall的源码,可以看到它通过getcallerpc和getcallersp分别获取当前协程的程序计数器和栈帧:
func entersyscall() {
reentersyscall(getcallerpc(), getcallersp())
}
2
3
4
查看reentersyscall的内部调用即可看到对于协程执行进度的保存逻辑:
func reentersyscall(pc, sp uintptr) {
//拿到当前协程
gp := getg()
//上锁
gp.m.locks++
//......
//记录当前协程的执行进度
save(pc, sp)
//......
//cas修改协程状态
casgstatus(gp, _Grunning, _Gsyscall)
//......
gp.m.locks--
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
保存协程执行进度后,发起系统调用再执行exitsyscall调用exitsyscall0回到schedule方法:
func exitsyscall() {
//获取当前协程
gp := getg()
//上锁
gp.m.locks++ // see comment in entersyscall
if getcallersp() > gp.syscallsp {
throw("exitsyscall: syscall frame is no longer valid")
}
//......
//解锁
gp.m.locks--
// 调用exitsyscall0回到schedule方法
mcall(exitsyscall0)
//......
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
可以看到exitsyscall0逻辑和gopark的park_m差不多,完成CAS修改协程状态为可运行之后,再次调用schedule等待被调度:
func exitsyscall0(gp *g) {
//通过原子操作将协程状态修改为可运行
casgstatus(gp, _Gsyscall, _Grunnable)
//......
//调用schedule
schedule()
}
2
3
4
5
6
7
8
# 全局队列饥饿问题
经过上文的讲解我们了解了协程并发调度的设计和实现,最后我们再来看看go语言如何避免全局队列的饥饿问题,查看schedule方法的某段核心代码可以看到,其内部会schedule调度次数是否超61,若超过这一数值则从全局队列获取新建协程避免全局饥饿问题:
//操作61此则从全局队列获取协程
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
//从全局队列获取协程
gp := globrunqget(pp, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
2
3
4
5
6
7
8
9
10
# 小结
以上便是笔者关于go语言是如何实现协程并发高效执行原因的剖析,希望对你有帮助。
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
