23

Golang源码学习:调度逻辑(三)工作线程的执行流程与调度循环 - 虾敏四把刀 - 博客园

 4 years ago
source link: https://www.cnblogs.com/flhs/p/12682881.html?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文内容主要分为三部分:

  1. main goroutine 的调度运行
  2. 非 main goroutine 的退出流程
  3. 工作线程的执行流程与调度循环。

main goroutine 的调度运行#

runtime·rt0_go中在调用完runtime.newproc创建main goroutine后,就调用了runtime.mstart。让我们来分析一下这个函数。

mstart#

mstart没什么太多工作,然后就调用了mstart1。

func mstart() {
	_g_ := getg()
        // 在启动阶段,_g_.stack早就完成了初始化,所以osStack是false,下面被省略的也不会执行。
	osStack := _g_.stack.lo == 0 
	......
	_g_.stackguard0 = _g_.stack.lo + _StackGuard
	_g_.stackguard1 = _g_.stackguard0
	mstart1()
        ......
	mexit(osStack)
}

mstart1#

  • 调用save保存g0的状态
  • 处理信号相关
  • 调用 schedule 开始调度
func mstart1() {
	_g_ := getg()

	if _g_ != _g_.m.g0 {
		throw("bad runtime·mstart")
	}
	save(getcallerpc(), getcallersp())	// 保存调用mstart1的函数(mstart)的 pc 和 sp。
	asminit()				// 空函数
	minit()					// 信号相关

	if _g_.m == &m0 {			// 初始化时会执行这里,也是信号相关
		mstartm0()
	}

	if fn := _g_.m.mstartfn; fn != nil {	// 初始化时 fn = nil,不会执行这里
		fn()
	}

	if _g_.m != &m0 {			// 不是m0的话,没有p。绑定一个p
		acquirep(_g_.m.nextp.ptr())
		_g_.m.nextp = 0
	}
	schedule()
}

save(pc, sp uintptr) 保存调度信息#

保存当前g(初始化时为g0)的状态到sched字段中。

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_))
	if _g_.sched.ctxt != nil {
		badctxt()
	}
}

schedule 开始调度#

调用globrunqget、runqget、findrunnable获取一个可执行的g

func schedule() {
	_g_ := getg()	// g0
        ......
	var gp *g	// 初始化时,经过下面一系列查找,会找到main goroutine,因为目前为止整个运行时只有这一个g(除了g0)。
	var inheritTime bool
        ......
	if gp == nil {
                // 该p上每进行61次就从全局队列中获取一个g
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}
	if gp == nil {
                // 从p的runq中获取一个g
		gp, inheritTime = runqget(_g_.m.p.ptr())
		// We can see gp != nil here even if the M is spinning,
		// if checkTimers added a local goroutine via goready.
	}
	if gp == nil {
                // 寻找可执行的g,会尝试从本地,全局运行对列获取,如果没有,从其他p那里偷取。
		gp, inheritTime = findrunnable() // blocks until work is available
	}
	......
	execute(gp, inheritTime)
}

execute:安排g在当前m上运行#

  • 被调度的 g 与 m 相互绑定
  • 更改g的状态为 _Grunning
  • 调用 gogo 切换到被调度的g上
func execute(gp *g, inheritTime bool) {
	_g_ := getg()	// g0

	_g_.m.curg = gp	// 与下面一行是 gp 和 m 相互绑定。gp 其实就是 main goroutine
	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)
}

gogo(buf *gobuf)#

在本方法下面的讲解中将使用newg代指被调度的g。

gogo函数是用汇编实现的。其作用是:加载newg的上下文,跳转到gobuf.pc指向的函数。

// go/src/runtime/asm_amd64.s
TEXT runtime·gogo(SB), NOSPLIT, $16-8
	MOVQ	buf+0(FP), BX		// bx = &gp.sched
	MOVQ	gobuf_g(BX), DX		// dx = gp.sched.g ,也就是存储的 newg 指针
	MOVQ	0(DX), CX		// make sure g != nil
	get_tls(CX)
	MOVQ	DX, g(CX)		// newg指针设置到tls
	MOVQ	gobuf_sp(BX), SP	// 下面四条是加载上下文到cpu寄存器。
	MOVQ	gobuf_ret(BX), AX
	MOVQ	gobuf_ctxt(BX), DX
	MOVQ	gobuf_bp(BX), BP
	MOVQ	$0, gobuf_sp(BX)	// 下面四条是清零,减少gc的工作量。
	MOVQ	$0, gobuf_ret(BX)
	MOVQ	$0, gobuf_ctxt(BX)
	MOVQ	$0, gobuf_bp(BX)
	MOVQ	gobuf_pc(BX), BX	// gobuf.pc 存储的是要执行的函数指针,初始化时此函数为runtime.main
	JMP	BX			// 跳转到要执行的函数

