go-GMP和Scheduler

GPM模型

  • G 待执行的goroutine,结构定义在runtime.g

  • M 操作系统中的线程,它由操作系统的调度器 进行 调度和管理, 结构定义在runtime.m

  • P 处理器,是GM的中间件,它通过一个队列绑定了GM,每个P都有一个局部queue,用来存放待执行的本地goroutine

G

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元。

Goroutine 在 Go 语言运行时使用私有结构体 runtime.g 表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量。

runtime.g结构

runtime.g存放在内存堆上,对所有线程都共享

栈相关字段

go 复制代码
type g struct {
	stack       stack
	stackguard0 uintptr
}
  • stack: 栈内存范围 [stack.lo, stack.hi)
  • stackguard0:用于调度器抢占式调度,该字段被设置成 StackPreempt 意味着当前 Goroutine 发出了抢占请求;

抢占式调度字段

go 复制代码
type g struct {
	preempt       bool // 抢占信号
	preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
	preemptShrink bool // 在同步安全点收缩栈
}

defer和panic链表字段,链表头插入,链表头获取

go 复制代码
type g struct {
	_panic       *_panic // 最内侧的 panic 结构体
	_defer       *_defer // 最内侧的延迟函数结构体
}

调度相关

go 复制代码
type g struct {
	m              *m
	sched          gobuf
	atomicstatus   uint32
	goid           int64
}
  • m:执行当前g的线程m(runtime.m)
  • sched:调度器结构体,里面有全局runq等信息
  • atomicstatus:goroutine的状态
  • goid:协程ID

G状态

状态 描述
_Gidle 刚被分配,但未被初始化
_Grunnable 未执行代码,没有栈的所有权,存储在运行队列中
_Grunning 可以执行代码,拥有栈的所有权,被赋予了内核线程M和处理器P
_Gsyscall 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程M但是不在运行队列上
_Gwaiting 由于运行时而被阻塞,没有执行用户代码并且不再运行队列上,但是可能存在于channel的等待队列上
_Gdead 没有被使用,没有执行代码,可能有分配的栈
_Gcopystack 栈正在拷贝,没有执行代码,不在运行队列上
_Greempted 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_Gscan GC正在扫描空间,没有执行代码,可以于其他状态同时存在
  • 等待中状态:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting_Gsyscall_Gpreempted 几个状态;
  • 可运行状态:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable
  • 运行中状态:Goroutine 正在某个线程上运行,即 _Grunning

G状态转换

M

​ Go 语言并发模型中的 M 是操作系统内核线程 。调度器最多可以创建 10000 个线程 ,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行(因为运行时需要绑定P,P的数量是由GOMAXPROCS 决定的)。

​ 在默认情况下,一个四核机器会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。

在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 数,默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少很多额外开销。

runtime.m结构

结构体runtime.m表示操作系统线程,这个结构体也包含了几十个字段

与协程相关字段:

go 复制代码
type m struct {
	g0   *g
	curg *g
	...
}
  • g0:每个m都会初始化一个g0,用来切换调度,g0持有调度栈
  • curg:当前执行g(当需要调度其他g时,先切换至g0)

与处理器P相关字段:

go 复制代码
type m struct {
	p             puintptr
	nextp         puintptr
	oldp          puintptr
}
  • p:m绑定的p,获取本地goroutine
  • nextp:暂存的p,如果M阻塞,当前p会分给其他m,唤醒时就从nextp获取。
  • oldp:切换p后时,把nextp指向当前p,p指针指向新的处理器p

P

​ 调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列(runq本地队列),通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。

因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上。

runtime.p结构

go 复制代码
type p struct {
	m           muintptr

	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	runnext guintptr
	...
}
  • m:p绑定的内核线程m
  • runqhead:本地队列头
  • runqtail:本地队列尾
  • runq:本地队列,用来存放G
  • runnext:下一个要执行的G

P状态

处理器P状态:

状态 描述
_Pidle 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空
_Prunning 被线程 M 持有,并且正在执行用户代码或者调度器
_Psyscall 没有执行用户代码,当前线程陷入系统调用
_Pgcstop 被线程 M 持有,当前处理器由于垃圾回收被停止
_Pdead 当前处理器已经不被使用

