本文是《Go语言调度器源代码情景分析》系列的第14篇,也是第二章的第4小节。


上一节我们通过分析main goroutine的创建详细讨论了goroutine的创建及初始化流程,这一节我们接着来分析调度器如何把main goroutine调度到CPU上去运行。本节需要重点关注的问题有:

  • 如何保存g0的调度信息?

  • schedule函数有什么重要作用?

  • gogo函数如何完成从g0到main goroutine的切换?

接着前一节继续分析代码,从newproc返回到rt0_go,继续往下执行mstart。

runtime/proc.go : 1153

func mstart() {
_g_ := getg() //_g_ = g0 //对于启动过程来说,g0的stack.lo早已完成初始化,所以onStack = false
osStack := _g_.stack.lo == 0
if osStack {
// Initialize stack bounds from system stack.
// Cgo may have left stack size in stack.hi.
// minit may update the stack bounds.
size := _g_.stack.hi
if size == 0 {
size = 8192 * sys.StackGuardMultiplier
}
_g_.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
_g_.stack.lo = _g_.stack.hi - size + 1024
}
// Initialize stack guards so that we can start calling
// both Go and C functions with stack growth prologues.
_g_.stackguard0 = _g_.stack.lo + _StackGuard
_g_.stackguard1 = _g_.stackguard0 mstart1() // Exit this thread.
if GOOS == "windows" || GOOS == "solaris" || GOOS == "plan9" || GOOS == "darwin" || GOOS == "aix" {
// Window, Solaris, Darwin, AIX and Plan 9 always system-allocate
// the stack, but put it in _g_.stack before mstart,
// so the logic above hasn't set osStack yet.
osStack = true
}
mexit(osStack)
}

mstart函数本身没啥说的,它继续调用mstart1函数。

runtime/proc.go : 1184

func mstart1() {
_g_ := getg() //启动过程时 _g_ = m0的g0 if _g_ != _g_.m.g0 {
throw("bad runtime·mstart")
} // Record the caller for use as the top of stack in mcall and
// for terminating the thread.
// We're never coming back to mstart1 after we call schedule,
// so other calls can reuse the current frame.
//getcallerpc()获取mstart1执行完的返回地址
//getcallersp()获取调用mstart1时的栈顶地址
save(getcallerpc(), getcallersp())
asminit() //在AMD64 Linux平台中,这个函数什么也没做,是个空函数
minit() //与信号相关的初始化,目前不需要关心 // Install signal handlers; after minit so that minit can
// prepare the thread to be able to handle the signals.
if _g_.m == &m0 { //启动时_g_.m是m0,所以会执行下面的mstartm0函数
mstartm0() //也是信号相关的初始化,现在我们不关注
} if fn := _g_.m.mstartfn; fn != nil { //初始化过程中fn == nil
fn()
} if _g_.m != &m0 {// m0已经绑定了allp[0],不是m0的话还没有p,所以需要获取一个p
acquirep(_g_.m.nextp.ptr())
_g_.m.nextp = 0
} //schedule函数永远不会返回
schedule()
}

mstart1首先调用save函数来保存g0的调度信息,save这一行代码非常重要,是我们理解调度循环的关键点之一。这里首先需要注意的是代码中的getcallerpc()返回的是mstart调用mstart1时被call指令压栈的返回地址,getcallersp()函数返回的是调用mstart1函数之前mstart函数的栈顶地址,其次需要看看save函数到底做了哪些重要工作。

runtime/proc.go : 2733

// save updates getg().sched to refer to pc and sp so that a following
// gogo will restore pc and sp.
//
// save must not have write barriers because invoking a write barrier
// can clobber getg().sched.
//
//go:nosplit
//go:nowritebarrierrec
func save(pc, sp uintptr) {
_g_ := getg() _g_.sched.pc = pc //再次运行时的指令地址
_g_.sched.sp = sp //再次运行时到栈顶
_g_.sched.lr = 0
_g_.sched.ret = 0
_g_.sched.g = guintptr(unsafe.Pointer(_g_))
// We need to ensure ctxt is zero, but can't have a write
// barrier here. However, it should always already be zero.
// Assert that.
if _g_.sched.ctxt != nil {
badctxt()
}
}

可以看到,save函数保存了调度相关的所有信息,包括最为重要的当前正在运行的g的下一条指令的地址和栈顶地址,不管是对g0还是其它goroutine来说这些信息在调度过程中都是必不可少的,我们会在后面的调度分析中看到调度器是如何利用这些信息来完成调度的。代码执行完save函数之后g0的状态如下图所示:

从上图可以看出,g0.sched.sp指向了mstart1函数执行完成后的返回地址,该地址保存在了mstart函数的栈帧之中;g0.sched.pc指向的是mstart函数中调用mstart1函数之后的 if 语句。

为什么g0已经执行到mstart1这个函数了而且还会继续调用其它函数,但g0的调度信息中的pc和sp却要设置在mstart函数中?难道下次切换到g0时要从mstart函数中的 if 语句继续执行?可是从mstart函数可以看到,if语句之后就要退出线程了!这看起来很奇怪,不过随着分析的进行,我们会看到这里为什么要这么做。

继续分析代码,save函数执行完成后,返回到mstart1继续其它跟m相关的一些初始化,完成这些初始化后则调用调度系统的核心函数schedule()完成goroutine的调度,之所以说它是核心,原因在于每次调度goroutine都是从schedule函数开始的。

runtime/proc.go : 2469

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
_g_ := getg() //_g_ = 每个工作线程m对应的g0,初始化时是m0的g0 //...... var gp *g //...... if gp == nil {
// Check the global runnable queue once in a while to ensure fairness.
// Otherwise two goroutines can completely occupy the local runqueue
// by constantly respawning each other.
//为了保证调度的公平性,每进行61次调度就需要优先从全局运行队列中获取goroutine,
//因为如果只调度本地队列中的g,那么全局运行队列中的goroutine将得不到运行
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock) //所有工作线程都能访问全局运行队列,所以需要加锁
gp = globrunqget(_g_.m.p.ptr(), 1) //从全局运行队列中获取1个goroutine
unlock(&sched.lock)
}
}
if gp == nil {
//从与m关联的p的本地运行队列中获取goroutine
gp, inheritTime = runqget(_g_.m.p.ptr())
if gp != nil && _g_.m.spinning {
throw("schedule: spinning with local work")
}
}
if gp == nil {
//如果从本地运行队列和全局运行队列都没有找到需要运行的goroutine,
//则调用findrunnable函数从其它工作线程的运行队列中偷取,如果偷取不到,则当前工作线程进入睡眠,
//直到获取到需要运行的goroutine之后findrunnable函数才会返回。
gp, inheritTime = findrunnable() // blocks until work is available
} //跟启动无关的代码..... //当前运行的是runtime的代码,函数调用栈使用的是g0的栈空间
//调用execte切换到gp的代码和栈空间去运行
execute(gp, inheritTime)
}

schedule函数通过调用globrunqget()和runqget()函数分别从全局运行队列和当前工作线程的本地运行队列中选取下一个需要运行的goroutine,如果这两个队列都没有需要运行的goroutine则通过findrunnalbe()函数从其它p的运行队列中盗取goroutine,一旦找到下一个需要运行的goroutine,则调用excute函数从g0切换到该goroutine去运行。对于我们这个场景来说,前面的启动流程已经创建好第一个goroutine并放入了当前工作线程的本地运行队列,所以这里会通过runqget把目前唯一的一个goroutine取出来,至于具体是如何取出来的,我们将在第三章讨论调度策略时再回头来详细分析globrunqget(),runqget()和findrunnable()这三个函数的实现流程,现在我们先来分析execute函数是如何把从运行队列中找出来的goroutine调度到CPU上运行的。

runtime/proc.go : 2136

// Schedules gp to run on the current M.
// If inheritTime is true, gp inherits the remaining time in the
// current time slice. Otherwise, it starts a new time slice.
// Never returns.
//
// Write barriers are allowed because this is called immediately after
// acquiring a P in several places.
//
//go:yeswritebarrierrec
func execute(gp *g, inheritTime bool) {
_g_ := getg() //g0 //设置待运行g的状态为_Grunning
casgstatus(gp, _Grunnable, _Grunning) //...... //把g和m关联起来
_g_.m.curg = gp
gp.m = _g_.m //...... //gogo完成从g0到gp真正的切换
gogo(&gp.sched)
}

execute函数的第一个参数gp即是需要调度起来运行的goroutine,这里首先把gp的状态从_Grunnable修改为_Grunning,然后把gp和m关联起来,这样通过m就可以找到当前工作线程正在执行哪个goroutine,反之亦然。

完成gp运行前的准备工作之后,execute调用gogo函数完成从g0到gp的的切换:CPU执行权的转让以及栈的切换。

gogo函数也是通过汇编语言编写的,这里之所以需要使用汇编,是因为goroutine的调度涉及不同执行流之间的切换,前面我们在讨论操作系统切换线程时已经看到过,执行流的切换从本质上来说就是CPU寄存器以及函数调用栈的切换,然而不管是go还是c这种高级语言都无法精确控制CPU寄存器的修改,因而高级语言在这里也就无能为力了,只能依靠汇编指令来达成目的。

runtime/asm_amd64.s : 251

# func gogo(buf *gobuf)
# restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $-
#buf = &gp.sched
MOVQ buf+(FP), BX # BX = buf #gobuf->g --> dx register
MOVQ gobuf_g(BX), DX # DX = gp.sched.g #下面这行代码没有实质作用,检查gp.sched.g是否是nil,如果是nil进程会crash死掉
MOVQ (DX), CX # make sure g != nil get_tls(CX) #把要运行的g的指针放入线程本地存储,这样后面的代码就可以通过线程本地存储
#获取到当前正在执行的goroutine的g结构体对象,从而找到与之关联的m和p
MOVQ DX, g(CX) #把CPU的SP寄存器设置为sched.sp,完成了栈的切换
MOVQ gobuf_sp(BX), SP # restore SP #下面三条同样是恢复调度上下文到CPU相关寄存器
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP #清空sched的值,因为我们已把相关值放入CPU对应的寄存器了,不再需要,这样做可以少gc的工作量
MOVQ $, gobuf_sp(BX) # clear to help garbage collector
MOVQ $, gobuf_ret(BX)
MOVQ $, gobuf_ctxt(BX)
MOVQ $, gobuf_bp(BX) #把sched.pc值放入BX寄存器
MOVQ gobuf_pc(BX), BX #JMP把BX寄存器的包含的地址值放入CPU的IP寄存器,于是,CPU跳转到该地址继续执行指令,
JMP BX

gogo函数的这段汇编代码短小而强悍,虽然笔者已经在代码中做了详细的注释,但为了完全搞清楚它的工作原理,我们有必要再对这些指令进行逐条分析:

execute函数在调用gogo时把gp的sched成员的地址作为实参(型参buf)传递了过来,该参数位于FP寄存器所指的位置,所以第1条指令

MOVQ    buf+(FP), BX        # &gp.sched --> BX

把buf的值也就是gp.sched的地址放在了BX寄存器之中,这样便于后面的指令依靠BX寄存器来存取gp.sched的成员。sched成员保存了调度相关的信息,上一节我们已经看到,main goroutine创建时已经把这些信息设置好了。

第2条指令

MOVQ    gobuf_g(BX), DX  # gp.sched.g --> DX

把gp.sched.g读取到DX寄存器,注意这条指令的源操作数是间接寻址,如果读者对间接寻址不熟悉的话可以参考预备知识汇编语言部分。

第3条指令

MOVQ    (DX), CX        # make sure g != nil

的作用在于检查gp.sched.g是否为nil,如果为nil指针的话,这条指令会导致程序死掉,有读者可能会有疑问,为什么要让它死掉啊,原因在于这个gp.sched.g是由go runtime代码负责设置的,按道理说不可能为nil,如果为nil,一定是程序逻辑写得有问题,所以需要把这个bug暴露出来,而不是把它隐藏起来。

第4条和第5条指令

get_tls(CX)
#把DX值也就是需要运行的goroutine的指针写入线程本地存储之中
#运行这条指令之前,线程本地存储存放的是g0的地址
MOVQ DX, g(CX)

把DX寄存器的值也就是gp.sched.g(这是一个指向g的指针)写入线程本地存储之中,这样后面的代码就可以通过线程本地存储获取到当前正在执行的goroutine的g结构体对象,从而找到与之关联的m和p。

第6条指令

MOVQ    gobuf_sp(BX), SP    # restore SP

设置CPU的栈顶寄存器SP为gp.sched.sp,这条指令完成了栈的切换,从g0的栈切换到了gp的栈。

第7~13条指令

#下面三条同样是恢复调度上下文到CPU相关寄存器
MOVQ gobuf_ret(BX), AX #系统调用的返回值放入AX寄存器
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP #清空gp.sched中不再需要的值,因为我们已把相关值放入CPU对应的寄存器了,不再需要,这样做可以少gc的工作量
MOVQ $, gobuf_sp(BX) // clear to help garbage collector
MOVQ $, gobuf_ret(BX)
MOVQ $, gobuf_ctxt(BX)
MOVQ $, gobuf_bp(BX)

一是根据gp.sched其它字段设置CPU相关寄存器,可以看到这里恢复了CPU的栈基地址寄存器BP,二是把gp.sched中已经不需要的成员设置为0,这样可以减少gc的工作量。

第14条指令

MOVQ    gobuf_pc(BX), BX

