继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

偷天换日 —— g0 栈和用户栈如何完成切换?(四)

码农桃花源
关注TA
已关注
手记 30
粉丝 88
获赞 320

上一讲讲完了 main goroutine 的诞生,它不是第一个,算上 g0,它要算第二个了。不过,我们要考虑的就是这个 goroutine,它会真正执行用户代码。

g0 栈用于执行调度器的代码,执行完之后,要跳转到执行用户代码的地方,如何跳转?这中间涉及到栈和寄存器的切换。要知道,函数调用和返回主要靠的也是 CPU 寄存器的切换。 goroutine 的切换和此类似。

继续看 proc1 函数的代码。中间有一段调整运行空间的代码,计算出的结果一般为 0,也就是一般不会调整 SP 的位置,忽略好了。

// 确定参数入栈位置spArg := sp

参数的入参位置也是从 SP 处开始,通过:

// 将参数从执行 newproc 函数的栈拷贝到新 g 的栈memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))

将 fn 的参数从 g0 栈上拷贝到 newg 的栈上,memmove 函数需要传入源地址、目的地址、参数大小。由于 main 函数在这里没有参数需要拷贝,因此这里相当于没做什么。

接着,初始化 newg 的各种字段,而且涉及到最重要的 pc,sp 等字段:

// 把 newg.sched 结构体成员的所有成员设置为 0memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))// 设置 newg 的 sched 成员,调度器需要依靠这些字段才能把 goroutine 调度到 CPU 上运行newg.sched.sp = spnewg.stktopsp = sp// newg.sched.pc 表示当 newg 被调度起来运行时从这个地址开始执行指令newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same functionnewg.sched.g = guintptr(unsafe.Pointer(newg))gostartcallfn(&newg.sched, fn)newg.gopc = callerpc// 设置 newg 的 startpc 为 fn.fn,该成员主要用于函数调用栈的 traceback 和栈收缩// newg 真正从哪里开始执行并不依赖于这个成员,而是 sched.pcnewg.startpc = fn.fnif _g_.m.curg != nil {    newg.labels = _g_.m.curg.labels}

首先, memclrNoHeapPointers 将 newg.sched 的内存全部清零。接着,设置 sched 的 sp 字段,当 goroutine 被调度到 m 上运行时,需要通过 sp 字段来指示栈顶的位置,这里设置的就是新栈的栈顶位置。

最关键的一行来了:

// newg.sched.pc 表示当 newg 被调度起来运行时从这个地址开始执行指令newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function

设置 pc 字段为函数 goexit 的地址加 1,也说是 goexit 函数的第二条指令, goexit 函数是 goroutine 退出后的一些清理工作。有点奇怪,这是要干嘛?接着往后看。

newg.sched.g = guintptr(unsafe.Pointer(newg))

设置 g 字段为 newg 的地址。插一句,sched 是 g 结构体的一个字段,它本身也是一个结构体,保存调度信息。复习一下:

type gobuf struct {    // 存储 rsp 寄存器的值    sp   uintptr    // 存储 rip 寄存器的值    pc   uintptr    // 指向 goroutine    g    guintptr    ctxt unsafe.Pointer // this has to be a pointer so that gc scans it    // 保存系统调用的返回值    ret  sys.Uintreg    lr   uintptr    bp   uintptr // for GOEXPERIMENT=framepointer}

接下来的这个函数非常重要,可以解释之前为什么要那样设置 pc 字段的值。调用 gostartcallfn

gostartcallfn(&newg.sched, fn) //调整sched成员和newg的栈

传入 newg.sched 和 fn。

  1. func gostartcallfn(gobuf *gobuf, fv *funcval) {

  2.    var fn unsafe.Pointer

  3.    if fv != nil {

  4.        // fn: gorotine 的入口地址,初始化时对应的是 runtime.main

  5.        fn = unsafe.Pointer(fv.fn)

  6.    } else {

  7.        fn = unsafe.Pointer(funcPC(nilfunc))

  8.    }

  9.    gostartcall(gobuf, fn, unsafe.Pointer(fv))

  10. }


  11. func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {

  12.    // newg 的栈顶,目前 newg 栈上只有 fn 函数的参数,sp 指向的是 fn 的第一参数

  13.    sp := buf.sp


  14.    // …………………………


  15.    // 为返回地址预留空间

  16.    sp -= sys.PtrSize

  17.    // 这里填的是 newproc1 函数里设置的 goexit 函数的第二条指令

  18.    // 伪装 fn 是被 goexit 函数调用的,使得 fn 执行完后返回到 goexit 继续执行,从而完成清理工作

  19.    *(*uintptr)(unsafe.Pointer(sp)) = buf.pc

  20.    // 重新设置 buf.sp

  21.    buf.sp = sp

  22.    // 当 goroutine 被调度起来执行时,会从这里的 pc 值开始执行,初始化时就是 runtime.main

  23.    buf.pc = uintptr(fn)

  24.    buf.ctxt = ctxt

  25. }

函数 gostartcallfn 只是拆解出了包含在 funcval 结构体里的函数指针,转过头就调用 gostartcall。将 sp 减小了一个指针的位置,这是给返回地址留空间。果然接着就把 buf.pc 填入了栈顶的位置:

*(*uintptr)(unsafe.Pointer(sp)) = buf.pc

原来 buf.pc 只是做了一个搬运工,搞什么啊。重新设置 buf.sp 为送减掉一个指针位置之后的值,设置 buf.pc 为 fn,指向要执行的函数,这里就是指的 runtime.main 函数。

对嘛,这才是应有的操作。之后,当调度器“光顾”此 goroutine 时,取出 buf.sp 和 buf.pc,恢复 CPU 相应的寄存器,就可以构造出 goroutine 的运行环境。

而 goexit 函数也通过“偷天换日”将自己的地址“强行”放到 newg 的栈顶,达到自己不可告人的目的:每个 goroutine 执行完之后,都要经过我的一些清理工作,才能“放行”。这样一说,goexit 函数还真是无私,默默地做一些“扫尾”的工作。

设置完 newg.sched 这后,我们的图又可以前进一步:

http://img.mukewang.com/5d70bec40001deff06560413.jpg

上图中,newg 新增了 sched.pc 指向 runtime.main 函数,当它被调度起来执行时,就从这里开始;新增了 sched.sp 指向了 newg 栈顶位置,同时,newg 栈顶位置的内容是一个跳转地址,指向 runtime.goexit 的第二条指令,当 goroutine 退出时,这条地址会载入 CPU 的 PC 寄存器,跳转到这里执行“扫尾”工作。

之后,将 newg 的状态改为 runnable,设置 goroutine 的 id:

// 设置 g 的状态为 _Grunnable,可以运行了casgstatus(newg, _Gdead, _Grunnable)newg.goid = int64(_p_.goidcache)

每个 P 每次会批量(16个)申请 id,每次调用 newproc 函数,新创建一个 goroutine,id 加 1。因此 g0 的 id 是 0,而 main goroutine 的 id 就是 1。

newg 的状态变成可执行后(Runnable),就可以将它加入到 P 的本地运行队列里,等待调度。所以,goroutine 何时被执行,用户代码决定不了。来看源码:

  1. // 将 G 放入 _p_ 的本地待运行队列

  2. runqput(_p_, newg, true)


  3. // runqput 尝试将 g 放到本地可执行队列里。

  4. // 如果 next 为假,runqput 将 g 添加到可运行队列的尾部

  5. // 如果 next 为真,runqput 将 g 添加到 p.runnext 字段

  6. // 如果 run queue 满了,runnext 将 g 放到全局队列里

  7. //

  8. // runnext 成员中的 goroutine 会被优先调度起来运行

  9. func runqput(_p_ *p, gp *g, next bool) {

  10.    // ……………………


  11.    if next {

  12.    retryNext:

  13.        oldnext := _p_.runnext

  14.        if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {

  15.            // 有其它线程在操作 runnext 成员,需要重试

  16.            goto retryNext

  17.        }

  18.        // 老的 runnext 为 nil,不用管了

  19.        if oldnext == 0 {

  20.            return

  21.        }

  22.        // 把之前的 runnext 踢到正常的 runq 中

  23.        // 原本存放在 runnext 的 gp 放入 runq 的尾部

  24.        gp = oldnext.ptr()

  25.    }


  26. retry:

  27.    h := atomic.Load(&_p_.runqhead) // load-acquire, synchronize with consumers

  28.    t := _p_.runqtail

  29.    // 如果 P 的本地队列没有满,入队

  30.    if t-h < uint32(len(_p_.runq)) {

  31.        _p_.runq[t%uint32(len(_p_.runq))].set(gp)

  32.        // 原子写入

  33.        atomic.Store(&_p_.runqtail, t+1) // store-release, makes the item available for consumption

  34.        return

  35.    }

  36.    // 可运行队列已经满了,放入全局队列了

  37.    if runqputslow(_p_, gp, h, t) {

  38.        return

  39.    }

  40.    // the queue is not full, now the put above must succeed

  41.    // 没有成功放入全局队列,说明本地队列没满,重试一下

  42.    goto retry

  43. }

runqput 函数的主要作用就是将新创建的 goroutine 加入到 P 的可运行队列,如果本地队列满了,则加入到全局可运行队列。前两个参数都好理解,最后一个参数 next 的作用是,当它为 true 时,会将 newg 加入到 P 的 runnext 字段,具有最高优先级,将先于普通队列中的 goroutine 得到执行。

先将 P 老的 runnext 成员取出,接着用一个原子操作 cas 来试图将 runnext 成员设置成 newg,目的是防止其他线程在同时修改 runnext 字段。

设置成功之后,相当于 newg “挤掉” 了原来老的处于 runnext 的 goroutine,还得给人遣散费,安顿好人家嘛,不然和强盗有何区别?

“安顿”的动作在 retry 代码段中执行。先通过 headtaillen(_p_.runq) 来判断队列是否已满,如果没满,则直接写到队列尾部,同时修改队列尾部的指针。

// store-release, makes it available for consumptionatomic.Store(&_p_.runqtail, t+1)

这里使用原子操作写入 runtail,防止编译器和 CPU 指令重排,保证上一行代码对 runq 的修改发生在修改 runqtail 之前,并且保证当前线程对队列的修改对其它线程立即可见。

如果本地队列满了,那就只能试图将 newg 添加到全局可运行队列中了。调用 runqputslow(_p_,gp,h,t) 完成。

  1. // 将 g 和 _p_ 本地队列的一半 goroutine 放入全局队列。

  2. // 因为要获取锁,所以会慢

  3. func runqputslow(_p_ *p, gp *g, h, t uint32) bool {

  4.    var batch [len(_p_.runq)/2 + 1]*g


  5.    // First, grab a batch from local queue.

  6.    n := t - h

  7.    n = n / 2

  8.    if n != uint32(len(_p_.runq)/2) {

  9.        throw("runqputslow: queue is not full")

  10.    }

  11.    for i := uint32(0); i < n; i++ {

  12.        batch[i] = _p_.runq[(h+i)%uint32(len(_p_.runq))].ptr()

  13.    }

  14.    // 如果 cas 操作失败,说明本地队列不满了,直接返回

  15.    if !atomic.Cas(&_p_.runqhead, h, h+n) { // cas-release, commits consume

  16.        return false

  17.    }

  18.    batch[n] = gp


  19.    // …………………………


  20.    // Link the goroutines.

  21.    // 全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的 g 链接起来,

  22.    // 减小锁粒度,从而降低锁冲突,提升性能

  23.    for i := uint32(0); i < n; i++ {

  24.        batch[i].schedlink.set(batch[i+1])

  25.    }


  26.    // Now put the batch on global queue.

  27.    lock(&sched.lock)

  28.    globrunqputbatch(batch[0], batch[n], int32(n+1))

  29.    unlock(&sched.lock)

  30.    return true

  31. }

先将 P 本地队列里所有的 goroutine 加入到一个数组中,数组长度为 len(_p_.runq)/2+1,也就是 runq 的一半加上 newg。

接着,将从 runq 的头部开始的前一半 goroutine 存入 bacth 数组。然后,使用原子操作尝试修改 P 的队列头,因为出队了一半 goroutine,所以 head 要向后移动 1/2 的长度。如果修改失败,说明 runq 的本地队列被其他线程修改了,因此后面的操作就不进行了,直接返回 false,表示 newg 没被添加进来。

batch[n] = gp

将 newg 本身添加到数组。

通过循环将 batch 数组里的所有 g 串成链表:

for i := uint32(0); i < n; i++ {    batch[i].schedlink.set(batch[i+1])}


http://img3.mukewang.com/5d70beda0001815806630139.jpg

最后,将链表添加到全局队列中。由于操作的是全局队列,因此需要获取锁,因为存在竞争,所以代价较高。这也是本地可运行队列存在的原因。调用 globrunqputbatch(batch[0],batch[n],int32(n+1))

// Put a batch of runnable goroutines on the global runnable queue.// Sched must be locked.func globrunqputbatch(ghead *g, gtail *g, n int32) {    gtail.schedlink = 0    if sched.runqtail != 0 {        sched.runqtail.ptr().schedlink.set(ghead)    } else {        sched.runqhead.set(ghead)    }    sched.runqtail.set(gtail)    sched.runqsize += n}

如果全局的队列尾 sched.runqtail 不为空,则直接将其和前面生成的链表头相接,否则说明全局的可运行列队为空,那就直接将前面生成的链表头设置到 sched.runqhead。

最后,再设置好队列尾,增加 runqsize。

设置完成之后:

http://img4.mukewang.com/5d70bee600016f3a06390116.jpg

再回到 runqput 函数,如果将 newg 添加到全局队列失败了,说明本地队列在此过程中发生了变化,又有了位置可以添加 newg,因此重试 retry 代码段。我们也可以发现,P 的本地可运行队列的长度为 256,它是一个循环队列,因此最多只能放下 256 个 goroutine。

因为本文还是处于初始化的场景,所以 newg 被成功放入 p0 的本地可运行队列,等待被调度。

将我们的图再完善一下:

http://img2.mukewang.com/5d70bef10001f65306510420.jpg

参考资料

【阿波张 Go语言调度器之调度 main 】https://mp.weixin.qq.com/s/8eJm5hjwKXya85VnT4y8Cw



打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP