本文是《Go语言调度器源代码情景分析》系列的第20篇,也是第五章《主动调度》的第1小节。
Goroutine的主动调度是指当前正在运行的goroutine通过直接调用runtime.Gosched()函数暂时放弃运行而发生的调度。
主动调度完全是用户代码自己控制的,我们根据代码就可以预见什么地方一定会发生调度。比如下面的程序,在main goroutine中创建了一个新的我们称之为g2的goroutine去执行start函数,g2在start函数的循环中反复调用Gosched()函数放弃自己的执行权,主动把CPU让给调度器去执行调度。
package main import ( "runtime" "sync" ) const N = 1 func main() { var wg sync.WaitGroup wg.Add(N) for i := 0; i < N; i++ { go start(&wg) } wg.Wait() } func start(wg *sync.WaitGroup) { for i := 0; i < 1000 * 1000 * 1000; i++ { runtime.Gosched() } wg.Done() }
下面我们就从这个程序开始分析主动调度是如何实现的。
首先从主动调度的入口函数Gosched()开始分析。
runtime/proc.go : 262
// Gosched yields the processor, allowing other goroutines to run. It does not // suspend the current goroutine, so execution resumes automatically. func Gosched() { checkTimeouts() //amd64 linux平台空函数 //切换到当前m的g0栈执行gosched_m函数 mcall(gosched_m) //再次被调度起来则从这里开始继续运行 }
因为我们需要关注程序运行起来之后g2 goroutine的状态,所以这里用gdb配合源代码一起来进行调试和分析,首先使用b proc.go:266在Gosched函数的mcall(gosched_m)这一行设置一个断点,然后运行程序,等程序被断下来之后,反汇编一下程序当前正在执行的函数
(gdb) disass Dump of assembler code for function main.start: 0x000000000044fc90 <+0>:mov %fs:0xfffffffffffffff8,%rcx 0x000000000044fc99 <+9>:cmp 0x10(%rcx),%rsp 0x000000000044fc9d <+13>:jbe 0x44fcfa <main.start+106> 0x000000000044fc9f <+15>:sub $0x20,%rsp 0x000000000044fca3 <+19>:mov %rbp,0x18(%rsp) 0x000000000044fca8 <+24>:lea 0x18(%rsp),%rbp 0x000000000044fcad <+29>:xor %eax,%eax 0x000000000044fcaf <+31>:jmp 0x44fcd0 <main.start+64> 0x000000000044fcb1 <+33>:mov %rax,0x10(%rsp) 0x000000000044fcb6 <+38>:nop 0x000000000044fcb7 <+39>:nop => 0x000000000044fcb8 <+40>:lea 0x241e1(%rip),%rax # 0x473ea0 0x000000000044fcbf <+47>:mov %rax,(%rsp) 0x000000000044fcc3 <+51>:callq 0x447380 <runtime.mcall> 0x000000000044fcc8 <+56>:mov 0x10(%rsp),%rax 0x000000000044fccd <+61>:inc %rax 0x000000000044fcd0 <+64>:cmp $0x3b9aca00,%rax 0x000000000044fcd6 <+70>:jl 0x44fcb1 <main.start+33> 0x000000000044fcd8 <+72>:nop 0x000000000044fcd9 <+73>:mov 0x28(%rsp),%rax 0x000000000044fcde <+78>:mov %rax,(%rsp) 0x000000000044fce2 <+82>:movq $0xffffffffffffffff,0x8(%rsp) 0x000000000044fceb <+91>:callq 0x44f8f0 <sync.(*WaitGroup).Add> 0x000000000044fcf0 <+96>:mov 0x18(%rsp),%rbp 0x000000000044fcf5 <+101>:add $0x20,%rsp 0x000000000044fcf9 <+105>:retq 0x000000000044fcfa <+106>:callq 0x447550 <runtime.morestack_noctxt> 0x000000000044fcff <+111>:jmp 0x44fc90 <main.start>
可以看到当前正在执行的函数是main.start而不是runtime.Gosched,在整个start函数中都找不到Gosched函数的身影,原来它被编译器优化了。程序现在停在了0x000000000044fcb8 <+40>: lea 0x241e1(%rip),%rax 这一指令处,该指令下面的第二条callq指令在调用runtime.mcall,我们首先使用si 2来执行2条汇编指令让程序停在下面这条指令处:
=> 0x000000000044fcc3 <+51>: callq 0x447380 <runtime.mcall>
然后使用i r rsp rbp rip记录一下CPU的rsp、rbp和rip寄存器的值备用:
(gdb) i r rsprbprip rsp 0xc000031fb0 0xc000031fb0 rbp 0xc000031fc8 0xc000031fc8 rip 0x44fcc3 0x44fcc3 <main.start+51>
继续看0x000000000044fcc3位置的callq指令,它首先会把紧挨其后的下一条指令的地址0x000000000044fcc8放入g2的栈,然后跳转到mcall函数的第一条指令开始执行。回忆一下第二章我们详细分析过的mcall函数的执行流程,结合现在这个场景,mcall将依次完成下面几件事:
-
把上面call指令压栈的返回地址0x000000000044fcc8取出来保存在g2的sched.pc字段,把上面我们查看到的rsp(0xc000031fb0)和rbp(0xc000031fc8)分别保存在g2的sched.sp和sched.bp字段,这几个寄存器代表了g2的调度现场信息;
-
把保存在g0的sched.sp和sched.bp字段中的值分别恢复到CPU的rsp和rbp寄存器,这样完成从g2的栈到g0的栈的切换;
-
在g0栈执行gosched_m函数(gosched_m函数是runtime.Gosched函数调用mcall时传递给mcall的参数)。
继续看gosched_m函数
runtime/proc.go : 2623
// Gosched continuation on g0. func gosched_m(gp *g) { if trace.enabled { //traceback 不关注 traceGoSched() } goschedImpl(gp) //我们这个场景:gp = g2 }
gosched_m函数只是简单的在调用goschedImpl:
runtime/proc.go : 2608
func goschedImpl(gp *g) { ...... casgstatus(gp, _Grunning, _Grunnable) dropg() //设置当前m.curg = nil, gp.m = nil lock(&sched.lock) globrunqput(gp) //把gp放入sched的全局运行队列runq unlock(&sched.lock) schedule() //进入新一轮调度 }
goschedImpl函数有一个g指针类型的形参,我们这个场景传递给它的实参是g2,goschedImpl函数首先把g2的状态从_Grunning设置为_Grunnable,并通过dropg函数解除当前工作线程m和g2之间的关系(把m.curg设置成nil,把g2.m设置成nil),然后通过调用我们已经分析过的globrunqput函数把g2放入全局运行队列之中。
g2被挂入全局运行队列之后,g2以及其它一些相关部分的状态和关系如下图所示:
从上图我们可以清晰的看到,g2被挂在了sched的全局运行队列里面,该队列有一个head头指针指向队列中的第一个g对象,还有一个tail尾指针指向队列中的最后一个g对象,队列中各个g对象通过g的schedlink指针成员相互链接起在一起;g2的sched结构体成员中保存了调度所需的所有现场信息(比如栈寄存器sp和bp的值,pc指令寄存器的值等等),这样当g2下次被schedule函数调度时,gogo函数会负责把这些信息恢复到CPU的rsp, rbp和rip寄存器中,从而使g2又得以从0x44fcc8地址处开始在g2的栈中执行g2的代码。
把g2挂入全局运行队列之后,goschedImpl函数继续调用schedule()进入下一轮调度循环,至此g2通过自己主动调用Gosched()函数自愿放弃了执行权,达到了调度的目的。