前言
在上一篇了解协程里讲了协程与线程的构造,本篇幅则是介绍协程是如何在单线程上运行的。
单线程循环结构
单线程循环结构的原理大致为,线程一直在跑一些函数,每一轮循环都会跳到一个协程并执行,执行完后又跳回线程继续循环,循环往复,其效果似协程在线程上运行一样。
下图为运行流程,自顶向下看
- 线程从本地或全局搜索协程,拿到协程后会更新记录一些协程信息。
- 跳转到协程的位置执行代码。
- 又退出协程重新回到循环中。
以下讲解线程的每个流程。
schedule()
schedule方法作用是找到一个可运行的协程并执行这个协程,该方法从不停止。
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
...
var gp *g // 新建一个协程
// 多次从本地或全局队列中获取协程
...
execute(gp, inheritTime)
}
execute()
线程进入execute后,更新了gp协程的一些字段,如curg:当前运行的协程。
func execute(gp *g, inheritTime bool) {
_g_ := getg()
// 更新了gp协程的一些字段
_g_.m.curg = gp
gp.m = _g_.m
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
gp.stackguard0 = gp.stack.lo + _StackGuard
if !inheritTime {
_g_.m.p.ptr().schedtick++
}
...
gogo(&gp.sched)
}
注意,此时由g0协程记录线程的运行信息。
这里回忆下g结构体的构造。
type g struct {
stack stack // 记录了协程的高地址和低地址
sched gobuf // 记录程序运行时的信息
atomicstatus uint32 // 协程的状态
goid int64 // 协程的id号
...
}
具体细节如图
stack中lo,hi为高低地址gobuf中sp是栈指针,pc记录协程运行到了哪一行
gogo()
execute最后跳转到gogo方法,我们通过点击gogo()跳转后发现,gogo()方法只写个函数声明,说明这个方法是使用汇编来实现的。
func gogo(buf *gobuf)
这时使用ctrl+shift+f在目录中搜索gogo(SB),则跳出好几个方法,存在于不同文件中,说明Go针对不同的平台实现了不同的gogo方法。
我们选择asm_amd64.s文件,找到gogo方法如下
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), BX // gobuf
MOVQ gobuf_g(BX), DX
MOVQ 0(DX), CX // make sure g != nil
JMP gogo<>(SB)
这里面的SB参数是什么呢,其指的是一个gobuf的指针,而gobuf结构体有两个重要的字段。
StackPointer:即sp,记录协程的栈地址。ProgramPointer:即pc,记录协程运行到了哪一行。
了解了gobuf呢,我们接着看里面的代码,发现用了JMP gogo<>(SB)跳到另一个方法中,具体方法如下:
TEXT gogo<>(SB), NOSPLIT, $0
get_tls(CX)
MOVQ DX, g(CX)
MOVQ DX, R14 // set the g register
MOVQ gobuf_sp(BX), SP // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ $0, gobuf_sp(BX) // clear to help garbage collector
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
MOVQ gobuf_pc(BX), BX
JMP BX
该方法只需关心两个重点。
- 人为的插入了一个
goexit()方法
MOVQ gobuf_sp(BX), SP // restore SP,这行代码作用是往这个协程栈中人为的插入了一个goexit()方法。
为什么能插入呢?因为通过gobuf知道了协程栈的地址,便能往里边插入goexit方法了。
于是协程栈就变成如下所示:
- 跳转到协程中运行:
MOVQ gobuf_pc(BX), BX
JMP BX
这两行代码取出了gobuf里的ProgramPointer,然后跳转到这行代码运行。
注意了,在这之前,都是在线程中运行,发生跳转后,则在协程中运行。
业务方法
开始运行协程中do()1,do()2方法。
goexit0
等该协程运行完do()1,do()2后,由于gogo方法人为的插入了goexit方法,所以还会运行goexit()方法,代码如下:
// The top-most function running on a goroutine
// returns to goexit+PCQuantum.
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0
BYTE $0x90 // NOP
CALL runtime·goexit1(SB) // does not return
// traceback from goexit1 must hit code range of goexit
BYTE $0x90 // NOP
该方法也是个汇编方法,其中CALL runtime·goexit1(SB)则跳转到了goexit1()方法,goexit1()代码如下,是一个Go方法,调用了mcall函数。
// Finishes execution of the current goroutine.
func goexit1() {
if raceenabled {
racegoend()
}
if trace.enabled {
traceGoEnd()
}
mcall(goexit0)
}
关键的地方来了,mcall函数也是个汇编方法,其作用是切换到g0栈并执行传入的参数方法。这里传入了goexit0,便运行goexit0方法。
而在goexit0方法里面呢,我们注意到。
- 它先将
gp协程的字段重置了, - 再次调用
schedule方法!
来了个回马金枪,又重新回到线程最开始运行的地方了。
// goexit continuation on g0.
func goexit0(gp *g) {
...
// 将gp协程的字段重置
gp.m = nil
locked := gp.lockedm != 0
gp.lockedm = 0
_g_.m.lockedg = 0
gp.preemptStop = false
gp.paniconfault = false
gp._defer = nil // should be true already but just in case.
gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
gp.writebuf = nil
gp.waitreason = 0
gp.param = nil
gp.labels = nil
gp.timer = nil
...
schedule() // 回马金枪处
}
之后线程又开始一轮新的征程。
总结
从整体流程上看,我们发现,在线程运行时,用的是g0栈记录运行信息。
而抓取到协程后,用了gogo汇编方法往协程g里插入了goexit方法,然后跳转到协程的位置运行,这时用协程g栈记录运行信息。
等协程运行结束后,会自动调用goexit方法,里面的mcall方法切换成g0栈记录运行信息,并运行了goexit0方法,最终又回到了shedule方法中,线程开始新一轮循环。
结语
既然单线程上运行协程的流程我们理解了,那下一篇就要介绍多线程是如何运行协程的。