把gp.sched.pc的值读取到BX寄存器,这个pc值是gp这个goroutine马上需要执行的第一条指令的地址,对于我们这个场景来说它现在就是runtime.main函数的第一条指令,现在这条指令的地址就放在BX寄存器里面。最后一条指令

JMP    BX

这里的JMP BX指令把BX寄存器里面的指令地址放入CPU的rip寄存器,于是,CPU就会跳转到该地址继续执行属于gp这个goroutine的代码,这样就完成了goroutine的切换。

总结一下这15条指令,其实就只做了两件事:

  1. 把gp.sched的成员恢复到CPU的寄存器完成状态以及栈的切换;

  2. 跳转到gp.sched.pc所指的指令地址(runtime.main)处执行。

现在已经从g0切换到了gp这个goroutine,对于我们这个场景来说,gp还是第一次被调度起来运行,它的入口函数是runtime.main,所以接下来CPU就开始执行runtime.main函数:

runtime/proc.go : 109

// The main goroutine.
func main() {
g := getg() // g = main goroutine,不再是g0了 // ...... // Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.
// Using decimal instead of binary GB and MB because
// they look nicer in the stack overflow failure message.
if sys.PtrSize == 8 { //64位系统上每个goroutine的栈最大可达1G
maxstacksize = 1000000000
} else {
maxstacksize = 250000000
} // Allow newproc to start new Ms.
mainStarted = true if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
//现在执行的是main goroutine,所以使用的是main goroutine的栈,需要切换到g0栈去执行newm()
systemstack(func() {
//创建监控线程,该线程独立于调度器,不需要跟p关联即可运行
newm(sysmon, nil)
})
} //...... //调用runtime包的初始化函数,由编译器实现
runtime_init() // must be before defer // Record when the world started.
runtimeInitTime = nanotime() gcenable() //开启垃圾回收器 //...... //main 包的初始化函数,也是由编译器实现,会递归的调用我们import进来的包的初始化函数
fn := main_init // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn() //...... //调用main.main函数
fn = main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
fn() //...... //进入系统调用,退出进程,可以看出main goroutine并未返回,而是直接进入系统调用退出进程了
exit(0) //保护性代码,如果exit意外返回,下面的代码也会让该进程crash死掉
for {
var x *int32
*x = 0
}
}

runtime.main函数主要工作流程如下:

  1. 启动一个sysmon系统监控线程,该线程负责整个程序的gc、抢占调度以及netpoll等功能的监控,在抢占调度一章我们再继续分析sysmon是如何协助完成goroutine的抢占调度的;

  2. 执行runtime包的初始化;

  3. 执行main包以及main包import的所有包的初始化;

  4. 执行main.main函数;

  5. 从main.main函数返回后调用exit系统调用退出进程;

从上述流程可以看出,runtime.main执行完main包的main函数之后就直接调用exit系统调用结束进程了,它并没有返回到调用它的函数(还记得是从哪里开始执行的runtime.main吗?),其实runtime.main是main goroutine的入口函数,并不是直接被调用的,而是在schedule()->execute()->gogo()这个调用链的gogo函数中用汇编代码直接跳转过来的,所以从这个角度来说,goroutine确实不应该返回,没有地方可返回啊!可是从前面的分析中我们得知,在创建goroutine的时候已经在其栈上放好了一个返回地址,伪造成goexit函数调用了goroutine的入口函数,这里怎么没有用到这个返回地址啊?其实那是为非main goroutine准备的,非main goroutine执行完成后就会返回到goexit继续执行,而main goroutine执行完成后整个进程就结束了,这是main goroutine与其它goroutine的一个区别。

总结一下从g0切换到main goroutine的流程:

  1. 保存g0的调度信息,主要是保存CPU栈顶寄存器SP到g0.sched.sp成员之中;

  2. 调用schedule函数寻找需要运行的goroutine,我们这个场景找到的是main goroutine;

  3. 调用gogo函数首先从g0栈切换到main goroutine的栈,然后从main goroutine的g结构体对象之中取出sched.pc的值并使用JMP指令跳转到该地址去执行;

  4. main goroutine执行完毕直接调用exit系统调用退出进程。

下一节我们将用例子来分析非main goroutine的退出。

Go语言调度器之调度main goroutine(14)的更多相关文章

  1. Go语言调度器之创建main goroutine(13)

    本文是<Go语言调度器源代码情景分析>系列的第13篇,也是第二章的第3小节. 上一节我们分析了调度器的初始化,这一节我们来看程序中的第一个goroutine是如何创建的. 创建main g ...

  2. go语言之行--golang核武器goroutine调度原理、channel详解

    一.goroutine简介 goroutine是go语言中最为NB的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心.goroutine使用方式非常的简单,只需使用go关键字 ...

  3. Go语言调度器之主动调度(20)

    本文是<Go语言调度器源代码情景分析>系列的第20篇,也是第五章<主动调度>的第1小节. Goroutine的主动调度是指当前正在运行的goroutine通过直接调用runti ...

  4. [GO语言的并发之道] Goroutine调度原理&Channel详解

    并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题:Go语言作为一个出道以来就自带 『高并发』光环的富二代编程语言,它的并发(并行)编程肯定是值得开发者去探究的,而Go ...

  5. 非main goroutine的退出及调度循环(15)

    本文是<Go语言调度器源代码情景分析>系列的第15篇,也是第二章的第5小节. 上一节我们说过main goroutine退出时会直接执行exit系统调用退出整个进程,而非main goro ...

  6. Golang源码学习:调度逻辑(二)main goroutine的创建

    接上一篇继续分析一下runtime.newproc方法. 函数签名 newproc函数的签名为 newproc(siz int32, fn *funcval) siz是传入的参数大小(不是个数):fn ...

  7. 深入Golang调度器之GMP模型

    前言 随着服务器硬件迭代升级,配置也越来越高.为充分利用服务器资源,并发编程也变的越来越重要.在开始之前,需要了解一下并发(concurrency)和并行(parallesim)的区别. 并发:  逻 ...

  8. Go语言的GPM调度器是什么?

    我是平也,这有一个专注Gopher技术成长的开源项目「go home」 导读 相信很多人都听说过Go语言天然支持高并发,原因是内部有协程(goroutine)加持,可以在一个进程中启动成千上万个协程. ...

  9. Linux CFS调度器之pick_next_task_fair选择下一个被调度的进程--Linux进程的管理与调度(二十八)

    1. CFS如何选择最合适的进程 每个调度器类sched_class都必须提供一个pick_next_task函数用以在就绪队列中选择一个最优的进程来等待调度, 而我们的CFS调度器类中, 选择下一个 ...

随机推荐

  1. c# 第40节 密封类、密封方法

    本节内容: 1:密封的存在意义 2:密封方法的实现 1:密封的存在意义 2:密封方法的实现 class2::父类 class Class2 { public virtual void show() { ...

  2. web-never give up

    打开题目连接 ?id=1 ,疑是注入点 但是输入其他数字无果 打开源码,发现注释有网页链接 打开连接123.206.87.240:8006/test/1p.html 发现回到了bugku的论坛首页,应 ...

  3. CentOS7 通过 devstack 安装 OpenStack

    安装前的准备 修改源 (可跳过) 将下载源变更到国内可以时下载速度大大提升 打开下面的文件 vim /etc/yum.repos.d/CentOS-Base.repo 将原来的注释掉改成: [base ...

  4. Spring Cloud Alibaba Sentinel对RestTemplate的支持

    Spring Cloud Alibaba Sentinel 支持对 RestTemplate 的服务调用使用 Sentinel 进行保护,在构造 RestTemplate bean的时候需要加上 @S ...

  5. 数据仓库005 - 复习Linux shell命令 - crontab调度 sh脚本 后台执行 软连接

    一.crontab调度 对于linux 自带crontab而言,      xxx.sh的一般编写格式以#!/bin/bash 解释器开头,可在脚本中加入: date 但是,shell脚本执行 需要 ...

  6. 【shell脚本】通过位置变量创建Linux账户及密码===addUser.sh

    通过位置变量创建Linux账户及密码 脚本内容 [root@VM_0_10_centos shellScript]# vi addUser.sh #!/bin/bash # 通过位置变量创建系统账户及 ...

  7. 如何配置jdk的本地环境

    在计算机→属性→高级系统设置→高级→环境变量,如图: 第一步:系统变量→新建 JAVA_HOME 变量 . 变量值填写jdk的安装目录(本人是C:\Program Files\Java\jdk1.8. ...

  8. idea 项目在一般模式下可以正常启动,在debug模式下无法启动,像是卡住了的感觉

    项目一般模式下可以启动,debug模式下就是启动不了,后经过排查发现打的有断点,断点取消在重启立马就可以啦. Method breakpoints may dramatically slow down ...

  9. 在 .NET Core 中使用异步的 ADO.NET 的简单示例

    直接贴代码: Program.cs using Microsoft.Extensions.Configuration; using System; using System.Data; using S ...

  10. Kubernetes PV与PVC的关系

    Kubernetes PV与PVC的关系 PersistenVolume(PV):对存储资源创建和使用的抽象,使得存储作为集群中的资源管理,分为有静态与动态.PersistentVolumeClaim ...