Go 深入考究协程

251 阅读4分钟

前言

在上一篇了解协程里讲了协程与线程的构造,本篇幅则是介绍协程是如何在单线程上运行的。

单线程循环结构

单线程循环结构的原理大致为,线程一直在跑一些函数,每一轮循环都会跳到一个协程并执行,执行完后又跳回线程继续循环,循环往复,其效果似协程在线程上运行一样。

下图为运行流程,自顶向下看

  1. 线程从本地或全局搜索协程,拿到协程后会更新记录一些协程信息。
  2. 跳转到协程的位置执行代码。
  3. 又退出协程重新回到循环中。

image.png

以下讲解线程的每个流程。

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协程记录线程的运行信息。

image.png

这里回忆下g结构体的构造。

type g struct {
   stack        stack  // 记录了协程的高地址和低地址
   sched        gobuf  // 记录程序运行时的信息
   atomicstatus uint32 // 协程的状态
   goid         int64  // 协程的id号
   ...
}

具体细节如图

  • stacklo,hi为高低地址
  • gobufsp是栈指针,pc记录协程运行到了哪一行

image.png

gogo()

execute最后跳转到gogo方法,我们通过点击gogo()跳转后发现,gogo()方法只写个函数声明,说明这个方法是使用汇编来实现的。

func gogo(buf *gobuf)

这时使用ctrl+shift+f在目录中搜索gogo(SB),则跳出好几个方法,存在于不同文件中,说明Go针对不同的平台实现了不同的gogo方法。

image.png

我们选择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,记录协程运行到了哪一行。

image.png

了解了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

该方法只需关心两个重点。

  1. 人为的插入了一个goexit()方法

MOVQ gobuf_sp(BX), SP // restore SP,这行代码作用是往这个协程栈中人为的插入了一个goexit()方法。

为什么能插入呢?因为通过gobuf知道了协程栈的地址,便能往里边插入goexit方法了。

于是协程栈就变成如下所示:

image.png

  1. 跳转到协程中运行:
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方法里面呢,我们注意到。

  1. 它先将gp协程的字段重置了,
  2. 再次调用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方法中,线程开始新一轮循环。

结语

既然单线程上运行协程的流程我们理解了,那下一篇就要介绍多线程是如何运行协程的。