一个使用golang 协程的坑
在使用go语言协程的时候,大家肯定都踩过如下的坑,比如,下面这段代码的运行结果会是我们期望的10么?
func main(){
runtime.GOMAXPROCS(1)
var cnt int
for i := 0;i<10;i++{
go func(j int){
cnt++
}(i)
}
fmt.Println(cnt)
}
这段代码的运行结果是:0,和我们期望的10不一样,这个应该大家都猜到了。原因是由于还没等子协程调度执行,main 程序就已经执行完退出了,所以最终打印出来的结果是0。我们可以使用runtime.Gosched()来主动让出CPU,进而调度其他协程来增加cnt的值:
func main(){
runtime.GOMAXPROCS(1)
var cnt int
for i := 0;i<10;i++{
go func(j int){
cnt++
}(i)
}
runtime.Gosched()
fmt.Println(cnt)
}
这次运行的结果确实是我们所期望的那样,执行main()函数的协程等待所有的子协程执行完了之后才打印输出。但是好奇的同学肯定会想,runtime.Gosched()到底是如何发挥作用的,调度器会等到所有的子协程都执行完了再调度main协程运行么?我们接着往下看。
当尝试把10改成20、30、40、50、60的时候,输出的结果都是对应的值。然后我们尝试继续增加创建的子goroutine的数目,可以看到当大于60的时候,输出的结果都是61。61这个数字为什么这么特殊??这个就关系到接下来要讲的goroutine调度机制了。
Goroutine调度原理
当协程主动调用runtime.Gosched()的过程中,会先调用mcall(),从当前的正在执行的goroutine g转向调度器goroutine g0(g0操作的是内核栈),然后将g的PC和SP 寄存器的值保存到g->sched域中,这样当g再被调度的时候,可以使用保存的PC和SP的值来恢复执行的上下文。然后接着mcall()会调用gosched_m(g)来做一些操作。
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
我们接下来看看gosched_m做了什么
func gosched_m(gp *g) {
if trace.enabled {
traceGoSched()
}
goschedImpl(gp)
}
trace是和goroutine 运行时追踪相关的部分,在这里不做主要关注,接下来就调用了goschedImpl(g):
func goschedImpl(gp *g) {
status := readgstatus(gp)
if status&^_Gscan != _Grunning {
dumpgstatus(gp)
throw("bad g status")
}
casgstatus(gp, _Grunning, _Grunnable)
dropg()
lock(&sched.lock)
globrunqput(gp)
unlock(&sched.lock)
schedule()
}
所做的操作如下:
- 首先获取g的状态,如果g的状态不是Grunning的话则抛出异常结束。
- 调用casgstatus(gp, _Grunning, _Grunnable)用于确保不在GC执行的前提下把g的状态从Grunning修改为Grunnable,即从正在运行的状态改为待运行的状态。
- 接着调用dropg()来将g与工作线程M解绑。
- 然后调用globrunqput()来把g放到全局的可运行goroutine队列中,所以这个地方要加锁。
- 然后就调用schedule()函数来找一个可运行的Goroutine来放到当前的工作线程M中运行。
摘取了一些schedule()函数比较关键的部分,来看看它做了什么
func schedule() {
//一些检查
...
top:
...
if gp == nil {
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
gp, inheritTime = runqget(_g_.m.p.ptr())
// We can see gp != nil here even if the M is spinning,
// if checkTimers added a local goroutine via goready.
}
if gp == nil {
gp, inheritTime = findrunnable() // blocks until work is available
}
...
execute(gp, inheritTime)
}
shedule()函数关于获取goroutine部分的的处理流程是:
- 当前的P累计处理的goroutine的数目是61的倍数,为了保证全局队列中的goroutine 不会被饿死,就会去sched的全局goroutine 队列中取一个可运行的 G。
- 如果上一步没取到,从P的本地队列中获取一个可执行的P。
- 如果也没有取到的话会尝试从其他P处偷一些goroutine来执行。然后调用execute()来执行获取到的goroutine。
总结
这样,上面提出的那个问题的答案就很清晰了,在程序的一开始我们通过 runtime.GOMAXPROCS(1) 来设置P的个数为1,当main 调用runtime.Sched() 让出CPU时,当前协程对应的G会被放到全局队列中,然后会调度for循环中创建的协程来运行,这些协程都放在P的本地队列中,当调度了61个协程后,就会去全局队列中看一下把main 函数协程取出来执行,所以main函数执行fmt.Println退出后打印的值是61。