​ 通过分析处理器 P 的状态,我们能够对处理器的工作过程有一些简单理解,例如处理器在执行用户代码时会处于 _Prunning 状态,在当前线程执行 I/O 操作时会陷入 _Psyscall 状态。

P状态转换

调度器设计原理

单线程调度

多线程调度器

任务窃取调度器

抢占式调度器

go服务启动

步骤:

  1. osinit:系统初始化
  2. schedinit:go服务调度器初始化
  3. new main goroutine
  4. mstart:线程启动,开启调度循环,m0开始获取g和创建其他m

schedinit 调度器初始化

变量概念

  • 变量g0存在TLS中):负责调度工作,存放着调度栈信息,每一个m都会有一个自己的g0,g0的协程栈空间是在主线程栈上分配的
  • 变量m0(存在TLS中):程序启动后编号为0的主线程,用于创建P和启动main.main(),之后和其他m一样
  • 全局变量allp[](存在静态数据段):根据环境变量GOMAXPROCS创建N个p,这个切片存放这些p的指针
  • 全局变量allgs[](存在静态数据段):,这个切片存放所有g的指针,存在静态数据段
  • 全局变量allm[](存在静态数据段):记录所有的m,
  • 全局变量sched结构体(存在静态数据段):
    • midle:空闲的m
    • pidle:空闲的p
    • runq:全局的queue队列,用来存放待运行的g

TLS(thread local storage)是每个线程私有的存储空间
静态数据段:是存放全局变量的,是所有thread公用的内存段,所以需要加锁来保证线程安全

初始化步骤

每一个m创建时都会进行调度器初始化。以下是第一个m0的初始化步骤:

  1. 创建g0、m0,他们相互存着对方的指针是1:1绑定的
  2. m0根据环境变量GOMAXPROCS创建N个p,绑定allp[]全局变量
  3. 通过指针将m0和处理器allp[0]绑定
  4. 将allp[0]以外的处理器设置成__Pidel__状态
  5. 创建了一些全局变量allgs[]allm[]sched

new main goroutine

调用newproc()方法来创建main函数的协程,加入到m0本地队列的P中

创建goroutine

以下面例子做协程创建

go 复制代码
package main 


func helle(name string) {
    fmt.Println("Hello ", name)
}

func main() {
    name := "Goroutine"
    go hello(name)	// 调用newproc()创建协程
}

以下是代码函数栈帧的变化,栈内变量是由高到底存放"

  1. 执行main函数,创建main函数的函数栈帧:

    1. 函数返回地址addr
    2. 调用者main的栈基,BP of main
  2. 执行name:="Goroutine",存放调用者caller(main函数)的局部变量

  3. 执行go hello(name),准成机器码就是执行newproc(siz int32, fn *funcval)。变量由右至左存放到栈 siz变量, fn变量 和 参数变量name。main函数栈帧结束

  4. 调用newproc的返回地址(调用一个函数叫做call func),指向hello()

  5. 开始newprocd的栈基,BP of newproc

  6. 切换到g0栈(线程里的栈帧)。调用newproc1()函数。因为线程栈比协程栈要大,防止栈溢出

  7. newproc1()会先将当前m锁住,runtime.gfget方法获取过两种不同的方式获取新的 runtime.g

    • 从 Goroutine 所在处理器的 gFree 列表或者调度器的 sched.gFree 列表中获取 runtime.g);空协程,可是已经分配了栈内存空间,避免g0的切换和g的重复销毁/创建
    • 调用 runtime.malg生成一个新的 runtime.g并将结构体追加到全局的 Goroutine 列表 allgs 中。runtime.g结构体保存在堆上(因为要线程共享),runtime.g.stack指向协程函数funcval的函数栈帧。

mstart 开启调度循环

  1. 启动调度器。Go 语言运行时会调用 runtime.mstart 以及 runtime.mstart1,前者会初始化 g0 的 stackguard0stackguard1 字段,后者会初始化线程并调用 runtime.schedule 进入调度循环。
  2. 查找可运行协程。runtime.schedule会调用runtime.findrunnabel,阻塞查找goroutine,通过以下的过程获取可运行的 Goroutine:
    • 从本地运行队列、全局运行队列中查找;
    • 从网络轮询器中查找是否有 Goroutine 等待运行;
    • 通过 runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器;
  3. 运行协程runtime.execute 执行获取的 Goroutine,做好准备工作后,它会通过 runtime.gogo 将 Goroutine 调度到当前线程上。
  4. 结束协程runtime.goexit0 函数,该函数会将 Goroutine 转换会 _Gdead 状态、清理其中的字段、移除 Goroutine 和线程的关联并调用。重新加入处理器的 Goroutine 空闲列表 gFree。返回1步骤重新一轮新的调度

