近万字长文深入浅出MainGoroutine启动以及Goroutine调度模型GMP工作原理

深入浅出Golang源码,助力分析Golang GMP 工作原理

本文参考的源码为:1.21.6

一、main 函数初始化

1.1 golang main 函数入口点分析

由于Golang是由其本身以及Go汇编通过ABI交叉实现(通过应用程序二进制接口可以实现汇编调用go函数,go函数调汇编),而我们想要查看 Golang 初始化协程调度逻辑的话,那么我们肯定要先了解 go 在执行用户级别的 main 函数之前,有做什么初始化相关的逻辑操作,只有当我们了解这个相关之后,后续我们在用户级别中执行 go func(){} 的时候才能更加清楚的知道其背后被调度的实际原理是什么。

在这里,我们直接通过 gdb 查看二进制的执行点,并通过断点的方式来查看启动 go 程序时执行的相关汇编:

编译二进制:-N 禁用优化,-l 禁用内联

shell 复制代码
go build -gcflags="-N -l" main.go

// 也可以通过 go tool objdump -s "xx.main" -S main 来查看某个函数的汇编的同时也展示该源代码

编译成二进制之后,我们通过 gdb 调试来找到 golang 汇编的入口点,只有找到了 golang 汇编的入口点,

Windows 下安装 gdb 环境文档: c.biancheng.net/view/8296.h...

进入 gdb 调试:gdb main 然后执行 info files 就会得到下面的二进制的入口点

vbnet 复制代码
(gdb) info files
Symbols from "F:\code\go\goresource\go\test\openxm\main.exe".
Local exec file:
        `F:\code\go\goresource\go\test\openxm\main.exe', file type pei-x86-64.
        Entry point: 0x45e340
        0x0000000000401000 - 0x0000000000483dae is .text
        0x0000000000484000 - 0x0000000000523f68 is .rdata
        0x0000000000524000 - 0x0000000000530200 is .data
        0x0000000000588000 - 0x000000000058bca8 is .pdata
        0x000000000058c000 - 0x000000000058c09c is .xdata
        0x0000000000617000 - 0x0000000000617516 is .idata
        0x0000000000618000 - 0x000000000061a8d0 is .reloc

在我们得知到二进制的入口点后,我们直接对该入口点进行断点:

break *0x45e340,最终会输出如下:

vbnet 复制代码
(gdb) info files
Symbols from "F:\code\go\goresource\go\test\openxm\main.exe".
Local exec file:
        `F:\code\go\goresource\go\test\openxm\main.exe', file type pei-x86-64.
        Entry point: 0x45e340
        0x0000000000401000 - 0x0000000000483dae is .text
        0x0000000000484000 - 0x0000000000523f68 is .rdata
        0x0000000000524000 - 0x0000000000530200 is .data
        0x0000000000588000 - 0x000000000058bca8 is .pdata
        0x000000000058c000 - 0x000000000058c09c is .xdata
        0x0000000000617000 - 0x0000000000617516 is .idata
        0x0000000000618000 - 0x000000000061a8d0 is .reloc
(gdb) break *0x45e340
Breakpoint 1 at 0x45e340: file F:/code/go/goresource/go/src/runtime/rt0_windows_amd64.s, line 10.
(gdb)

OK,我们通过 gdb 找到了我们想要的文件以及对应的行数,也就是说,这个二进制的启动入口点就是在这个文件内对应的行数开始的,而对于之前解释的 ABI (应用程序二进制接口)可以知道,在 Go 汇编中,可以直接调用 Go func。所以我们直接去分析其给出的汇编文件就可以找到我们想要的信息。

下面的就是我们要找的信息:

c 复制代码
TEXT _rt0_amd64_windows(SB),NOSPLIT|NOFRAME,$-8
	JMP	_rt0_amd64(SB)

通过全局搜索或者 gdb 调试信息,我们可以快速定位到 _rt0_amd64 汇编的实现

c 复制代码
TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

我们直接看 runtime·rt0_go 实现原理,我们去掉不相关代码,只保留 CALL 相关的汇编函数,因为 go 汇编可以直接通过 CALL 生成 golang 函数栈帧,并同时修改 PC 寄存器(CPU要执行的下一行机器码),当修改 PC 计数器之后,CPU 则会立马去执行当前 PC 计数器所指向的机器码,也就是我们定义的 go 函数了。

补充:这里使用的跳转指令为 BL,也可以使用 CALL 、直接修改 PC 寄存器等多种方式。

c 复制代码
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
    // 汇编初始化m0,g0
    MOVW	$runtime·g0(SB), g
	MOVW	$runtime·m0(SB), R8
    // save m->g0 = g0
	MOVW	g, m_g0(R8)
	// save g->m = m0
	MOVW	R8, g_m(g)
	BL	runtime·args(SB)
	BL	runtime·osinit(SB)
	BL	runtime·schedinit(SB)
	// newproc创建一个协程,并且此时 SB 寄存器上为执行该协程的闭包函数的指针
    // 这里创建的g则为main goroutine
	BL	runtime·newproc(SB)
	
	// start this M
	BL	runtime·mstart(SB)

    // 这里runtime·mainPC指向runtime·main, 通过ABI
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

