Go runtime 调度器精讲(四):运行 main goroutine

news/2024/11/15 12:51:09/文章来源:https://www.cnblogs.com/xingzheanan/p/18412514

原创文章,欢迎转载,转载请注明出处,谢谢。


0. 前言

皇天不负有心人,终于我们到了运行 main goroutine 环节了。让我们走起来,看看一个 goroutine 到底是怎么运行的。

1. 运行 goroutine

稍微回顾下前面的内容,第一讲 Go 程序初始化,介绍了 Go 程序是怎么进入到 runtime 的,随之揭开 runtime 的面纱。第二讲,介绍了调度器的初始化,要运行 goroutine 调度器是必不可少的,只有调度器准备就绪才能开始工作。第三讲,介绍了 main goroutine 是如何创建出来的,只有创建一个 goroutine 才能开始运行,否则执行代码无从谈起。这一讲,我们继续介绍如何运行 main goroutine。

我们知道 main goroutine 此时处于 _Grunnable 状态,要使得 main goroutine 处于 _Grunning 状态,还需要将它和 P 绑定。毕竟 P 是负责调度任务给线程处理的,只有和 P 绑定线程才能处理相应的 goroutine。

1.1 绑定 P

回到代码 newproc

func newproc(fn *funcval) {gp := getg()pc := getcallerpc()systemstack(func() {newg := newproc1(fn, gp, pc)        // 创建 newg,这里是 main goroutinepp := getg().m.p.ptr()              // 获取当前工作线程绑定的 P,这里是 g0.m.p = allp[0]runqput(pp, newg, true)             // 绑定 allp[0] 和 main goroutineif mainStarted {                    // mainStarted 还未启动,这里是 falsewakep()}})
}

进入 runqput 函数查看 main goroutine 是怎么和 allp[0] 绑定的:

// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the pp.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(pp *p, gp *g, next bool) {...if next {retryNext:oldnext := pp.runnext                                               // 从 P 的 runnext 获取下一个将要执行的 goroutine,这里 pp.runnext = nilif !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {         // 将 P 的 runnext 更新为 gp,这里的 gp 是 main goroutinegoto retryNext  }if oldnext == 0 {                                                   // 如果 P 原来要执行的 goroutine 是 nil,则直接返回,这里创建的是 main goroutine 将直接返回return}gp = oldnext.ptr()                                                  // 如果不为 nil,表示是一个将要执行的 goroutine。后续对这个被赶走的 goroutine 进行处理}retry:h := atomic.LoadAcq(&pp.runqhead)t := pp.runqtailif t-h < uint32(len(pp.runq)) {                                         // P 的队尾和队头指向本地运行队列 runq,如果当前队列长度小于 runq 则将赶走的 goroutine 添加到队尾pp.runq[t%uint32(len(pp.runq))].set(gp)atomic.StoreRel(&pp.runqtail, t+1)return}if runqputslow(pp, gp, h, t) {                                          // 如果当前 P 的队列长度等于不小于 runq,表示本地队列满了,将赶走的 goroutine 添加到全局队列中return}goto retry
}

runqput 函数绑定 P 和 goroutine,同时处理 P 中的本地运行队列。基本流程在注释中已经介绍的比较清楚了。

这里我们绑定的是 main goroutine,直接绑定到 P 的 runnext 成员即可。不过对于 runqput 的整体处理来说,还需要在介绍一下 runqputslow 函数:

// Put g and a batch of work from local runnable queue on global queue.
// Executed only by the owner P.
func runqputslow(pp *p, gp *g, h, t uint32) bool {var batch [len(pp.runq)/2 + 1]*g                                                // 定义 batch,长度是 P.runq 的一半。batch 用来装 g// First, grab a batch from local queue.n := t - hn = n / 2if n != uint32(len(pp.runq)/2) {throw("runqputslow: queue is not full")}for i := uint32(0); i < n; i++ {batch[i] = pp.runq[(h+i)%uint32(len(pp.runq))].ptr()                        // 从 P 的 runq 中拿出一半的 g 到 batch 中}if !atomic.CasRel(&pp.runqhead, h, h+n) { // cas-release, commits consume       // 更新 P 的 runqhead 的指向,它指向的是本地队列的头return false}batch[n] = gp                                                                   // 将赶走的 goroutine 放到 batch 尾if randomizeScheduler {                                                         // 如果是随机调度的话,这里还要打乱 batch 中 g 的顺序以保证随机性for i := uint32(1); i <= n; i++ {j := fastrandn(i + 1)batch[i], batch[j] = batch[j], batch[i]}}// Link the goroutines.for i := uint32(0); i < n; i++ {batch[i].schedlink.set(batch[i+1])                                          // batch 中 goroutine 的 schedlink 按顺序指向其它 goroutine,构造一个链表}var q gQueue                                                                    // gQueue 是一个包含头和尾的指针,将头和尾指针分别指向 batch 的头 batch[0] 和尾 batch[n]q.head.set(batch[0])q.tail.set(batch[n])// Now put the batch on global queue.lock(&sched.lock)                                                               // 操作全局变量 sched,为 sched 加锁globrunqputbatch(&q, int32(n+1))                                                // globrunqputbatch 将 q 指向的 batch 传给全局变量 schedunlock(&sched.lock)                                                             // 解锁return true
}func globrunqputbatch(batch *gQueue, n int32) {assertLockHeld(&sched.lock)sched.runq.pushBackAll(*batch)                                                  // 这里将 sched.runq 指向 batchsched.runqsize += n                                                             // sched 的 runqsize 加 n,n 表示新添加进 sched.runq 的 goroutine*batch = gQueue{}
}

如果 P 的本地队列已满,则在 runqputslow 中拿出本地队列的一半 goroutine 放到 sched.runq 全局队列中。这里本地队列是固定长度,容量有限,用数组来表示队列。而全局队列长度是不固定的,用链表来表示全局队列。

我们可以画出示意图如下图,注意示意图只是加深理解,和我们这里运行 main goroutine 的流程没关系:

image

1.2 运行 main goroutine

P 和 main goroutine 绑定之后,理论上已经可以运行 main goroutine 了。继续看代码执行的什么:

> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:358 (PC: 0x45434a)
Warning: debugging optimized function353:         PUSHQ   AX354:         CALL    runtime·newproc(SB)355:         POPQ    AX356:357:         // start this M
=> 358:         CALL    runtime·mstart(SB)      // 调用 mstart 意味着当前线程开始工作了;mstart 是一个永不返回的函数359:360:         CALL    runtime·abort(SB)       // mstart should never return361:         RET362:

向下执行:

(dlv) si
> runtime.mstart() /usr/local/go/src/runtime/asm_amd64.s:394 (PC: 0x4543c0)
Warning: debugging optimized function
TEXT runtime.mstart(SB) /usr/local/go/src/runtime/asm_amd64.s
=>      asm_amd64.s:394 0x4543c0        e87b290000      call $runtime.mstart0asm_amd64.s:395 0x4543c5        c3              ret

调用 runtime.mstart0

func mstart0() {gp := getg()                // gp = g0...mstart1()...
}

调用 mstart1

func mstart1() {gp := getg()                                    // gp = g0// 保存线程执行的栈,当线程进入 schedule 函数就不会返回,这意味着线程执行的栈是可复用的gp.sched.g = guintptr(unsafe.Pointer(gp))gp.sched.pc = getcallerpc()gp.sched.sp = getcallersp()...if fn := gp.m.mstartfn; fn != nil {             // 执行 main goroutine,fn == nilfn()}...schedule()                                      // 线程进入 schedule 调度循环,该循环是永不返回的
}

进入 schedule

func schedule() {mp := getg().m                                  // mp = m0...
top:pp := mp.p.ptr()                                // pp = allp[0]pp.preempt = false// 线程有两种状态,自旋和非自旋。自旋表示线程没有工作,在找工作阶段。非自旋表示线程正在工作// 这里如果线程自旋,但是线程绑定的 P 本地队列有 goroutine 则报异常if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {throw("schedule: spinning with local work")}// blocks until work is availablegp, inheritTime, tryWakeP := findRunnable()     // 找一个处于 _Grunnable 状态的 goroutine 出来...execute(gp, inheritTime)                        // 运行该 goroutine,这里运行的是 main goroutine
}

schedule 中的重点是 findRunaable 函数,进入该函数:

// Finds a runnable goroutine to execute.
// Tries to steal from other P's, get g from local or global queue, poll network.
// tryWakeP indicates that the returned goroutine is not normal (GC worker, trace
// reader) so the caller should try to wake a P.
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {mp := getg().m                      // mp = m0top:pp := mp.p.ptr()                    // pp = allp[0] = p0...// 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.// 官方的注释对这一段逻辑已经解释的很详细了,我们就跳过了,偷个懒if pp.schedtick%61 == 0 && sched.runqsize > 0 {lock(&sched.lock)gp := globrunqget(pp, 1)unlock(&sched.lock)if gp != nil {return gp, false, false}}// local runq// 从 P 的本地队列找 goroutineif gp, inheritTime := runqget(pp); gp != nil {return gp, inheritTime, false}...
}

findRunnable 中首先为了公平,每调用 schedule 函数 61 次就要从全局可运行队列中获取 goroutine,防止全局队列中的 goroutine 被“饿死”。接着从 P 的本地队列中获取 goroutine,这里运行的是 main goroutine 将从 P 的本地队列中获取 goroutine。查看 runqget

func runqget(pp *p) (gp *g, inheritTime bool) {// If there's a runnext, it's the next G to run.next := pp.runnext// If the runnext is non-0 and the CAS fails, it could only have been stolen by another P,// because other Ps can race to set runnext to 0, but only the current P can set it to non-0.// Hence, there's no need to retry this CAS if it fails.if next != 0 && pp.runnext.cas(next, 0) {return next.ptr(), true}for {h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumerst := pp.runqtailif t == h {return nil, false}gp := pp.runq[h%uint32(len(pp.runq))].ptr()if atomic.CasRel(&pp.runqhead, h, h+1) { // cas-release, commits consumereturn gp, false}}
}

注释已经比较详细了,首先拿到 P 的 runnext 作为要运行的 goroutine。如果拿到的 goroutine 不是空,则重置 runnext,并且返回拿到的 goroutine。如果拿到的 goroutine 是空的,则从本地队列中拿 goroutine。

通过 findRunnable 我们拿到可执行的 main goroutine。接着调用 execute 执行 main goroutine。

进入 execute

func execute(gp *g, inheritTime bool) {mp := getg().m                                  // mp = m0mp.curg = gp                                    // mp.curg = g1gp.m = mp                                       // gp.m = m0casgstatus(gp, _Grunnable, _Grunning)           // 更新 goroutine 的状态为 _Grunninggp.waitsince = 0gp.preempt = falsegp.stackguard0 = gp.stack.lo + stackGuardif !inheritTime {mp.p.ptr().schedtick++}...gogo(&gp.sched)                             
}

execute 中将线程和 gouroutine 关联起来,更新 goroutine 的状态,然后调用 gogo 完成从 g0 栈到 gp 栈的切换,gogo 是用汇编编写的,原因如下:

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

进入 gogogogo 传入的是 goroutine 的 sched 结构:

TEXT runtime·gogo(SB), NOSPLIT, $0-8MOVQ	buf+0(FP), BX		                // gobufMOVQ	gobuf_g(BX), DX                     // gobuf 的 g 赋给 DXMOVQ	0(DX), CX		                    // make sure g != nilJMP	gogo<>(SB)                              // 跳转到私有函数 gogo<>TEXT gogo<>(SB), NOSPLIT, $0get_tls(CX)                                 // 获取当前线程 tls 中的 goroutineMOVQ	DX, g(CX)MOVQ	DX, R14		                        // set the g registerMOVQ	gobuf_sp(BX), SP	                // restore SPMOVQ	gobuf_ret(BX), AX                   // AX = gobuf.retMOVQ	gobuf_ctxt(BX), DX                  // DX = gobuf.ctxtMOVQ	gobuf_bp(BX), BP                    // BP = gobuf.bpMOVQ	$0, gobuf_sp(BX)	                // clear to help garbage collectorMOVQ	$0, gobuf_ret(BX)MOVQ	$0, gobuf_ctxt(BX)MOVQ	$0, gobuf_bp(BX)MOVQ	gobuf_pc(BX), BX                    // BX = gobuf.pcJMP	BX                                      // 跳转到 gobuf.pc 

gogo<> 中完成 g0 到 gp 栈的切换:MOVQ gobuf_sp(BX), SP,并且跳转到 gobuf.pc 执行。我们看 gobuf.pc 要执行的指令地址是什么:

asm_amd64.s:421 0x45363a        488b5b08                mov rbx, qword ptr [rbx+0x8]
=>      asm_amd64.s:422 0x45363e        ffe3                    jmp rbx
(dlv) regsRbx = 0x000000000042ee80

执行 JMP BX 跳转到 0x000000000042ee80

(dlv) si
> runtime.main() /usr/local/go/src/runtime/proc.go:144 (PC: 0x42ee80)
Warning: debugging optimized function
TEXT runtime.main(SB) /usr/local/go/src/runtime/proc.go
=>      proc.go:144     0x42ee80        4c8d6424e8      lea r12, ptr [rsp-0x18]

终于我们揭开了它的神秘面纱,这个指令指向的是 runtime.main 函数的第一条汇编指令。也就是说,跳转到了 runtime.main,这个函数会调用我们 main 包下的 main 函数。查看 runtime.main 函数:

// The main goroutine.
func main() {mp := getg().m                          // mp = m0if goarch.PtrSize == 8 {maxstacksize = 1000000000           // 扩栈,栈的最大空间是 1GB} else {maxstacksize = 250000000}...// Allow newproc to start new Ms.mainStarted = trueif GOARCH != "wasm" { // no threads on wasm yet, so no sysmonsystemstack(func() {newm(sysmon, nil, -1)           // 开启监控线程,这个线程很重要,我们后续会讲,这里先放着,让 sysmon 飞一会儿})}...// make an indirect call, as the linker doesn't know the address of the main package when laying down the runtimefn := main_main                         // 这里的 main_main 链接的是 main 包中的 main 函数fn()                                    // 执行 main.main...runExitHooks(0)exit(0)                                 // 执行完 main.main 之后调用 exit 退出线程for {var x *int32*x = 0}
}

runtime.main 是在 main goroutine 栈中执行的。在函数中调用 main.main 执行我们写的用户代码:

(dlv) n
266:            fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
=> 267:         fn()
(dlv) s
> main.main() ./hello.go:3 (PC: 0x45766a)
Warning: debugging optimized function1: package main2:
=>   3: func main() {4:         println("Hello World")5: }

main.main 执行完之后线程调用 exit(0) 退出程序。

2. 小结

至此我们的 main goroutine 就执行完了,花了四讲才算走通了一个 main goroutine,真不容易呀。当然,关于 Go runtime 调度器的故事还没结束,下一讲我们继续。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/796641.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Python网页应用开发神器Dash 2.18.1稳定版本来啦

本文示例代码已上传至我的Github仓库:https://github.com/CNFeffery/dash-master Gitee同步仓库地址:https://gitee.com/cnfeffery/dash-master大家好我是费老师,上周Dash发布了2.18.0新版本,并于今天发布了可稳定使用的2.18.1版本(自古.1版本最稳✌),今天的文章中就将针…

吊打面试官!从多维度理解架构

大家好,我是汤师爷~ 在工作当中,我们经常会听到以下说法:产品负责人说,现在的业务架构太复杂,需要仔细梳理下。 技术领导说,这个项目很复杂,需要做下系统架构方案评审。 研发经理说,这次秒杀活动访问量非常大,需要用到高并发架构方案。 一线研发说,互联网大厂都会用到…

数组的下标越界

1.数组下标 数组的下标通俗来讲是数组中数据的代号,例如a[0]=1,即数组下标0代表1 2.下标越界 假设创建的数组内存为3(a=new int[3]),则数组的下标为0,1,2. 如果多输出了下标3,则会显示下标越界。 1.正常数组的输出2.下标越界的输出(即提示你数组下标越界异常!) 3.数组…

转载:国产操作系统麒麟v10、UOS在线打开excel文件并动态赋值

在实际的开发过程中,经常会遇到数据库中的数据填充到excel生成一份正式文件的功能,PageOffice客户端控件支持在线预览Excel文件时,通过Workbook对象来实现对Excel文件的数据填充功能,如果只是简单的填充一下数据,那么通过调用Sheet对象的openCell方法获取到Cell对象并赋值…

构建 openEuler Embedded 24.03 LTS (Phytium BSP)

Ubuntu 24.04 构建 openEuler Embedded 24.03 LTS (Phytium BSP) 参考链接:Phytium-OpenEuler-Embedded-BSP - Gitee 1 介绍 本文档介绍如何在Ubuntu 24.04上构建openEuler Embedded 24.03 LTS (Phytium BSP)。对计算机配置有要求。 2 脚本 将以下内容复制到新文件oe_phy.sh,…

架构师备考的一些思考(四)

前言 对于数学,我们之前学的是对的,但不是真的,所以我们没有数学思维。 对于计算机,我们学校教的是对的,但不是真的,所以仅仅从学校学习知识的应届毕业生,不论985,211,本科,专科都一样,都是一张白纸,啥也不会。 案例分析 案例分析是5选3,第一题必答。问题一的类型 …

字符串-面试题

字符串的相关面试题String 的不可变性String底层是一个 final修饰的字符数组,当改变String的值时,会在常量池生成新的字符串,字符数组的地址指向常量池中新的字符串StringBuffer的扩容原理String、StringBuffer、StringBuilder三者对比Strings是不可变的字符序列,底层是一个…

单考一个OCP认证?还是OCP和OCM认证都要考?

​ Oracle的OCP认证是数据库行业非常经典的一个认证,从事数据库行业的人都建考一个 Oracle OCP 认证。 OCP认证内容包括: OCA部分:数据库基础知识、SQL 语言使用、基本的数据库管理技能等,如数据库安装与配置、理解数据库架构、管理数据库实例、备份和恢复,以及使用 SQL 进…

为什么那么多开源软件都用netty来做网络通信编程框架?

1、用netty来做网络通信编程框架而不是我们自己去基于JDK NIO来编程的好处有如下这些: (1)、netty支持常见的应用层协议(如:HTTP、FTP、DNS等),还可以支持自定义协议; (2)、netty可以自动解决网络编程当中的粘包与半包问题; (3)、netty还可以支持流量整形; (4)、netty对…

USB总线-Linux内核USB3.0设备控制器中断处理程序分析(九)

1.概述 USB设备枚举、请求处理、数据交互都涉及USB设备控制器中断。当有事件发生时,USB设备控制器首先将事件信息通过DMA写入到事件缓冲区中,然后向CPU发出中断,随后CPU调用中断处理函数开始处理中断事件。 2.事件 dwc3 USB设备控制器事件使用dwc3_event数据结构描述,由4个…

通义灵码企业代码补全增强使用实践

通义灵码提供了企业代码补全增强的能⼒,在开发者使⽤通义灵码 IDE 插件的⾏间代码补全时,可以结合企业上传的代码库作为上下⽂进⾏⾏间代码补全,使代码补全更加贴合企业代码规范、业务特点。本⽂将分享如何构建⾼质量的企业代码库,以及开发者在前端和后端开发场景的使⽤实践…

LeetCode算法—分治法

纵有疾风起;人生不言弃思路:分治法的核心思想是“分而治之”,即将一个复杂的问题分成多个较小的子问题,分别求解这些子问题,然后将子问题的解合并,得到原问题的解。具体到求众数的问题上,分治法通过递归地将数组分成两部分,分别找出每一部分的众数,最后通过合并步骤来…