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


上一节我们分析了调度器的初始化,这一节我们来看程序中的第一个goroutine是如何创建的。

创建main goroutine

接上一节,schedinit完成调度系统初始化后,返回到rt0_go函数中开始调用newproc() 创建一个新的goroutine用于执行mainPC所对应的runtime·main函数,看下面的代码:

runtime/asm_amd64.s : 197

  1. # create a new goroutine to start program
  2. MOVQ $runtime·mainPC(SB), AX# entry,mainPC是runtime.main
  3. # newproc的第二个参数入栈,也就是新的goroutine需要执行的函数
  4. PUSHQ AX # AX = &funcval{runtime·main},
  5.  
  6. # newproc的第一个参数入栈,该参数表示runtime.main函数需要的参数大小,因为runtime.main没有参数,所以这里是0
  7. PUSHQ $
  8. CALL runtime·newproc(SB) # 创建main goroutine
  9. POPQ AX
  10. POPQ AX
  11.  
  12. # start this M
  13. CALL runtime·mstart(SB) # 主线程进入调度循环,运行刚刚创建的goroutine
  14.  
  15. # 上面的mstart永远不应该返回的,如果返回了,一定是代码逻辑有问题,直接abort
  16. CALL runtime·abort(SB)// mstart should never return
  17. RET
  18.  
  19. DATA runtime·mainPC+(SB)/,$runtime·main(SB)
  20. GLOB Lruntime·mainPC(SB),RODATA,$

在后面的分析过程中我们会看到这个runtime.main最终会调用我们写的main.main函数,在分析runtime·main之前我们先把重点放在newproc这个函数上。

newproc函数用于创建新的goroutine,它有两个参数,先说第二个参数fn,新创建出来的goroutine将从fn这个函数开始执行,而这个fn函数可能也会有参数,newproc的第一个参数正是fn函数的参数以字节为单位的大小。比如有如下go代码片段:

  1. func start(a, b, c int64) {
  2. ......
  3. }
  4.  
  5. func main() {
  6. go start(1, 2, 3)
  7. }

编译器在编译上面的go语句时,就会把其替换为对newproc函数的调用,编译后的代码逻辑上等同于下面的伪代码

  1. func main() {
  2. push 0x3
  3. push 0x2
  4. push 0x1
  5. runtime.newproc(24, start)
  6. }

编译器编译时首先会用几条指令把start函数需要用到的3个参数压栈,然后调用newproc函数。因为start函数的3个int64类型的参数共占24个字节,所以传递给newproc的第一个参数是24,表示start函数需要24字节大小的参数。

那为什么需要传递fn函数的参数大小给newproc函数呢?原因就在于newproc函数将创建一个新的goroutine来执行fn函数,而这个新创建的goroutine与当前这个goroutine会使用不同的栈,因此就需要在创建goroutine的时候把fn需要用到的参数先从当前goroutine的栈上拷贝到新的goroutine的栈上之后才能让其开始执行,而newproc函数本身并不知道需要拷贝多少数据到新创建的goroutine的栈上去,所以需要用参数的方式指定拷贝多少数据。

了解完这些背景知识之后,下面我们开始分析newproc的代码。newproc函数是对newproc1的一个包装,这里最重要的准备工作有两个,一个是获取fn函数第一个参数的地址(代码中的argp),另一个是使用systemstack函数切换到g0栈,当然,对于我们这个初始化场景来说现在本来就在g0栈,所以不需要切换,然而这个函数是通用的,在用户的goroutine中也会创建goroutine,这时就需要进行栈的切换。

runtime/proc.go : 3232

  1. // Create a new g running fn with siz bytes of arguments.
  2. // Put it on the queue of g's waiting to run.
  3. // The compiler turns a go statement into a call to this.
  4. // Cannot split the stack because it assumes that the arguments
  5. // are available sequentially after &fn; they would not be
  6. // copied if a stack split occurred.
  7. //go:nosplit
  8. func newproc(siz int32, fn *funcval) {
  9. //函数调用参数入栈顺序是从右向左,而且栈是从高地址向低地址增长的
  10. //注意:argp指向fn函数的第一个参数,而不是newproc函数的参数
  11. //参数fn在栈上的地址+8的位置存放的是fn函数的第一个参数
  12. argp := add(unsafe.Pointer(&fn), sys.PtrSize)
  13. gp:= getg() //获取正在运行的g,初始化时是m0.g0
  14.  
  15. //getcallerpc()返回一个地址,也就是调用newproc时由call指令压栈的函数返回地址,
  16. //对于我们现在这个场景来说,pc就是CALLruntime·newproc(SB)指令后面的POPQ AX这条指令的地址
  17. pc := getcallerpc()
  18.  
  19. //systemstack的作用是切换到g0栈执行作为参数的函数
  20. //我们这个场景现在本身就在g0栈,因此什么也不做,直接调用作为参数的函数
  21. systemstack(func() {
  22. newproc1(fn, (*uint8)(argp), siz, gp, pc)
  23. })
  24. }

newproc1函数的第一个参数fn是新创建的goroutine需要执行的函数,注意这个fn的类型是funcval结构体类型,其定义如下:

  1. type funcval struct{
  2. fn uintptr
  3. // variable-size, fn-specific data here
  4. }

newproc1的第二个参数argp是fn函数的第一个参数的地址,第三个参数是fn函数的参数以字节为单位的大小,后面两个参数我们不用关心。这里需要注意的是,newproc1是在g0的栈上执行的。该函数很长也很重要,所以我们分段来看。

runtime/proc.go : 3248

  1. // Create a new g running fn with narg bytes of arguments starting
  2. // at argp. callerpc is the address of the go statement that created
  3. // this. The new g is put on the queue of g's waiting to run.
  4. func newproc1(fn *funcval, argp *uint8, narg int32, callergp *g, callerpc uintptr) {
  5. //因为已经切换到g0栈,所以无论什么场景都有 _g_ = g0,当然这个g0是指当前工作线程的g0
  6. //对于我们这个场景来说,当前工作线程是主线程,所以这里的g0 = m0.g0
  7. _g_ := getg()
  8.  
  9. ......
  10.  
  11. _p_ := _g_.m.p.ptr() //初始化时_p_ = g0.m.p,从前面的分析可以知道其实就是allp[0]
  12. newg := gfget(_p_) //从p的本地缓冲里获取一个没有使用的g,初始化时没有,返回nil
  13. if newg == nil {
  14. //new一个g结构体对象,然后从堆上为其分配栈,并设置g的stack成员和两个stackgard成员
  15. newg = malg(_StackMin)
  16. casgstatus(newg, _Gidle, _Gdead) //初始化g的状态为_Gdead
  17. //放入全局变量allgs切片中
  18. allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
  19. }
  20.  
  21. ......
  22.  
  23. //调整g的栈顶置针,无需关注
  24. totalSize := 4*sys.RegSize+uintptr(siz) +sys.MinFrameSize// extra space in case of reads slightly beyond frame
  25. totalSize += -totalSize&(sys.SpAlign-1) // align to spAlign
  26. sp := newg.stack.hi-totalSize
  27. spArg := sp
  28.  
  29. ......
  30.  
  31. if narg > 0 {
  32. //把参数从执行newproc函数的栈(初始化时是g0栈)拷贝到新g的栈
  33. memmove(unsafe.Pointer(spArg), unsafe.Pointer(argp), uintptr(narg))
  34. // ......
  35. }

这段代码主要从堆上分配一个g结构体对象并为这个newg分配一个大小为2048字节的栈,并设置好newg的stack成员,然后把newg需要执行的函数的参数从执行newproc函数的栈(初始化时是g0栈)拷贝到newg的栈,完成这些事情之后newg的状态如下图所示:

我们可以看到,经过前面的代码之后,程序中多了一个我们称之为newg的g结构体对象,该对象也已经获得了从堆上分配而来的2k大小的栈空间,newg的stack.hi和stack.lo分别指向了其栈空间的起止位置。

接下来我们继续分析newproc1函数。

runtime/proc.go : 3314

  1.  
  1. //把newg.sched结构体成员的所有成员设置为0
  2. memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
  3.  
  4. //设置newg的sched成员,调度器需要依靠这些字段才能把goroutine调度到CPU上运行。
  5. newg.sched.sp = sp //newg的栈顶
  6. newg.stktopsp = sp
  7. //newg.sched.pc表示当newg被调度起来运行时从这个地址开始执行指令
  8. //把pc设置成了goexit这个函数偏移1(sys.PCQuantum等于1)的位置,
  9. //至于为什么要这么做需要等到分析完gostartcallfn函数才知道
  10. newg.sched.pc = funcPC(goexit) + sys.PCQuantum// +PCQuantum so that previous instruction is in same function
  11. newg.sched.g = guintptr(unsafe.Pointer(newg))
  12.  
  13. gostartcallfn(&newg.sched, fn)//调整sched成员和newg的栈

这段代码首先对newg的sched成员进行了初始化,该成员包含了调度器代码在调度goroutine到CPU运行时所必须的一些信息,其中sched的sp成员表示newg被调度起来运行时应该使用的栈的栈顶,sched的pc成员表示当newg被调度起来运行时从这个地址开始执行指令,然而从上面的代码可以看到,new.sched.pc被设置成了goexit函数的第二条指令的地址而不是fn.fn,这是为什么呢?要回答这个问题,必须深入到gostartcallfn函数中做进一步分析。

  1. // adjust Gobuf as if it executed a call to fn
  2. // and then did an immediate gosave.
  3. func gostartcallfn(gobuf *gobuf, fv *funcval) {
  4. var fn unsafe.Pointer
  5. if fv != nil {
  6. fn = unsafe.Pointer(fv.fn) //fn: gorotine的入口地址,初始化时对应的是runtime.main
  7. } else {
  8. fn = unsafe.Pointer(funcPC(nilfunc))
  9. }
  10. gostartcall(gobuf, fn, unsafe.Pointer(fv))
  11. }

gostartcallfn首先从参数fv中提取出函数地址(初始化时是runtime.main),然后继续调用gostartcall函数。

  1. // adjust Gobuf as if it executed a call to fn with context ctxt
  2. // and then did an immediate gosave.
  3. func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
  4. sp := buf.sp//newg的栈顶,目前newg栈上只有fn函数的参数,sp指向的是fn的第一参数
  5. if sys.RegSize > sys.PtrSize {
  6. sp -= sys.PtrSize
  7. *(*uintptr)(unsafe.Pointer(sp)) = 0
  8. }
  9. sp -= sys.PtrSize//为返回地址预留空间,
  10. //这里在伪装fn是被goexit函数调用的,使得fn执行完后返回到goexit继续执行,从而完成清理工作
  11. *(*uintptr)(unsafe.Pointer(sp)) = buf.pc//在栈上放入goexit+1的地址
  12. buf.sp = sp//重新设置newg的栈顶寄存器
  13. //这里才真正让newg的ip寄存器指向fn函数,注意,这里只是在设置newg的一些信息,newg还未执行,
  14. //等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,
  15. //从而使newg得以在cpu上真正的运行起来
  16. buf.pc = uintptr(fn)
  17. buf.ctxt = ctxt
  18. }