runtime.main:main函数的执行#

在上面gogo执行最后的JMP指令,其实就是跳转到了runtime.main。

func main() {
	g := getg()		// 获取当前g,已经不是g0了,我们暂且称为maing
        
	if sys.PtrSize == 8 {	// 64位系统,栈最大为1GB
		maxstacksize = 1000000000
	} else {
		maxstacksize = 250000000
	}
	mainStarted = true
        // 启动监控进程,抢占调度就是在这里实现的
	if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
		systemstack(func() {
			newm(sysmon, nil)
		})
	}
        ......
	doInit(&runtime_inittask)	// 调用runtime的初始化函数
        ......
	runtimeInitTime = nanotime()	// 记录世界开始时间
	gcenable()			// 开启gc
	......
	doInit(&main_inittask)		// 调用main的初始化函数
        ......
	fn := main_main			// 调用main.main,也就是我们经常写hello world的main。
	fn()
        ......
	exit(0)				// 退出
}

runtime.main主要做了以下的工作:

  • 启动监控进程。
  • 调用runtime的初始化函数。
  • 开启gc。
  • 调用main的初始化函数。
  • 调用main.main,执行完后退出。

非 main goroutine 的退出流程#

首先明确一点,无论是main goroutine还是非main goroutine的都是调用newproc创建的,所以在调度上基本是一致的。

之前的文章中说过,在gostartcall函数中,会将goroutine要执行的函数fn伪造成是被goexit调用的。但是,当fn是runtime.main的时候是没有用的,因为在runtime.main末尾会调用exit(0)退出程序。所以,这只对非main goroutine起作用。让我们简单验证一下。

先给出一个简单的例子:

package main

import "fmt"

func main() {
	ch := make(chan int)
	go foo(ch)
	fmt.Println(<-ch)
}

func foo(ch chan int) {
	ch <- 1
}

dlv调试一波:

root@xiamin:~/study# dlv debug foo.go
(dlv) b main.foo // 打个断点
Breakpoint 1 set at 0x4ad86f for main.foo() ./foo.go:11
(dlv) c
> main.foo() ./foo.go:11 (hits goroutine(6):1 total:1) (PC: 0x4ad86f)
     6:		ch := make(chan int)
     7:		go foo(ch)
     8:		fmt.Println(<-ch)
     9:	}
    10:
=>  11:	func foo(ch chan int) {
    12:		ch <- 1
    13:	}
(dlv) bt // 可以看到调用栈中确实存在goexit
0  0x00000000004ad86f in main.foo
   at ./foo.go:11
1  0x0000000000463df1 in runtime.goexit
   at /root/go/src/runtime/asm_amd64.s:1373

// 此处执行三次 s,得到以下结果,确实是回到了goexit。

> runtime.goexit() /root/go/src/runtime/asm_amd64.s:1374 (PC: 0x463df1)
  1370:	// The top-most function running on a goroutine
  1371:	// returns to goexit+PCQuantum.
  1372:	TEXT runtime·goexit(SB),NOSPLIT,$0-0
  1373:		BYTE	$0x90	// NOP
=>1374:		CALL	runtime·goexit1(SB)	// does not return
  1375:		// traceback from goexit1 must hit code range of goexit
  1376:		BYTE	$0x90	// NOP

我们暂且将关联foo的g称之为foog,接下来我们看一下它的退出流程。

goexit#

TEXT runtime·goexit(SB),NOSPLIT,$0-0
	BYTE	$0x90	// NOP
	CALL	runtime·goexit1(SB)	// does not return
	// traceback from goexit1 must hit code range of goexit
	BYTE	$0x90	// NOP

goexit1#

func goexit1() {
	if raceenabled {
		racegoend()
	}
	if trace.enabled {
		traceGoEnd()
	}
	mcall(goexit0)
}

goexit和goexit1没什么可说的,看一下mcall

mcall(fn func(*g))#

mcall的参数是个函数fn,而fn有个参数是*g,此处fn是goexit0。