在执行完以下三个初始化操作过程后,将会开始执行main,下面的三个函数都是由汇编跳转过来的 go 实现的初始化后函数。初始化完成之后就开始调用runtime.main 去执行用户级别的代码。不过我们先来看看其主要有做了什么初始化.

  1. runtime·args
  2. runtime·osinit
  3. runtime·schedinit

**这里有一个前提:m0和g0,详细参考如下:**juejin.cn/post/697424...

m0 :m0和普通的m没有任何区别,只不过其并不是由 runtime 创建的,而是由 go 汇编直接创建的(其创建后直降将m0添加到 TLS或者其他寄存器中),m0.go 负责初始化和启动 main goroutine

g0:执行调度任务

所以我们这里,直接去查看 runtime.schedinit 函数即可。

1.2 初始化runtime核心组件

这里执行初始化,那么其具体执行了什么初始化? 具体初始化了什么? 既然要做这件事情,那么做这个初始化事情背后的含义是什么? 这里我们直接看下源代码(删除不必要的代码):虽然函数名称为初始化调度,但是其本质上是初始化Golang Runtime所有核心组件,执行完该函数后,在后面就开始调用调度循环来执行runtime.main了。

go 复制代码
func schedinit() {
	// 这里获取到的应该是m0上的g0,因为现在在进行调度初始化的是m0和g0
	gp := getg()
	if raceenabled {
		gp.racectx, raceprocctx0 = raceinit()
	}

	stackinit()  // 初始化执行栈
	mallocinit() // 初始化内存分配
	// 初始化当前系统线程
	mcommoninit(gp.m, -1) // 初始化m0(m0是由汇编创建赋值的),同时当前运行的是m0中的g0,g0负责调度相关,不执行用户级代码
	gcinit() // 初始化垃圾回收器

	// 1、初始化P以及为P分配M
	// 1.1 P中不存在要等待运行的g,放入sched等待队列中
	// 1.2 P中存在等待运行的g,立即分配空闲的m
	if procresize(procs) != nil {
		throw("unknown runnable goroutine during bootstrap")
	}
}

上面的代码在删除掉和本文分析不挂钩的代码逻辑外就变的非常简单了,其做了以下初始化:

  • 初始化执行栈
  • 初始化内存分配器
  • 初始化当前系统线程
  • 初始化垃圾回收器
  • 初始化P以及分配给P分配M

我们这里主要分下和GMP相关的初始化流程,在执行 procresize 初始化时,其根据当前系统CPU核心数来确定初始化P的个数,同时其根据P本地队列G的等待情况来分配M,如果当前P的本地队列有M则立即分配空闲的M。

下面则为判断本地队列是否为空的判断:P通过链表来维护本地队列,其中runnext表示立马要运行的g,可能存在一种情况为head==tail==nil,但是runnext不为空,那么此时P的本地队列也仍然判断为不为空。

go 复制代码
func runqempty(pp *p) bool {
	for {
		head := atomic.Load(&pp.runqhead)
		tail := atomic.Load(&pp.runqtail)
		runnext := atomic.Loaduintptr((*uintptr)(unsafe.Pointer(&pp.runnext)))
		if tail == atomic.Load(&pp.runqtail) {
			// 1、head != tail 表示p本地队列中存在待运行的g
			// 1.1 runnext == 0 : 无意义
			// 1.2 runnext != 0 : 无意义
			// 2、head == tail, runnext != 0 本地队列为空,但是有一个g待运行
			return head == tail && runnext == 0
		}
	}
}

这里,我们看一下具体函数调用关系图:从图中我们可以看到,比较重要的其实就是 schedinit之后的流程,因为在该流程中初始化了 runtime 运行必要的一些核心组件。

OK,这里,我们已经知道 runtime 核心组件:内存分配、gc、协程调度等已经全部初始化完毕,那么我们下面接着来分析一下后续的流程。

二、创建 main goroutine

G、M、P 三者之间的关系要理清楚,同时m0、g0也要理解清楚。

M是什么时候启动的? m0一个go进程只有一个m0实例,m0由go汇编初始化执行在 rt0_g0 中,那么后续的m又是如何创建的? 为什么 M 能表示一个 工作线程? 具体逻辑是怎么实现的?

P 是什么时候启动的?

m0对应的P是那个? main goroutine 对应的 p 是那个?m0中对应的p为初始化之后的第一个P,虽然也被称作为p0,此p0非g0对应的含义

这里逻辑虽然很简单,但是各个结构体之间的关系还是有必要分析清楚的,否则GMP调度根本理解不了。

其实可以这么理解,工作线程只执行m.g,

2.1 goroutine 创建