gostartcall函数的主要作用有两个:

  1. 调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作;

  2. 重新设置newg.buf.pc 为需要执行的函数的地址,即fn,我们这个场景为runtime.main函数的地址。

调整完成newg的栈和sched成员之后,返回到newproc1函数,我们继续往下看,

  1.    
  1. newg.gopc = callerpc //主要用于traceback
  2. newg.ancestors = saveAncestors(callergp)
  3. //设置newg的startpc为fn.fn,该成员主要用于函数调用栈的traceback和栈收缩
  4. //newg真正从哪里开始执行并不依赖于这个成员,而是sched.pc
  5. newg.startpc = fn.fn
  6.  
  7. ......
  8.  
  9. //设置g的状态为_Grunnable,表示这个g代表的goroutine可以运行了
  10. casgstatus(newg, _Gdead, _Grunnable)
  11.  
  12. ......
  13.  
  14. //把newg放入_p_的运行队列,初始化的时候一定是p的本地运行队列,其它时候可能因为本地队列满了而放入全局队列
  15. runqput(_p_, newg, true)
  16.  
  17. ......
  18. }

newproc1函数最后这点代码比较直观,首先设置了几个与调度无关的成员变量,然后修改newg的状态为_Grunnable并把其放入了运行队列,到此程序中第一个真正意义上的goroutine已经创建完成。

这时newg也就是main goroutine的状态如下图所示:

