1.Go1.14持续输出
我们来看这样一段代码,只是创建了一个不断输出递增数值的协程,然后 用一个空的for循环等待,接下来我们就通过这段代码来理解,
go语言的抢占式调度按理说这个程序运行起来应该会不断的输出递增数字,实际上在Go1.14以及以后的版本中确实如此,
2.Go1.13阻塞
但是在Go1.13的时候,它居然阻塞了,运行环境是双核CPU,执行top命令查看,发现程序仍处在运当中,却没有再继续输出,看来是负责输出数字的goroutine阻塞了,下面我们通过go语言的调试工具dlv来调试一下,
2.分析Go1.13阻塞
首先拿到程序对应的进程ID,dlv attack到当前进程,
通过grs查看一下当前所有的协程,
1号goroutine对应的main.go的12行,也就是这个空的for 循环,
剩下的协程里只有7号协程是我们创建的,它当前停在了main.go 第九行,也就是说fmt.printn这里阻塞了。在1号协程前面有一个小星星,代表当前调试工具绑定到了1号协程
下面输入"gr 6"与"grs"切换到6号协程查看一下栈回溯,看看究竟阻塞在哪里,即通过gr命令切换到六号协程。
再通过bt命令看一下栈回溯,
可以看到实际阻塞发生在runtime.futex 这里,再往上倒,会找到runtime,gcStart,阻塞发生在gcstart所在文件的1287行,定位到这一行,发现它是在执行stop the world(STW)时发生了阻塞。
我们在介绍go 语言GC时已经知道GC开始前需要stop the world(SWT) 来进行开启写屏障等准备工作。所以stop the world (SWT)就是要抢占所有的P,让他们暂时放下手里的活,让GC得以正常工作。
而我们的程序中1号协程没能被抢占,它一直在执行。而stop the world(SWT)一直在等待它让出,这样就陷入了僵局。下面我们就来看看为何程序会陷入这样的局面。
首先我们来梳理一下stop the world (SWT)的主要逻辑。GC 需要抢占所有的P,但这不是值日生喊一嗓子就能清场的问题,所以它会记录下自己要等待多少个P让出(sched.stopwait=gomaxprocs)。当这个值减为0,目的就达到了。
对于当前P,以及陷入系统调用中的P(Psyscall),还有空闲状态的P, 直接将它们设置为_Pgcstop状态即可。
对于还有G在运行的P, 则会将其对应的g.stackguard0设置为一个特殊标识(runtime.stackPreempt),告诉它GC 正在等待你让出呢。
此外,还会设置一个gcwaiting标识(sched.gcwaiting=1),接下来就通过这两个标识符的配合来实现运行中P的强占。怎么实现呢?
我们知道goroutine创建之初,栈的大小是固定的。为了防止出现栈溢出的情况,编译器会在有明显栈消耗的函数头部插入一些检测代码。通过g.stackguard0来判断是否需要进行栈增长。但如果这一点g.stackguard0被设置为特殊标识runtime.stackPreempt, 便不会去执行栈增长,而是执行一次调度(schedule())。
在调度执行时会检测gcwaiting标识,
若发现GC在等待执行,便会让出当前P, 将其置为_Pgcstop状态。
这样看来,1号协程之所以没能让出,是因为空的for循环并没有调用函数,也就没机会执行栈增长检测代码。所以他并不知道GC 在等待他让出,这是Go1.13中的情况。依赖栈增长检测代码的方式,不算是真正的抢占式调度。不过,在1.14中,我们迎来了真正意义上的抢占式调度。