通过上面的流程分析,我们可以知道,在进行完 schedinit 完之后m0、g0协程将会执行 newproc 创建出 main goroutine,同时该 goroutine 对应的 gfn 的闭包函数指针为 runtime.main。具体我们来看下其函数具体实现(创建普通goroutine也是通过如下函数创建,后续进行分析):

go 复制代码
// 当从汇编函数rt0_go跳转过来之后,这里创建的则为main goroutine协程,用来执行用户级 main 函数。
func newproc(fn *funcval) {
	gp := getg()
	pc := getcallerpc()
	// 切换到系统栈(g0)执行
	// gp = g1
	systemstack(func() {
		// func(){} 全部都是g0在执行,此时g1上下文信息都被保存下来了
		// systemstack 切换到系统栈(非用户栈空间),所以这里为g0在执行.
		// 创建g: go runc(){user code},我们假设g1创建了g2
		// 此时通过汇编将g1切换到后台,g0切换到当前,由g0开始执行,因为g0为系统栈,g1为用户栈
   		// 初始化g的一些基本信息,包括ID等,其中会将g.sched相关的值赋值,同时将g.sched.pc等相关和g挂钩的函数的寄存器上下文赋值到sched结构体中,当执行协程切换时,则会将sched中的寄存器值复原从而达到执行这个goroutine的效果 
		newg := newproc1(fn, gp, pc)

		pp := getg().m.p.ptr()
		// 将当前g加入到p的本地队列
		// 如果都是true,那岂不是意味着每次新创建的g都优先于已经创建好的g? 
		runqput(pp, newg, true)
		// 如果main M 已经启动,则执行wake唤醒
        // go进程初始化的时候mainStarted还未进行初始化
		if mainStarted {
			wakep()
		}
	})
}

在该函数内,golang runtime 将会通过调用汇编 systemstack 实现g0系统栈和用户栈之间的切换,通过系统栈g0来实现goroutine的创建,我们具体来看下

scss 复制代码
// func systemstack(fn func())
TEXT runtime·systemstack(SB), NOSPLIT, $0-8
	MOVQ	fn+0(FP), DI									// DI = fn,将本次需要g0系统栈执行的函数移动到寄存器DI上
	get_tls(CX) 											// ThreadLocalStorage中第一个参数就是存储的当前运行的g
	MOVQ	g(CX), AX										// AX = g // 当前运行的g
	MOVQ	g_m(AX), BX										// BX = m

	CMPQ	AX, m_gsignal(BX)
	JEQ	noswitch

	MOVQ	m_g0(BX), DX	// DX = g0    					// 同时将m下的g0移动到DX寄存器上
	CMPQ	AX, DX  										// 比较AX(g)和DX(g0)是否相同,如果相同则跳转到noswitch执行(无需切栈)
	JEQ	noswitch

	CMPQ	AX, m_curg(BX)
	JNE	bad

	// Switch stacks.
	// The original frame pointer is stored in BP,
	// which is useful for stack unwinding.
	// Save our state in g->sched. Pretend to
	// be systemstack_switch if the G stack is scanned.
	CALL	gosave_systemstack_switch<>(SB)

	// switch to g0
	MOVQ	DX, g(CX)  										
	MOVQ	DX, R14 // set the g register
	MOVQ	(g_sched+gobuf_sp)(DX), SP						// 将当前调用栈切换为g0(DX寄存器上为g0)系统栈

	// call target function									// 执行需要g0执行的目标函数
	MOVQ	DI, DX
	MOVQ	0(DI), DI										// 在这里对应的是创建新的g
	CALL	DI

	// switch back to g										// 切换到原先的g
	get_tls(CX)
	MOVQ	g(CX), AX
	MOVQ	g_m(AX), BX
	MOVQ	m_curg(BX), AX
	MOVQ	AX, g(CX)
	MOVQ	(g_sched+gobuf_sp)(AX), SP
	MOVQ	(g_sched+gobuf_bp)(AX), BP
	MOVQ	$0, (g_sched+gobuf_sp)(AX)
	MOVQ	$0, (g_sched+gobuf_bp)(AX)
	RET

通过上面的systemstack函数的汇编我们可以清楚的之后在执行newproc逻辑时协程切换的具体细节了,所以,golang runtime 在创建新的 g是都是通过 g0 来创建的(g0执行runtime级别的代码)。为什么创建新的g要通过g0来进行创建?这里我猜测可能是要通过汇编获取到寄存器相关的值用来切换或者保存上下文。

我们具体来看一下其创建 main goroutine 的一些细节与一些黑魔法:这个函数我们主要关注一个点,那就是g.sched.pc值的赋值流程,因为该值保存了PC寄存器指向go func 闭包函数的代码段,只要正确赋值该pc值,则goroutine可以正常运行,那么我们现在具体分析一下:其中我省略掉了很多和本文分析无关的代码:

go 复制代码
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	
	newg := gfget(pp)
	if newg == nil {
		// 使用最小的栈进行初始化也就是2K
		newg = malg(stackMin)
		casgstatus(newg, _Gidle, _Gdead)
		allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
	}
	
	// 初始化newg.sched.pc指针
	// 当goroutine被调度后,g0系统栈会切换到g,同时恢复g的上下文信息,也就是将g.sched相关寄存器的值复原,然后PC寄存器所执行的机器码就是g.startpc
	newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
	newg.sched.g = guintptr(unsafe.Pointer(newg))
    // 将goexit和fn全部保存到newg栈中,利用栈的特性,当fn执行完毕后,PC寄存器控制代码的执行函数自动去执行goexit函数
	gostartcallfn(&newg.sched, fn)

	newg.gopc = callerpc
	
	newg.startpc = fn.fn // 将func设置g.startpc
	// 设置g的状态为Grunnable,表示可以被调度器调度运行用户维度的代码
	casgstatus(newg, _Gdead, _Grunnable)
	
	return newg
}

newproc1 函数本身就是创建一个 goroutine 所表示的 g,并且进行初始化各个字段,最重要的为初始化 goroutine.sched 协程栈,初始化该协程栈时会优先将 goexit 函数提前压入到 newg 的栈顶中,然后在压入 User Code栈帧,这么做的目的就是为了在执行完 User Code 后能利用栈的特性自动调用 goexit 函数。

我们具体来看一下其压入栈的过程函数的实现:具体实现的关键代码的注释也已经在上面了,具体的我相信我已经不用在多说了,我相信读者读到这里已经明白其含义了。

go 复制代码
// adjust Gobuf as if it executed a call to fn
// and then stopped before the first instruction in fn.
func gostartcallfn(gobuf *gobuf, fv *funcval) {
	var fn unsafe.Pointer
	if fv != nil {
		fn = unsafe.Pointer(fv.fn)
	} else {
		fn = unsafe.Pointer(abi.FuncPCABIInternal(nilfunc))
	}
	gostartcall(gobuf, fn, unsafe.Pointer(fv))
}

// adjust Gobuf as if it executed a call to fn with context ctxt
// and then stopped before the first instruction in fn.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
	sp := buf.sp                             // 栈顶
	sp -= goarch.PtrSize                     //栈顶向下移动8个字节(64位)
	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc // 栈顶移下来的位置存放buf.pc也就是goexit的值
	buf.sp = sp                              // 设置goroutine栈顶
	buf.pc = uintptr(fn)                     // 重新设置buf.pc的值为go func 对应的闭包函数的值,这样做的目的是在执行完User Code后能利用栈的特性执行goexit.
	buf.ctxt = ctxt
}

我们现在重新来梳理一下Main Goroutine 的创建流程:

  • runtime.newproc:创建一个新的 goroutine,其中 g.sched 指向 fn
  • runtime.systemstack:改用 g0 系统栈执行给的回调函数,用于通过汇编保存相关寄存器上下文:PC、SB寄存区等
  • newproc1:初始化g的结构体,并且赋值gid等相关属性,最重要的是将goroutine对应的回调函数相关上下文寄存器的值保存到g.sched中,用于进行goroutine切换时上下文恢复让CPU从预想的地方开始执行这个步骤有一个前提就是提前将goexit函数放入到栈顶,用以执行返回时的处理函数
  • runtime.runqput:将当前新创建的g加入到当前p的本地队列,如果本地队列满了(256),则将其放到全局队列中(全局队列也是链表,在放入到全局队列中的时候,会从P的本地队列中截取128(这里是直接取1/2)个g一同通过链表的方式放到全局队列中,用以平衡当前P的工作负责以及将当前P的工作负载平衡到其他P中)。
  • wakep

此时,一个崭新的 g 已经创建好了,我们来具体看一下其具体的结构:我这里会删除掉一些对本文分析不是很重要的东西

go 复制代码
type g struct {
								// Stack parameters.
								// stack describes the actual stack memory: [stack.lo, stack.hi).
	stack       stack   		// offset known to runtime/cgo
	stackguard0 uintptr 		// offset known to liblink
	stackguard1 uintptr 		// offset known to liblink

	m         *m      			// 当前g在那个m中运行
    sched     gobuf				// 当前g所对应的func相关上下文(用于控制CPU执行用户级别代码)
	
	atomicstatus atomic.Uint32  // goroutine status

	goid         uint64
	schedlink    guintptr

	gopc          uintptr       // pc of go statement that created this goroutine
	startpc       uintptr       // pc of goroutine function
}

在main初始化章节中,我们有讲解到runtime中的所有P的初始化相关逻辑,在进行 schedinit 初始化时,会对所有的P进行初始化,而g0则会初始化将其自己归纳为P0,所以我们可以认为m0、p0、g0 互相挂钩。所以P0中的本地队列已经有了一个g,那就是main goroutine,此时P0还未被调度,也就是还未能执行该g,而现在正在执行的还是全局唯一的g0,所以我们后续要启动调度循环,调度到 P ,进而又 P去执行 main goroutine。

注意 注意 注意:此时main goroutine 还未开始执行,因为其还未被调度到M上进行执行。

**注意:**runtime在内部还维护了一个gfree用来保存已经执行完用户代码的g但是还没有进行gc,此时针对后续新创建的g则优先从该链表中获取,如果没有则分配一个新的g,而这里针对新的g分配的栈空间大小为 2048,也就是2K,到了这里,我们就能很轻松的知道goroutine是多么的轻量了。

2.2 将main goroutine 绑定到P中

每次创建一个 g 都是通过 m.go 来执行的,而 main goroutine 也不例外其是通过全局的g0来执行的。而新创建一个 goroutine,为了能够让其被 CPU 运行,我们必须将其加入到 P 的本地队列中或者全局队列中,否则其 User Code 永远不会被调度,不被调度则 User Code 永远不会被执行,那么 main goroutine 是在什么时候被加入到P中的?其又是加入到那个P中?

我们简单粗暴一点点,直接去查看入队函数:同理我也删掉了很多不挂钩的逻辑

go 复制代码
// 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 {
		// next 为true,体现了其runtime goroutine的优先性(局部优先)
	retryNext:
		oldnext := pp.runnext
		if !pp.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
			goto retryNext
		}
		// 旧的g为空
		if oldnext == 0 {
			return
		}
		// Kick the old runnext out to the regular run queue.
		// 从运行队列中剔除掉(如果这个时候还未执行完毕呢?),放入到尾部
		gp = oldnext.ptr()
	}

	// 将gp添加到队列
retry:
	// 从头进行消费,所以取的时候只需要对head进行原子取
	h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with consumers
	t := pp.runqtail
	// t-h+1 ?
	if t-h < uint32(len(pp.runq)) {
		// 本地队列可以存放
		pp.runq[t%uint32(len(pp.runq))].set(gp)
		atomic.StoreRel(&pp.runqtail, t+1) // store-release, makes the item available for consumption
		return
	}
	// 本地队列已经满了,放入全局队列
	if runqputslow(pp, gp, h, t) {
		return
	}
	// the queue is not full, now the put above must succeed
	goto retry
}

通过上面的代码和相关注释,我们可以知道将g存放的逻辑将会按照下面的规则来进行存放:

  • 体现局部优先性,如果指定了next,则将其放入到next中,然后将oldNext放入到p的本地队列或者全局队列中。
  • 优先放入P的本地队列,当P的本地队列大于256时则存入到全局队列中。
  • 存入全局队列。
  • 如果g没有放入到P中,则一直循环放入,g 必须一定放入到某个P中。

OK,G的放入优先顺序已经解释清楚了,那么我们现在接着往下去了解。

2.3 goroutine 生命周期

goroutine 状态机

  • _Gidle:刚刚分配g内存,但是还未进行初始化
  • _Grunnable:在运行队列中排队,用户代码还未执行
  • _Grunning:正在执行用户级代码
  • _Gsyscall:goroutine发生系统调用,此时g并不在运行队列,而是在m中占用,只不过工作线程放弃了发生系统调用的m转而去获取其他空闲的m去执行用以最大化提高资源利用率
  • _Gwaiting:用户态阻塞,但是g此时并不处于运行队列中等待运行,可能在执行 channel wait
  • _Gdead:成功执行完用户代码,可以复用

我们来看一下goroutine具体状态的转换:TODO:这里的状态图要重新替换一下

OK,到了这里,我相信 main goroutine 是如何创建的以及任何一个 G 加入 P的过程也是一目了然,那么现在一切就绪,只欠东风了,那就是如何让golang 用户代码正常执行呢?那么接下来我们就具体分下一个Golang Runtime Schedule 相关处理逻辑,这里提前打一个预防针,那就是Runtime 在调度这里是如何体现循环机制的。

此时,main goroutine 就已经和普通的 goroutine 没有了任何区别,在 Runtime 内部都是通过 g 结构体来表示。

本文为了减少不必要的重复性,在分析 Runtime 调度循环时,我们不在区分 main goroutine 还是普通的 goroutine了,因为其本身的含义是一致的,在 Runtime 一侧表示的结构体都是 g 。

三、Golang Runtime 调度循环

栈是先进后出,在设置每个g的用户态函数的PC指针时,会先将 goexit 的pc存入到栈顶,然后在将用户态函数的栈帧推送到g0栈(系统栈)中,当g用户态的代码执行完毕后,则会根据PC寄存器的值去调用 goexit 函数.

我们在 rt0_go 中知道,当通过汇编调用完初始化函数之后,则会通过汇编直接调用 runtime.mstart 函数,而该函数又通过汇编进行重写,跳转到 runtime.mstart0 , 所以我们直接分析 mstart0 函数。 同时,每次通过 go func 创建一个协程,都会进入到mstart0函数。所以我们直接分析mstart函数。

c 复制代码
// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()

TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
	BL	runtime·mstart0(SB)
	RET // not reached

接下来,我们重点看下一下 mstart0 函数:

c 复制代码
// mstart0每次创建一个M都会执行该函数.
// mstart0并不是针对m0的,而是针对有所m的
func mstart0() {
	gp := getg()

	osStack := gp.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.
		//
		// Note: these bounds may not be very accurate.
		// We set hi to &size, but there are things above
		// it. The 1024 is supposed to compensate this,
		// but is somewhat arbitrary.
		size := gp.stack.hi
		if size == 0 {
			size = 16384 * sys.StackGuardMultiplier
		}
		gp.stack.hi = uintptr(noescape(unsafe.Pointer(&size)))
		gp.stack.lo = gp.stack.hi - size + 1024
	}
	// Initialize stack guard so that we can start calling regular
	// Go code.
	gp.stackguard0 = gp.stack.lo + stackGuard
	// This is the g0, so we can also call go:systemstack
	// functions, which check stackguard1.
	gp.stackguard1 = gp.stackguard0
	mstart1()

    // main mexit 不会执行.
	mexit(osStack)
}

这里我们重点分析一下 mstart1 函数,该函数永远不会退出,永远在执行调度,当一个g执行完之后又会重新执行 schedule,OK,我们来具体看一下 mstart1函数实现:

go 复制代码
func mstart1() {
	gp := getg()

	if gp != gp.m.g0 {
		throw("bad runtime·mstart")
	}	
    
    // 这里保存g0栈帧,用于后续执行mcall时恢复g0的栈帧.

	// Set up m.g0.sched as a label returning to just
	// after the mstart1 call in mstart0 above, for use by goexit0 and mcall.
	// We're never coming back to mstart1 after we call schedule,
	// so other calls can reuse the current frame.
	// And goexit0 does a gogo that needs to return from mstart1
	// and let mstart0 exit the thread.
	gp.sched.g = guintptr(unsafe.Pointer(gp))
	gp.sched.pc = getcallerpc()
	gp.sched.sp = getcallersp()

	asminit()
	minit()

	// Install signal handlers; after minit so that minit can
	// prepare the thread to be able to handle the signals.
	if gp.m == &m0 {
		mstartm0()
	}

	// 执行启动函数
	if fn := gp.m.mstartfn; fn != nil {
		fn()
	}

    // 这里就是核心,最重要的调度流程。
	schedule()
}

我们废话少说,直接看 schedule 是如何实现调度:哈哈哈哈哈,当我们删掉一些不重要的代码逻辑时,其就变得非常简单了,第一:找到当前可运行的g, 第二运行该 g.

go 复制代码
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
	mp := getg().m

top:
	pp := mp.p.ptr()
	pp.preempt = false
	// 找到对应的g
	// 1、当前P执行调度61次则从全局队列中获取一个g执行
	// 2、本地队列中获取
	// 3、全局队列中获取
	// 4、网络连接中获取
	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

	// 1、谁在执行这个代码逻辑
	// 2、有多少个M在等待执行?如何找到等待执行的M?
	// 3、以及有多少等待执行的P? 如何找到等待执行的P?
	// 4、如何将g0切换到g? 以及用户维度代码执行完毕后如何将g切换到g0?
	// 5、有多少工作线程在执行(一开始肯定不是启动所有工作线程?那么工作线程是如何一步一步的启动?)
	execute(gp, inheritTime)
}

findRunnable 其实就是找到可运行的 g ,具体发现规则为如下:

  • 当前P执行调度61次则从全局队列中获取一个g执行
  • 本地队列中获取
  • 全局队列中获取
  • 网络连接中获取

当获取到一个可以运行的 g 的时候,我们直接调用 execute 去执行该 g ,OK,我想你们还不知道该函数的实现,那我们就直接来看看该函数的实现:同理我去掉了一些不想关的逻辑

go 复制代码
// Schedules gp to run on the current M.
// Never returns.
//go:yeswritebarrierrec
func execute(gp *g, inheritTime bool) {
	mp := getg().m

	// Assign gp.m before entering _Grunning so running Gs have an
	// M.
	mp.curg = gp
	gp.m = mp
	casgstatus(gp, _Grunnable, _Grunning)
	// P作为MG的一个中间态,降低了M的锁的粒度,同时P管理了所有的G,M获取可运行的G时通过P来获取
    
	// g0栈切换到gp栈,从而执行UserCode.
	gogo(&gp.sched)
}

我们简单分析一下上面的函数做了什么:

  • 将当前 m 正在运行的 g 由 g0 切换到 g,此时还没有去执行 g0 中的用户代码
  • 将 g 和 m 进行绑定
  • 修改 g 的状态为 Grunning 状态
  • 调用汇编,栈从g0栈切换到g栈,由此开始运行User Code.

废话不多话,我们直接看下 gogo 函数的汇编实现:

go 复制代码
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
	MOVQ	buf+0(FP), BX		// gobuf
	MOVQ	gobuf_g(BX), DX
	MOVQ	0(DX), CX		// make sure g != nil
	JMP	gogo<>(SB)

TEXT gogo<>(SB), NOSPLIT, $0
	get_tls(CX)					// 获取线程本地存储g,当前为g0
	MOVQ	DX, g(CX)
	MOVQ	DX, R14				// 设置 g 的运行上下文,也就是对SP、AX、DX、BP、BX寄存器还原
	MOVQ	gobuf_sp(BX), SP	// restore SP
	MOVQ	gobuf_ret(BX), AX
	MOVQ	gobuf_ctxt(BX), DX
	MOVQ	gobuf_bp(BX), BP
	MOVQ	$0, gobuf_sp(BX)	// clear to help garbage collector
	MOVQ	$0, gobuf_ret(BX)
	MOVQ	$0, gobuf_ctxt(BX)
	MOVQ	$0, gobuf_bp(BX)
	MOVQ	gobuf_pc(BX), BX	// 将UserCode代码段指针存入到PC寄存器,开始执行UserCode
	JMP	BX						// 执行 UserCode

当我们看到这里的时候,就已经明白了 go func(){}() 中对应的用户代码是如何被执行了。但是我们难道没有疑惑吗?为什么执行完 JMP BX 之后就没有了呢?我们不是说好进行调度循环的吗? 如果执行完了就退出,那么如何调度后续的 g 呢?

不要慌,我们是否还记得,在将go func 对应的闭包函数压入到 g 的栈顶时是否还有印象,其提前压入了一个 goexit 指针到 return addr 处,也就是说,在执行完用户代码之后,PC 寄存器的值就变成了 return addr 处的指针,同理其会去执行该指针对应的代码逻辑,也就是 goexit 的函数逻辑,废话不多话,我们直接上源码:

go 复制代码
TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME|NOFRAME,$0-0
	BYTE	$0x90	// NOP
	CALL	runtime·goexit1(SB)	// does not return
	// traceback from goexit1 must hit code range of goexit
	BYTE	$0x90	// NOP


// Finishes execution of the current goroutine.
func goexit1() {
	if raceenabled {
		racegoend()
	}
	if traceEnabled() {
		traceGoEnd()
	}
	mcall(goexit0)
}

感兴趣的可以看看 mcall 函数的实现:

go 复制代码
// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall<ABIInternal>(SB), NOSPLIT, $0-8
	MOVQ	AX, DX	// DX = fn

	// Save state in g->sched. The caller's SP and PC are restored by gogo to
	// resume execution in the caller's frame (implicit return). The caller's BP
	// is also restored to support frame pointer unwinding.
	MOVQ	SP, BX	// hide (SP) reads from vet
	MOVQ	8(BX), BX	// caller's PC
	MOVQ	BX, (g_sched+gobuf_pc)(R14)
	LEAQ	fn+0(FP), BX	// caller's SP
	MOVQ	BX, (g_sched+gobuf_sp)(R14)
	// Get the caller's frame pointer by dereferencing BP. Storing BP as it is
	// can cause a frame pointer cycle, see CL 476235.
	MOVQ	(BP), BX // caller's BP
	MOVQ	BX, (g_sched+gobuf_bp)(R14)

	// switch to m->g0 & its stack, call fn
	MOVQ	g_m(R14), BX
	MOVQ	m_g0(BX), SI	// SI = g.m.g0
	CMPQ	SI, R14	// if g == m->g0 call badmcall
	JNE	goodm
	JMP	runtime·badmcall(SB)
goodm:
	MOVQ	R14, AX		// AX (and arg 0) = g
	MOVQ	SI, R14		// g = g.m.g0
	get_tls(CX)		// Set G in TLS
	MOVQ	R14, g(CX)
	MOVQ	(g_sched+gobuf_sp)(R14), SP	// sp = g0.sched.sp
	PUSHQ	AX	// open up space for fn's arg spill slot
	MOVQ	0(DX), R12
	CALL	R12		// fn(g)
	POPQ	AX
	JMP	runtime·badmcall2(SB)
	RET

goexit0 函数的实现:

go 复制代码
// Finishes execution of the current goroutine.
func goexit1() {
	if raceenabled {
		racegoend()
	}
	if traceEnabled() {
		traceGoEnd()
	}
	mcall(goexit0)
}

// goexit continuation on g0.
func goexit0(gp *g) {
	mp := getg().m
	pp := mp.p.ptr()

    									
	casgstatus(gp, _Grunning, _Gdead)		// 设置g的状态为Gdead
	
	gp.m = nil								// 还原g的默认值,方便后续新的g直接引用
	locked := gp.lockedm != 0
	gp.lockedm = 0
	mp.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 = waitReasonZero
	gp.param = nil
	gp.labels = nil
	gp.timer = nil
    
	gfput(pp, gp) 							// 将释放的gp添加到p.freelist中,用于后续go func更加便捷的获取g

	schedule()								// 最神秘的地方,在每次结束一个g的执行之后,重新开始调度,紧跟上游
}

通过我们对上面几个函数的实现分析,我们可以得出下面的结论:

  • 创建goroutine之后,开始执行 m 的调度
  • m 调度执行通过获取 P 可运行的队列(G的出对顺序和优先级上面已经说过)
  • 切换g0栈为可运行g的栈
  • 执行 UserCode,也就是我们的用户代码
  • 跳转到 goexit 完成收尾工作
  • 切换g栈为g0栈
  • 回收g,放入 p.gFree 链表中
  • 重新执行schedule调度函数,由此实现Runtime调度循环

OK,我们来看下其具体的函数调用量流程关系:

我想到了这里,大家都已经明白了 Runtime 调度循环的原理了吧。哈哈哈哈

四、go func 创建普通协程

到了这里,我想必大家都应该理解了GMP的基本工作原理,以及GMP之间的各个关系,虽然到了这里我还没有总结。

我们通过编译下面代码,并通过反编译来查看具体函数调用关系:

go 复制代码
func main() {
	testGoroutine()
}

func testGoroutine() {
	go func() {
		fmt.Println("hello word")
	}()
}

我们按照本文开头给出的指示我们来进行反编译:

go 复制代码
go build -gcflags="-N -l" main.go
go tool objdump -s "main.testGoroutine" -S main

当我们通过执行上面命令后,则控制台会输出如下指令(Golang源代码对应的汇编),我们可以一眼就看到 go func 的实现逻辑其就是调用了 runtime.newproc 函数。而该函数我们在上面创建 main goroutine 的时候已经将过了。

go 复制代码
func testGoroutine() {
  0x483d40              493b6610                CMPQ SP, 0x10(R14)
  0x483d44              761b                    JBE 0x483d61
  0x483d46              55                      PUSHQ BP
  0x483d47              4889e5                  MOVQ SP, BP
  0x483d4a              4883ec08                SUBQ $0x8, SP
        go func() {
  0x483d4e              488d0533380200          LEAQ go:func.*+552(SB), AX
  0x483d55              e8469dfbff              CALL runtime.newproc(SB)
}

在这里我要重新梳理一个概念就是:Golang Runtime 中的 main goroutine 和我们用户代码中的 go func(){}() 其实是一模一样的,只不过 Golang 默认给我们省略掉了 go 关键字而已,所以,我们这里普通的 g 创建流程直接参考 main goroutine 的创建流程即可。

五、GMP 调度模型总结

最后最后最后,我们总结一下GMP调度模型以及GMP之间的关系:

5.1 GMP之间的关系:

  • m拥有g和p
  • p拥有m和g
  • g拥有m

为什么要这么设计呢?

  • m拥有g正常,可以理解,拥有现在运行的g
  • m拥有p则是为了降低锁的粒度,试想,如果没有P呢只有一个全局队列呢?那么必然有一个全局锁,所有的M都去竞争一个锁来获取要执行的g
  • p拥有m则是确定当前p是在哪个m上运行
  • p拥有g则是为了降低锁的粒度,提供g的本地队列
  • g拥有m表示当前g正在那个m上运行

5.2 g 入队优先级

  • 体现局部优先性,如果指定了next,则将其放入到next中,然后将oldNext放入到p的本地队列或者全局队列中。
  • 优先放入P的本地队列,当P的本地队列大于256时则存入到全局队列中。
  • 存入全局队列。
  • 如果g没有放入到P中,则一直循环放入,g 必须一定放入到某个P中。

5.3 g 调度优先级

  • 当前P执行调度61次则从全局队列中获取一个g执行
  • 本地队列中获取
  • 全局队列中获取
  • 网络连接中获取

最后,我们通过一张图来总结 GMP 调度模型:

个人博客:openxm.cn 微信公众号:社恐的小马同学 有兴趣的可以多多关注,我们都奔走在同一条康庄大道上,只有互相分享、互相交流才不会不断成长。

参考:

相关推荐
Pitayafruit6 分钟前
Spring AI 进阶之路04:集成 SearXNG 实现联网搜索
spring boot·后端·ai编程
IT毕设梦工厂7 分钟前
大数据毕业设计选题推荐-基于大数据的1688商品类目关系分析与可视化系统-Hadoop-Spark-数据可视化-BigData
大数据·毕业设计·源码·数据可视化·bigdata·选题推荐
风象南8 分钟前
SpringBoot 自研「轻量级 API 防火墙」:单机内嵌,支持在线配置
后端
液态不合群9 分钟前
下划线字段在golang结构体中的应用
go
Victor35625 分钟前
Redis(14)Redis的列表(List)类型有哪些常用命令?
后端
Victor35626 分钟前
Redis(15)Redis的集合(Set)类型有哪些常用命令?
后端
卷福同学27 分钟前
来上海三个月,我在马路边上遇到了阿里前同事...
java·后端
bobz9659 小时前
小语言模型是真正的未来
后端
DevYK10 小时前
企业级 Agent 开发实战(一) LangGraph 快速入门
后端·llm·agent
一只叫煤球的猫10 小时前
🕰 一个案例带你彻底搞懂延迟双删
java·后端·面试