Go 的 GMP 模型真的很"简单"

102 天前
 tigerbcode

查看带图原文 请移步 https://mp.weixin.qq.com/s/bj98rCXtnGJT87vBd4JEAw

更多内容请移步《 Go 语言轻松进阶:从入门、实战到内核揭秘》全面解析 Go 语言,从新手入门到实战应用,再到内核机制,一应俱全。https://tigerb.cn/go/#/

前言


关于 GMP 模型网上已经有很多文章,讲的内容大多都是如下图的逻辑,本系列我们就不再赘述。本系列我们换个视角,核心是搞清楚两个问题:

正文开始。

GMP只是结构体


GMP并不是你想象的那么神奇的存在,其实就是普通的结构体,如同你写业务代码定义的结构体一样,如下:

// Goroutine
// 代码位置:go1.19/src/runtime/proc.go
type g struct {
	stack     stack
	//...略...
	gopc      uintptr 
	startpc   uintptr
	sched     struct {
		sp   uintptr
		pc   uintptr
		//...略...
		bp   uintptr
	}
	//...略...
}

// Machine
// 代码位置:go1.19/src/runtime/proc.go
type m struct {
    g0            *g     
	//...略...
	curg          *g
	p             puintptr
	nextp         puintptr
	//...略...

	mOS 
}
// Processor
// 代码位置:go1.19/src/runtime/proc.go
type p struct {
	id          int32
	//...略...
	m           muintptr 
	mcache      *mcache
	//...略...
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	runnext guintptr
    //...略... 
	gFree struct {
		gList
		n int32
	}
    //...略...
	mspancache struct {
		len int
		buf [128]*mspan
	}
    //...略...
	gcw gcWork
}

GMP是系统线程运行的代码片段

GMP和你写的业务代码一样,都是由系统线程运行。

GMP是类似面相对象思想的封装

