通过一个常见的坑来了解goroutine调度

文章讨论了在Go语言中使用协程时遇到的一个常见问题,即主协程在所有子协程执行完毕前结束导致结果错误。通过解析Goroutine调度原理,解释了runtime.Gosched()的作用以及为何在超过一定数量后输出结果固定。
摘要由CSDN通过智能技术生成

一个使用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 g0g0操作的是内核栈),然后将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()
}

所做的操作如下:

  1. 首先获取g的状态,如果g的状态不是Grunning的话则抛出异常结束。
  2. 调用casgstatus(gp, _Grunning, _Grunnable)用于确保不在GC执行的前提下把g的状态从Grunning修改为Grunnable,即从正在运行的状态改为待运行的状态。
  3. 接着调用dropg()来将g与工作线程M解绑。
  4. 然后调用globrunqput()来把g放到全局的可运行goroutine队列中,所以这个地方要加锁。
  5. 然后就调用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部分的的处理流程是:

  1. 当前的P累计处理的goroutine的数目是61的倍数,为了保证全局队列中的goroutine 不会被饿死,就会去sched的全局goroutine 队列中取一个可运行的 G
  2. 如果上一步没取到,从P的本地队列中获取一个可执行的P
  3. 如果也没有取到的话会尝试从其他P处偷一些goroutine来执行。然后调用execute()来执行获取到的goroutine。

总结

这样,上面提出的那个问题的答案就很清晰了,在程序的一开始我们通过 runtime.GOMAXPROCS(1) 来设置P的个数为1,当main 调用runtime.Sched() 让出CPU时,当前协程对应的G会被放到全局队列中,然后会调度for循环中创建的协程来运行,这些协程都放在P的本地队列中,当调度了61个协程后,就会去全局队列中看一下把main 函数协程取出来执行,所以main函数执行fmt.Println退出后打印的值是61。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值