触发调度器

主动挂起

  1. runtime.gopark切换到g0触发调度是最常见的方法,他会将正在运行的goroutine暂停,不会扔回runq ,状态从_Gruning变成_GWaiting
  2. 当满足特定条件后,调用runtime.goready将协程从_Gwaiting状态切换成_Grunable加入到本地队列

系统调用

系统调用也会触发运行时调度器的调度,为了处理特殊的系统调用,Goroutine 中加入了 _Gsyscall 状态。系统调用会让M和P分离,释放P,让其他空闲M绑定P。

准备工作

runtime.entersyscall 会在获取当前程序计数器和栈位置之后调用 runtime.reentersyscall,它会完成 Goroutine 进入系统调用前的准备工作:

  1. 禁止线程上发生的抢占,防止出现内存不一致的问题;
  2. 保证当前函数不会触发栈分裂或者增长;
  3. 保存当前的程序计数器 PC 和栈指针 SP 中的内容;
  4. 将 Goroutine 的状态更新至 _Gsyscall
  5. 将 Goroutine 的处理器和线程暂时分离并更新处理器的状态到 _Psyscall
  6. 释放当前线程上的锁;

恢复工作

当系统调用结束后,会调用退出系统调用的函数 runtime.exitsyscall 为当前 Goroutine 重新分配资源,该函数有两个不同的执行路径:

  1. 调用 runtime.exitsyscallfast:获取P的方式来恢复
    1. 如果原处理器任然处于_Psyscall,直接使用原处理器
    2. 如果原处理器被其他M绑定,去sched.pidle获取一个空闲p。
  2. 切换至调度器的 Goroutine 并调用 runtime.exitsyscall0:此方法是无法获取到可用P才会调用,主要就是将G状态变成_Grunable,扔到全局变量runq,由调度器去处理

协作式调度

Go 语言基于协作式和信号的两种抢占式调度,这里主要介绍其中的协作式调度。runtime.Gosched 函数会主动让出处理器,允许其他 Goroutine 运行。该函数无法挂起 Goroutine,调度器可能会将当前 Goroutine 调度到其他线程上。

最终在 g0 的栈上调用 runtime.goschedImpl,运行时会更新 Goroutine 的状态到 _Grunnable,让出当前的处理器并将 Goroutine 重新放回全局队列,在最后,该函数会调用 runtime.schedule 触发调度。

线程的生命周期

Go 语言的运行时会通过 runtime.startm 启动线程来执行处理器 P,如果我们在该函数中没能从闲置列表中获取到线程 M 就会调用 runtime.newm 创建新的线程:

clone 创建的线程会在线程主动调用 exit、或者传入的函数 runtime.mstart 返回会主动退出,runtime.mstart 会执行调用 runtime.newm 时传入的匿名函数 fn,到这里也就完成了从线程创建到销毁的整个闭环。

巨人肩膀

Golang调度器GMP原理与调度全分析

Go语言设计与实现

幼麟go系列

相关推荐
郭京京3 小时前
Go 测试
go
郭京京3 小时前
Go 语言错误处理
go
液态不合群6 小时前
下划线字段在golang结构体中的应用
go
邹小邹1 天前
Go 1.25 强势来袭:GC 速度飙升、并发测试神器上线,内存检测更精准!
后端·go
用户89535603282201 天前
Go泛型实战:告别 interface{} 地狱,从零拆解数据流处理库
go
郭京京2 天前
go语言os.Signal接收操作系统发送的信号的通道
go
郭京京2 天前
go语言context包
go
smallyu2 天前
Go 语言 GMP 调度器的原理是什么
后端·go
ERP老兵_冷溪虎山2 天前
GoLand 卡成幻灯片?Gopher 必藏的 vmoptions 调优表(续集:WebStorm 飞升后,轮到 Go 开发神器起飞)
后端·go
江湖十年2 天前
万字长文:彻底掌握 Go 1.23 中的迭代器——原理篇
后端·面试·go