mcall是由汇编编写的:

TEXT runtime·mcall(SB), NOSPLIT, $0-8
	MOVQ	fn+0(FP), DI	// 此处 di 存储的是 funcval 结构体指针,funcval.fn 指向的是 goexit0。

	get_tls(CX)
	MOVQ	g(CX), AX	// 此处 ax 中存储的是foog

        // 保存foog的上下文
	MOVQ	0(SP), BX	// caller's PC。mcall的返回地址,此处就是 goexit1 调用 mcall 时的pc
	MOVQ	BX, (g_sched+gobuf_pc)(AX)	// foog.sched.pc = caller's PC
	LEAQ	fn+0(FP), BX			// caller's SP。
	MOVQ	BX, (g_sched+gobuf_sp)(AX)	// foog.sched.sp = caller's SP
	MOVQ	AX, (g_sched+gobuf_g)(AX)	// foog.sched.g = foog
	MOVQ	BP, (g_sched+gobuf_bp)(AX)	// foog.sched.bp = bp

        // 切换到m.g0和它的栈,调用fn。
	MOVQ	g(CX), BX			// 此处 bx 中存储的是foog
	MOVQ	g_m(BX), BX			// bx = foog.m
	MOVQ	m_g0(BX), SI			// si = m.g0
	CMPQ	SI, AX				// if g == m->g0 call badmcall
	JNE	3(PC)				// 上面的结果不相等就跳转到下面第三行。
	MOVQ	$runtime·badmcall(SB), AX
	JMP	AX
	MOVQ	SI, g(CX)			// g = m->g0。m.g0设置到tls
	MOVQ	(g_sched+gobuf_sp)(SI), SP	// sp = m->g0->sched.sp。设置g0栈.
	PUSHQ	AX				// fn的参数压栈,ax = foog
	MOVQ	DI, DX
	MOVQ	0(DI), DI			// 读取 funcval 结构的第一个成员,也就是 funcval.fn,此处是goexit0。
	CALL	DI				// 调用 goexit0(foog)。
	POPQ	AX
	MOVQ	$runtime·badmcall2(SB), AX
	JMP	AX
	RET

在此场景下,mcall做了以下工作:保存foog的上下文。切换到g0及其栈,调用传入的方法,并将foog作为参数。

可以看到mcall与gogo的作用正好相反:

  • gogo实现了从g0切换到某个goroutine,执行关联函数。
  • mcall实现了保存某个goroutine,切换到g0及其栈,并调用fn函数,其参数就是被保存的goroutine指针。

goexit0#

func goexit0(gp *g) {
	_g_ := getg()	// g0

	casgstatus(gp, _Grunning, _Gdead)	// 更改gp状态为_Gdead
	if isSystemGoroutine(gp, false) {
		atomic.Xadd(&sched.ngsys, -1)
	}
        // 下面的一段就是清零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
	......
	dropg()				// 解绑gp与当前m。_g_.m.curg.m = nil ; _g_.m.curg = nil 。
        ......
	gfput(_g_.m.p.ptr(), gp)	// 放入空闲列表。如果本地队列太多,会转移一部分到全局队列。
	......
	schedule()			// 重新调度
}

goexit0做了以下工作:

  • 将gp属性清零与m解绑
  • gfput 放入空闲列表
  • schedule 重新调度

工作线程的执行流程与调度循环#

以下给出一个工作线程的执行流程简图:

可以看到工作线程的执行是从mstart开始的。schedule->......->goexit0->schedule形成了一个调度循环。

高度概括一下执行流程与调度循环:

  • mstart:主要是设置g0.stackguard0,g0.stackguard1。
  • mstart1:调用save保存callerpc和callerpc到g0.sched。然后调用schedule开始调度循环。
  • schedule:获得一个可执行的g。下面用gp代指。
  • execute(gp *g, inheritTime bool):绑定gp与当前m,状态改为_Grunning。
  • gogo(buf *gobuf):加载gp的上下文,跳转到buf.pc指向的函数。
  • 执行buf.pc指向函数
  • goexit->goexit1:调用mcall(goexit0)。
  • mcall(fn func(*g)):保存当前g(也就是gp)的上下文;切换到g0及其栈,调用fn,参数为gp。
  • goexit0(gp *g):清零gp的属性,状态_Grunning改为_Gdead;dropg解绑m和gp;gfput放入队列;schedule重新调度。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK