Golang源码探究——从Go程序的入口到GMP模型

本文详细探讨了Go语言中的程序执行流程,从main函数开始,讲解了Go程序如何通过runtime进行初始化,然后进入调度器的GMP模型,包括G(goroutine)、M(工作线程)和P(处理器)的概念和交互。文章还深入解析了goroutine的创建、调度循环、抢占式调度以及系统监控线程sysmon的角色,展示了Go语言并发执行的内部机制。

在大多数的编程语言中,main函数都是用户程序的入口函数,go中也是如此。那么main.main是整个程序的入口吗, 肯定不是,因为go程序依赖于runtime,在程序的初始阶段需要初始化运行时,之后才会运行到用户的main函数,那么main.main是在哪里被调用的呢?接下来就从go程序的入口,再到go的GMP模型进行一个探究。

注意:本文使用的go sdk的版本为go1.20

1.go程序的入口

1 首先,编写一个简单的go程序,并将其进行编译,在此使用linux系统:

package main

import "fmt"

func main() {
   
   
	fmt.Println("hello,world")
}

编译:-N -l 用于阻止编译时进行优化和内联

go build -gcflags "-N -l" main.go

2 然后使用gdb来调试go程序:

首先,使用gdb加载支持调试go语言的脚本文件:

在shell中执行gdb命令,然后执行source /usr/local/go/src/runtime/runtime-gdb.py

➜  RemoteWorking git:(master) ✗ gdb
(gdb) source /usr/local/go/src/runtime/runtime-gdb.py

3 调试程序:

gdb main

在这里插入图片描述

使用info files来查看文件

可以看到程序的入口为0x45c020, 在该处打上端点,可以看到入口为_rt0_amd64_linux的函数它位于src/runtime/rt0_liunx_amd64.s的汇编文件中:

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
	JMP	_rt0_amd64(SB)

而该函数又调用了_rt0_amd64,在asm_amd64.s文件中:

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

进而又跳转到了rt0_go中:

TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
	...
	MOVL	24(SP), AX		// copy argc
	MOVL	AX, 0(SP)
	MOVQ	32(SP), AX		// copy argv
	MOVQ	AX, 8(SP)
	CALL	runtime·args(SB)
	CALL	runtime·osinit(SB)
	CALL	runtime·schedinit(SB)

	// create a new goroutine to start program
	MOVQ	$runtime·mainPC(SB), AX		// entry
	PUSHQ	AX
	CALL	runtime·newproc(SB)
	POPQ	AX

	// start this M
	CALL	runtime·mstart(SB)

	CALL	runtime·abort(SB)	// mstart should never return
	RET
...

// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

rt0_go中先是进行了一些初始化,比如runtime.osinit, runtime.schedinit

然后将runtime.mainPC的地址放入AX寄存器中,然后调用了runtime.newproc,根据下面的注释可以知道,mainPC就是runtime.main,而newproc则是创建goroutine的函数。

我们通常在程序中使用 go func()来启动一个协程,这是在go语言提供的一个语法糖,在编译时它会被翻译为newproc的调用。因此,下面的几行代码则是创建了runtime.maingoroutine,也就是主goroutine,主goroutine被创建后,只是被放入了当前p的本地队列,但是还没有得到运行。

在这里插入图片描述

接下来调用了runtime.mstart, 这个函数是除了sysmon线程以外的其它线程的入口函数,最终该函数会调用schedule函数,在schedule函数中调用findrunnable函数来获取一个可运行的goroutine,然后调用execute来执行,executegoroutine对应的g结构体中的字段进行一些设置,然后调用gogo来切换协程栈,并切换协程,因此main goroutine将会被调度执行。

如下图所示:

在这里插入图片描述

 

2 GMP模型

GMP模型是go语言goroutine的调度系统,调度是将goroutine调度到线程上执行的过程,而操作系统的调度器则负责将线程调度到CPU上运行。

2.1 GM模型

go语言早期的调度模型为GM模型,G代表goroutine,而M代表一个线程,goroutine和线程的数量有多个,那么调度器的职责就是将mgoroutine调度到n个线程上来运行。待调度的goroutine处于一个全局的调度队列globrunq中,每个线程需要从globrunq中获取goroutine来执行,那么多个线程同时访问全局队列,为了保证线程间的同步,需要加锁,那么就会导致锁争用较大,从而降低系统的效率。

在这里插入图片描述

而且一个goroutine创建的goroutine也会被放入全局队列中,同时也需要加锁。这样也会造成程序的局部性较差,因为一个goroutine创建的另一个goroutine大概率不会在同一个线程上运行。

 

2.2 改进的GMP模型

为了改进之前的缺点:1 所有线程都从全局队列获取goroutine,造成锁争用强度大。2. 程序的局部性较差

go语言引入了GMP模型,G同样代表一个goroutine,M代表machine,也就是worker thread,p代表processor,包含了运行go代码所需的资源。

官方解释:

// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
//     M must have an associated P to execute Go code, however it can be
//     blocked or in a syscall w/o an associated P.

线程是goroutine运行的载体,goroutine必须要在线程上运行。而一个线程想要运行goroutine,就需要和一个p进行关联,在每个p中都包含了一个本地runq,其中存放待运行的goroutine,线程可以从本地runq中无锁访问,减少了锁竞争的力度。本地runq的大小是有限的,最多可以存放256goroutine。除此之外,还存在一个全局的globrunq,当创建goroutine时,优先放入相关联的p的本地runq,当本地runq满了之后,新创建的goroutine就会被添加到全局globrunq中。

  • p的数量:p代表了一个逻辑处理器,p的数量一般与CPU的核心数相同,代表了可以并行运行的goroutine的数量,可以通过runtime.GOMAXPROC来设置。
  • m的数量:m表示一个线程,m的数量是不确定的,最大数量为10000个,但是正常情况下达不到这么大的数量。

在这里插入图片描述

 

2.3 相关数据结构

2.3.1 runtime.g

goroutine在runtime中表示为一个g结构体:

type g struct {
   
   
	stack       stack   // offset known to runtime/cgo
	stackguard0 uintptr // offset known to liblink

	...
	m         *m      // current m; offset known to arm liblink
	sched     gobuf
	...
	atomicstatus atomic.Uint32
	
	goid         uint64
	
	preempt       bool // preemption signal, duplicates stackguard0 = stackpreempt
}

type stack struct {
   
   
	lo uintptr
	hi uintptr
}

type gobuf struct {
   
   
	sp   uintptr
	pc   uintptr
	g    guintptr
	...
}

省略了一些不太关心的字段,其中的一些字段的含义如下:

字段 用途
stack goroutine的栈空间,表示栈空间的一个界限
stackguard0 栈的上限,它的值一般是stack.lo+StackGuard,用于判断是否需要栈增长。由于goroutine的栈在初始化只有2K,并且是可以动态增长的,因此在函数调用时会判断栈空间是否够用,如果不够用会进行扩容。同时该字段可能会被设置为StackPreempt来表示抢占当前goroutine。
m 关联到当前正在运行goroutine的m
sched 保存gouroutine的执行上下文,比如栈指针sp,程序计数器pc
atomicstatus 一个原子变量,表示goroutine的当前状态
goid goroutine的id,goroutine的id由p进行分配,p会从全局缓存处取一批id缓存起来
preempt 抢占标识,为true时,调度器会在合适的时机触发一次抢占

goroutine是一个有栈协程,stack字段用于描述协程的栈,goroutine的初始栈大小为2K,并且是从堆中分配的,是可以动态增长的。

sched用来存储goroutine执行的上下文,它与goroutine切换的底层实现相关,其中sp标识stack pointer,pc为program counter,g用来反向关联到当前g。

 

g的状态:

atomicstatus字段表示goroutine的状态,goroutine有多种状态:

状态 含义
_Gidle 当前goroutine刚被分配,还没有被初始化
_Grunnable 当前goroutine处于待运行状态,他可能处于p的本地runq或者globrunq中,当前并没有在运行用户代码,它的栈也不归自己所有。
_Grunning 当前goroutine正在运行用户代码,有关联的M和P。不会处于任何runq中,栈归该goroutine所有。
_Gsyscall 当前goroutine正在执行系统调用,并没有在执行用户代码,拥有栈,而且被分配了M。
_Gwaiting 当前goroutine处于阻塞状态,即不再runq中,也没有得到运行。它肯定被记录在某个地方,比如chan的阻塞队列、mutex的阻塞队列中。
_Gdead 当前goroutine没有在使用,可能存在一个free list中或者刚刚被初始化。
_Gcopystack 当前goroutine的栈正在被移动,没有在执行用户代码也不在一个runq中。

 

2.3.2 runtime.m

GMP中的M代表一个工作线程,在runtime中使用m结构体来表示:

type m struct {
   
   
	g0      *g     // goroutine with scheduling stack
	gsignal       *g                // signal-handling g	
	curg          *g       // current running goroutine	
	p             puintptr // attached p for executing go code (nil if not executing go code)	
	id            int64	
	preemptoff    string // if != "", keep curg running on this m
	locks         int32
	spinning      bool // m is out of work and is actively looking for work
	mOS
}

省略了其中一些不太关心的字段,其中一些字段的含义如下:

字段 用途
g0 每个工作线程都拥有一个g0,它的栈比普通线程的栈要大,是分配在线程栈上的。g0主要用来运行调度器代码,当需要调度新的协程运行时,就会切换到g0栈上来运行调度程序。
gsignal 用来处理操作系统信号的goroutine
curg 指向当前正在运行的g
p 关联到的p
id 线程的唯一ID
preemptoff 不为空时表示要关闭对curg的抢占,字符串的内容给出了相关的原因
locks 当前M持有锁的数量
spining 表示当前线程处于自旋状态
mOS 平台相关的线程

 

2.3.3 runtime.p

GMP中的p代表processor,其中包含了一系列用于运行goroutine的资源,比如本地runq、堆内存缓存、栈内存缓存、goroutine id缓存等,在runtime中使用p结构体表示:

type p struct {
   
   
	id          int32
	status      uint32 // one of pidle/prunning/...

	schedtick   uint32     // incremented on every scheduler call
	syscalltick uint32     // incremented on every system call
	sysmontick  sysmontick // last tick observed by sysmon
	m           muintptr   // back-link to associated m (nil if idle)
    
	// Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen.
	goidcache    uint64
	goidcacheend uint64

	// Queue of runnable goroutines. Accessed without lock.
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	
	runnext guintptr

	// Available G's (status == Gdead)
	gFree struct {
   
   
		gList
		n int32
	}

	// preempt is set to indicate that this P should be enter the
	// scheduler ASAP (regardless of what G is running on it).
	preempt bool
}

其中省略了一些不太关心的字段,一些字段的含义如下:

字段 用途
id p的唯一ID,等于在allp数组中的下标
status 表示p的状态
schedtick 记录了调度发生的次数,每调度一次goroutine并且不继承时间片的情况下,将该字段加1
syscalltick 记录发生系统调用的次数
sysmontick 被监控线程用来存储上一次检查时的调度器时钟滴答,用以实现时间片算法
m 当前关联的m
goidcache、goidcacheend goroutine id缓存,会从全局缓存中申请一批来减少锁争用
runqhead、runqtail、runq 本地goroutine运行队列,使用一个数组和一头一尾组成一个环形队列
runnext 如果不为nil,则指向一个被当前G准备好的就绪的G,接下来会继承当前G的时间片开始运行。
gFree 用来缓存已经推出的g,方便下次申请时复用
preempt 该字段用于支持异步抢占机制
p的状态:
状态 含义
_Pidle 当前p处于空闲状态,没有被用于执行用户代码或调度。p处于idle list中,它的本地runq是空的
_Prunning 当前p与一个m进行关联并且被用于执行用户代码或者调度
_Psyscall 当前p没有在运行用于代码,它与系统调用中的M有亲和关系,但不属于它,并且可能被另一个M窃取。这类似于_Pidle,但使用轻量级转换并维护M亲和关系。
_Pgcstop 当前p因为STW而停止
_Pdead 停用状态,因为GOMAXPROC可用收缩,会造成多余的p被停用。一旦GOMAXPROC重新增长,那么停用的p会被重新启用。

 

2.3.4 runtime.schedt

还有另一个和调度相关的数据结构需要关注,就是runtime.schedt,其中包含了调度的一些全局数据,schedt类型的实例只会存在一个:

var (
	allm       *m        // 所有m组成一个链表
	gomaxprocs int32     // 对应与GOMAXPROC
	ncpu       int32     // CPU核心数
	
	sched      schedt   // 调度器相关的数据结构
	


	allpLock mutex     // 保护allp的锁
	allp []*p          // 所有的p
)

schedt结构如下:

其中全局runq就存在与schedt结构中

type schedt struct {
   
   
	goidgen   atomic.Uint64    
	
	midle        muintptr // idle m's waiting for work
	nmidle       int32    // number of idle m's waiting for work
	mnext        int64    // number of m's that have been created and next M ID
	maxmcount    int32    // maximum number of m's allowed (or die)
	nmsys        int32    // number of system m's not counted for deadlock
	nmfreed      int64    // cumulative number of freed m's

	ngsys atomic.Int32 // number of system goroutines

	pidle        puintptr // idle p's
	npidle       atomic.Int32
	nmspinning   atomic.Int32  // See "Worker thread parking/unparking" comment in proc.go.

	// Global runnable queue.
	runq     gQueue
	runqsize int32

	// Global cache of dead G's.
	gFree struct {
   
   
		lock    mutex
		stack   gList // Gs with stacks
		noStack gList // Gs without stacks
		n       int32
	}
}
字段 用途
goidgen 全局goid的分配器,以保证goid的唯一性。P中的goidcache就是从这里批量获取的。
midle 空闲M链表的链表头
nmidle 空闲M的数量
mnext 记录共创建了多少个M,同时也被用于下一个M的ID
maxmcount 允许创建的M的最大数量
nmsys 系统M的数量
nmfreed 统计已经释放的M的数量
ngsys 系统goroutine的数量
pidle 空闲P链表的表头
npidle 空闲P的数量
nmspining 处于自旋状态的M的数量
qunq、runqsize 全局就绪goroutine队列,需要加锁访问
gFree 用来缓存已经退出的g

 

2.4 g0、m0

在每

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值