这个图看起来比较复杂,因为表示指针的箭头实在是太多了,这里对其稍作一下解释。

  • 首先,main goroutine对应的newg结构体对象的sched成员已经完成了初始化,图中只显示了pc和sp成员,pc成员指向了runtime.main函数的第一条指令,sp成员指向了newg的栈顶内存单元,该内存单元保存了runtime.main函数执行完成之后的返回地址,也就是runtime.goexit函数的第二条指令,预期runtime.main函数执行完返回之后就会去执行runtime.exit函数的CALL runtime.goexit1(SB)这条指令;

  • 其次,newg已经放入与当前主线程绑定的p结构体对象的本地运行队列,因为它是第一个真正意义上的goroutine,还没有其它goroutine,所以它被放在了本地运行队列的头部;

  • 最后,newg的m成员为nil,因为它还没有被调度起来运行,也就没有跟任何m进行绑定。

这一节我们分析了程序中第一个goroutine也就是main goroutine的创建,下一节我们继续分析它是怎么被主工作线程调度到CPU上去执行的。

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

  1. Go语言调度器之调度main goroutine(14)

    本文是<Go语言调度器源代码情景分析>系列的第14篇,也是第二章的第4小节. 上一节我们通过分析main goroutine的创建详细讨论了goroutine的创建及初始化流程,这一节我们 ...

  2. Go语言调度器之盗取goroutine(17)

    本文是<Go语言调度器源代码情景分析>系列的第17篇,也是第三章<Goroutine调度策略>的第2小节. 上一小节我们分析了从全局运行队列与工作线程的本地运行队列获取goro ...

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

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

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

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

  5. 详解Go语言调度循环源码实现

    转载请声明出处哦~,本篇文章发布于luozhiyun的博客: https://www.luozhiyun.com/archives/448 本文使用的go的源码15.7 概述 提到"调度&q ...

  6. go语言调度器源代码情景分析之一:开篇语

    专题简介 本专题以精心设计的情景为线索,结合go语言最新1.12版源代码深入细致的分析了goroutine调度器实现原理. 适宜读者 go语言开发人员 对线程调度器工作原理感兴趣的工程师 对计算机底层 ...

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

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

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

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

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

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

随机推荐

  1. C++ STL multiset

    multiset的例子,允许集合内的元素是重复的 #include <iostream> #include <set> using namespace std; int mai ...

  2. 201871010113-刘兴瑞《面向对象程序设计(java)》第六-七周学习总结

    项目 内容 这个作业属于哪个课程 <任课教师博客主页链接> https://www.cnblogs.com/nwnu-daizh/ 这个作业的要求在哪里 <作业链接地址>htt ...

  3. 20191031 牛客网CSP-S Round2019-2

    花了 \(50min\) 打了 \(130\) 分的暴力... T2想到正解之后开始 VP CF了...

  4. ionic4 新建 - 报错

    npm install -g cordova ionic 安装依赖 ionic start myApp tabs 新建项目 ionic g page name name为页面名称 新建组件 创建公共模 ...

  5. Paper | UNet++: A Nested U-Net Architecture for Medical Image Segmentation

    目录 1. 故事 2. UNet++ 3. 实验 3.1 设置 作者的解读,讲得非常好非常推荐:https://zhuanlan.zhihu.com/p/44958351 这篇文章提出的嵌套U-Net ...

  6. windows server 2008配置多用户远程连接

    打开开始菜单->管理工具->远程桌面服务->远程桌面会话主机配置 右键限制每个用户只能进行一个会话->常规->勾掉限制每个与用户只能进行一个会话 右键远程桌面授权模式-& ...

  7. 物联网架构成长之路(39)-Bladex开发框架环境搭建

    0.前言 上一篇博客已经介绍了,阶段性小结.目前第一版的物联网平台已经趋于完成.框架基本不变了,剩下就是调整一些UI,还有配合硬件和市场那边,看看怎么推广这个平台.能不能挣点外快.第一版系统虽然简陋, ...

  8. spring 注解AOP

     aspectAnnotation的切面信息,加到了AnnotationAwareAspectJAutoProxyCreator的advisorsCache属性里面去了. 解析annotationSe ...

  9. SEO-------- 了解

    SEO(Search Engine Optimization) 译为:搜索引擎优化,是一种透过了解搜索引擎的运作规则来调整网站,以及提高目的的网站在有关搜索引擎内的排名方式. 目的:为了让用户更快的搜 ...

  10. influxdb安装和学习

    安装 https://docs.docker.com/samples/library/influxdb/ 先启动,创建admin用户 docker run -d --name influxdb -p ...