类型 结构体含义 结构体职责
G Goroutine ,代表协程 1. 封装可被并发执行的函数片段,比如 go func() {// 函数 A}()
G - 2. 暂存函数片段(协程)切换时的上下文信息
G - 3. 封装 g 的栈内存空间,暂存函数片段(协程)执行时的临时变量的
M Machine ,和系统线程建立映射,结构体绑定一个系统线程 1. 绑定真正执行代码的系统线程,系统线程执行G的调度,和被调度的G绑定的函数
M - 2. 维护P链表(可以从下一个P的队列找G
P Processor ,和逻辑处理器建立映射 1. 维护可执行G的队列(M从该队列找可执行的G);
P - 2. 堆内存缓存层(mcache
P - 3. 维护 g 的闲置队列

G职责解析

接下来,展开关于G展开两个关键问题:

G和函数绑定过程

当你使用go关键字执行一个函数时go func(){}()

  1. Gfunc具体绑定在哪?
  2. Gfunc何时绑定?
// `go`关键字示例
func main() {
	// 使用 go 关键并发执行一个函数
	go func() {
		fmt.Println("demo")
	}()
}

Gfunc具体绑定在哪?

位于 g 的结构体 g.startpc属性,详细如下:

// Goroutine
// 代码位置:go1.19/src/runtime/proc.go
type g struct {
	//...略...
	gopc      uintptr  // go 关键字创建 Goroutine 的代码位置
    //...略...
	startpc   uintptr // Goroutine 绑定的函数代码地址
    //...略...
}

Gfunc何时绑定?

  1. 当通过 go 关键字运行一个函数时
  2. 从 g 的闲置队列获取一个 g ,并通过g.startpc属性绑定上待执行的函数 fn
// 当你用 go 关键字执行一个函数
// 通过这个函数 绑定 g 和 待被执行的函数 fn
func newproc(fn *funcval) {
	gp := getg()
	// 获取使用 go 关键字调用 fn 的代码位置
	// 方便 fn 执行完成之后跳回原代码位置
	pc := getcallerpc()
	systemstack(func() {
		// 绑定过程在这个函数中
		// 下面进一步分析 newproc1
		newg := newproc1(fn, gp, pc)

		_p_ := getg().m.p.ptr()
		// 放入本地队列
		// 等待调度
		runqput(_p_, newg, true)

		if mainStarted {
			wakep()
		}
	})
}

// 绑定过程在这个函数中 分析 newproc1
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	//...略...
	newg := gfget(_p_) // 从 g 的闲置队列获取一个 g
	//...略...
	newg.gopc = callerpc // 重点:设置 go 关键字的位置,便于 fn 执行完毕跳回原代码位置
	newg.startpc = fn.fn // 重点:这里绑定待被执行的函数 fn
	//...略...

	return newg
}

函数绑定过程如下:

G切换上下文过程

  1. goroutine的上下文信息具体保存在哪?
  2. goroutine的上下文如何切换?

goroutine的上下文信息具体保存在哪?

位于 g 的结构体 g.sched属性,详细如下:

// Goroutine
// 代码位置:go1.19/src/runtime/proc.go
type g struct {
	stack     stack // 协程栈 执行过程临时变量存放的地方
	sched     gobuf // Goroutine 上下文信息 保存在这个结构
    //...略...
}

// Goroutine 上下文信息
type gobuf struct {
	sp   uintptr // 栈指针:指向栈顶
	pc   uintptr // 代码(指令)执行位置的地址
	//...略...
	bp   uintptr // 基指针:指向栈基
}

goroutine的上下文如何切换?

g 恢复上下文过程:

触发调度时:

  1. 找到可执行的 g (来源本地队列、全局队列、netpoll list 读或写就绪的 g 列表)
  2. 把 g 的上下文g.sched通过汇编代码中的函数gogo恢复到对应的寄存器中
// g 的调度方法
func schedule() {
	
	//...略...

	// 找可执行的 g (本地队列、全局队列、netpoll list 读或写就绪的 g 列表 等)
	gp, inheritTime, tryWakeP := findRunnable() 
	
	//...略...
	
	//在这里 继续往下看
	execute(gp, inheritTime)
}

func execute(gp *g, inheritTime bool) {
	//...略...
	// 关键就是通过 gogo 这个函数 恢复
	gogo(&gp.sched)
}

gogo 函数汇编代码,arm64 架构示例汇编代码如下:

// void gogo(Gobuf*)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT|NOFRAME, $0-8
	MOVD	buf+0(FP), R5
	MOVD	gobuf_g(R5), R6
	MOVD	0(R6), R4
	B	gogo<>(SB)

TEXT gogo<>(SB), NOSPLIT|NOFRAME, $0
	MOVD	R6, g
	BL	runtime·save_g(SB)

	MOVD	gobuf_sp(R5), R0 // 恢复栈指针
	MOVD	R0, RSP
	MOVD	gobuf_bp(R5), R29 // 恢复基指针
	MOVD	gobuf_lr(R5), LR 
	MOVD	gobuf_ret(R5), R0
	MOVD	gobuf_ctxt(R5), R26
	MOVD	$0, gobuf_sp(R5)
	MOVD	$0, gobuf_bp(R5)
	MOVD	$0, gobuf_ret(R5)
	MOVD	$0, gobuf_lr(R5)
	MOVD	$0, gobuf_ctxt(R5)
	CMP	ZR, ZR 
	MOVD	gobuf_pc(R5), R6 // 恢复 PC 计数器 指向下一个待执行的指令
	B	(R6)

g 保存上下文过程:

其中两个关键函数如下

  1. func save(pc, sp uintptr)触发保存上下文
  2. func mcall(fn func(*g))触发保存上下文

save 函数

func save(pc, sp uintptr) {
	_g_ := getg()

	//...略...

	_g_.sched.pc = pc // 保存代码执行位置
	_g_.sched.sp = sp // 保存栈指针
	
	//...略...
}

调用func save(pc, sp uintptr)的场景如下:

// 进入系统调用
func entersyscall() {
	reentersyscall(getcallerpc(), getcallersp())
}

func reentersyscall(pc, sp uintptr) {
	_g_ := getg()

	//...略...
	// 保存上下文
	save(pc, sp)
	_g_.syscallsp = sp
	_g_.syscallpc = pc
	casgstatus(_g_, _Grunning, _Gsyscall)
	//...略...
}

mcall 函数

func mcall(fn func(*g))执行过程中,从 g 切换到 g0 ,并执行 fn 。fn 内部会执行调度函数 shedule(),触发新的调度,下面会举一个例子。

TEXT runtime·mcall<ABIInternal>(SB), NOSPLIT|NOFRAME, $0-8
	MOVD	R0, R26	

	MOVD	RSP, R0
	MOVD	R0, (g_sched+gobuf_sp)(g) // 保存当前 g 的栈指针
	MOVD	R29, (g_sched+gobuf_bp)(g) // 保存当前 g 的基指针
	MOVD	LR, (g_sched+gobuf_pc)(g)// 保存当前 g 的下一个待执行指令的位置 PC 计数器
	MOVD	$0, (g_sched+gobuf_lr)(g)

	// 切换到 g0 ,并执行函数 fn
	MOVD	g, R3
	MOVD	g_m(g), R8
	MOVD	m_g0(R8), g
	BL	runtime·save_g(SB)
	CMP	g, R3
	BNE	2(PC)
	B	runtime·badmcall(SB)

	MOVD	(g_sched+gobuf_sp)(g), R0
	MOVD	R0, RSP	
	MOVD	(g_sched+gobuf_bp)(g), R29
	MOVD	R3, R0	
	MOVD	$0, -16(RSP)
	SUB	$16, RSP
	MOVD	0(R26), R4
	BL	(R4)
	B	runtime·badmcall2(SB)

调用func mcall(fn func(*g))的场景如下:

  1. Gosched():触发协作&抢占式式调度时
  2. gopark:g 从运行状态转换为等待状态时
  3. goexit1()goroutine 执行完成时
  4. exitsyscall() 退出系统调用时

详细展开,Gosched():触发协作&抢占式式调度时看看,如下

// 触发调度
func Gosched() {
	checkTimeouts()
	mcall(gosched_m)
}

func gosched_m(gp *g) {
	//...略...
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	//...略...
	// 正在运行状态转变为 可运行状态
	casgstatus(gp, _Grunning, _Grunnable)
	dropg()
	lock(&sched.lock)
	globrunqput(gp) // 放入全局队列
	unlock(&sched.lock)
	// 触发调度
	schedule()
}

func schedule() {
	//...略...

	// 找到下一个可执行的 g
	gp, inheritTime, tryWakeP := findRunnable() 

	//...略...

	// 执行下一个 g
	execute(gp, inheritTime)
}

func execute(gp *g, inheritTime bool) {
	//...略...

	// 恢复上下文
	gogo(&gp.sched)
}

// gogo 汇编代码(arm64 架构)
TEXT gogo<>(SB), NOSPLIT|NOFRAME, $0
	//...略...
	MOVD	gobuf_sp(R5), R0 // 恢复栈指针
	MOVD	gobuf_bp(R5), R29 // 恢复基指针
	//...略...
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
	//...略...
	mcall(park_m)
}

func park_m(gp *g) {
	//...略...
	casgstatus(gp, _Grunning, _Gwaiting)
	dropg()

	//...略...

	// 触发调度
	schedule()
}

//...略...
// 同上`Gosched()`
func goexit1() {
	//...略...
	mcall(goexit0)
}

// goexit continuation on g0.
func goexit0(gp *g) {
	//...略...
	// 触发调度
	schedule()
}

//...略...
// 同上`Gosched()`
func exitsyscall() {
	//...略...

	mcall(exitsyscall0)

	//...略...
}

func exitsyscall0(gp *g) {
	casgstatus(gp, _Gsyscall, _Grunnable)
	dropg()
	//...略...
	stopm()
	// 触发调度
	schedule()
}

/...略...
// 同上`Gosched()`

具体如下图:

总结下 g 的完整切换过程:

M职责解析

  1. 绑定真正执行代码的系统线程
  2. 系统线程执行G的调度
  3. 系统线程执行被调度的G绑定的函数
  4. 维护P链表(可以从下一个P的队列找G
// Machine
// 代码位置:go1.19/src/runtime/proc.go
type m struct {
	g0            *g     
	//...略...
	curg          *g  // 当前执行的 g
	p             puintptr // m 绑定的 p
	nextp         puintptr // 4. 维护`P`链表(可以从下一个`P`的队列找`G`)
	//...略...

	// 1. 绑定真正执行代码的系统线程
	// 2. 执行`G`的调度
	// 3. 执行被调度的`G`绑定的函数
	mOS 

    //...略...
}

P职责解析

  1. 维护可执行G的队列(M从该队列找可执行的G);
  2. 堆内存缓存层(mcache
  3. 维护 g 的闲置队列
// Processor
// 代码位置:go1.19/src/runtime/proc.go
type p struct {
	id          int32
	//...略...
	m           muintptr 
	mcache      *mcache // 堆内存缓存层(`mcache`)

	//...略...

	runqhead uint32 // 1. 维护可执行`G`的队列(`M`从该队列找可执行的`G`);
	runqtail uint32 // 1. 维护可执行`G`的队列(`M`从该队列找可执行的`G`);
	runq     [256]guintptr // 1. 维护可执行`G`的队列(`M`从该队列找可执行的`G`);
	runnext guintptr // 1. 维护可执行`G`的队列(`M`从该队列找可执行的`G`);

    //...略... 

	// 3. 维护 g 的闲置队列
	gFree struct {
		gList
		n int32
	}

    //...略...
	mspancache struct {
		len int
		buf [128]*mspan
	}

    //...略...
	gcw gcWork
}

总结


再来回头看开篇的两个问题?

是不是已经很清晰。

类型 结构体含义 结构体职责
G Goroutine ,代表协程 1. 封装可被并发执行的函数片段,比如 go func() {// 函数 A}()
G - 2. 暂存函数片段(协程)切换时的上下文信息
G - 3. 封装 g 的栈内存空间,暂存函数片段(协程)执行时的临时变量的
M Machine ,和系统线程建立映射,结构体绑定一个系统线程 1. 绑定真正执行代码的系统线程,系统线程执行G的调度,和被调度的G绑定的函数
M - 2. 维护P链表(可以从下一个P的队列找G
P Processor ,和逻辑处理器建立映射 1. 维护可执行G的队列(M从该队列找可执行的G);
P - 2. 堆内存缓存层(mcache
P - 3. 维护 g 的闲置队列

查看带图原文 请移步 https://mp.weixin.qq.com/s/bj98rCXtnGJT87vBd4JEAw

更多内容请移步《 Go 语言轻松进阶:从入门、实战到内核揭秘》全面解析 Go 语言,从新手入门到实战应用,再到内核机制,一应俱全。https://tigerb.cn/go/#/

1142 次点击
所在节点    程序员
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1062888

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX