Go GMP 调度模型

787 阅读8分钟

这是我参与2022首次更文挑战的第9天,活动详情查看:2022首次更文挑战

众所周知go相比其他的语言,经常被提到的就是它在并发编程方面的强大能力

基本知识

  • 进程与线程的关系
  • 线程的基本调度也是需要陷入内核态
    • 切换时虽然不涉及到内存的切换,但是涉及到寄存器的切换
    • 线程自己本身占有一定的空间,在切换的时候也是需要处理的,涉及到空间的申请以及资源的销毁
  • go使用GMP模型,使用与CPU数量相等的线程数减少线程切换引起的开销
    • 使用的是处于用户态的Goroutine切换,避免频繁陷入内核态

基本结构

Go使用的是GMP模型,由GM模型演变而来

  • G:Goroutine
  • M:Machine,操作系统的执行线程
  • P:调度器,处理M与G的关系

G

  • 类似操作系统中的线程
  • 提供于用户态,粒度更小,切换代价更小
  • 占用空间更小,切换代价更小
type g struct {
	stack       stack // 当前G的栈范围
	stackguard0 uintptr // 判读当前G是否被抢占

	preempt       bool // 抢占信号
	preemptStop   bool // 抢占时将状态修改成 `_Gpreempted`
	preemptShrink bool // 在同步安全点收缩栈

	_panic       *_panic // 最内侧的 panic 结构体
	_defer       *_defer // 最内侧的延迟函数结构体

	m              *m // 当前G占用的线程
	sched          gobuf // 调度相关数据的存储
	atomicstatus   uint32 // G的状态
}

M

  • P最多可以创建10000个线程
  • 最多只有GOMAXPROCS个活跃线程(与核数一致),这样不会频繁地切换线程上下文
type m struct {
	g0   *g // 调度栈   使用的G
	curg *g // 当前在M上运行的G

	p             puintptr // 正在运行代码的P
	nextp         puintptr // 暂存的P
	oldp          puintptr // 之前使用的P
}

P

  • 调度线程上执行的G,可以让出那些等待资源(如网络、IO)的G,提高运行效率
  • 同时提供M执行所需要的上下文环境以及资源
type p struct {
	m           muintptr // 调度的M

	runqhead uint32 // G队列头
	runqtail uint32 // G队列尾
	runq     [256]guintptr // G队列
	runnext guintptr // 下一个可运行的G

	status int // 当前P的状态
}

状态有以下几个取值

  • _Pidle:运行队列为空,没有需要运行的G
  • _Prunning:M正在执行用户G
  • _Psyscall:M处于系统调用
  • _Pgcstop:M处于GC垃圾回收的stop中
  • _Pdead:P不再被使用

发展历史

0.x版本

实现了最基本的GM模型,同时全局只有一个M线程来执行G,寄存器、计数器为共享变量

  • 对于共享变量的操作需要使用锁
  • M切换G过程和线程切换没有太大差别,由Go语言来实现切换过程中Goroutine的上下文信息以及运行状态的改变
  • 由于只有一个M,因此只能进行单线程执行

1.0版本

支持了多线程调度

  • 调度器(调度部分的代码)会使用到大量全局变量、调度状态与情况,锁竞争严重
    • 线程等待锁,导致频繁地阻塞与唤醒线程
  • 不同线程之间共享Goroutine池,导致缓存容易失效,存在不同线程之间传递Goroutine的情况

1.1版本

在G、M中间增加一层P调度器,在此基础上实现工作窃取调度器

  • 通过runtime.runqget(P),可以获取当前调度器上的可执行G
    • 并不是通过M直接绑定对应的可执行G队列,而是通过P来绑定
  • 如果当前调度器上没有可执行G,调用runtime.findrunnable()从全局其他P随机获取可执行G
    • 顺序获取
  • P将选择的G放入自己负责的M上执行,相当于将G队列与M进行了绑定
struct P {
	Lock;

	uint32	status;
	P*	link;
	uint32	tick;
	M*	m;
	MCache*	mcache;

	G**	runq;
	int32	runqhead;
	int32	runqtail;
	int32	runqsize;

	G*	gfree;
	int32	gfreecnt;
};

1.2至今

实现了抢占式调度,在之前的版本中,只有Goroutine主动让出M,才会进行Goroutine的切换,存在

  • 一个G运行时间过长,导致其他G饿死

基于协作的抢占式调度

利用分段栈机制实现抢占调度,使用编译器在分段栈上插入的函数,所有的Goroutine在进行函数调用的时候,所有Gouroutine在调用函数的时候都可能去检查是否需要进行抢占

  • 当前G是否发出抢占请求看stackguard0
    • 如果该字段为StackPreempt,意味着当前G发出了抢占请求
  • G进行GC、系统发现G运行时间超过10ms,会发出抢占请求
  • 当前G调用函数之前会去执行runtime.morestack,会去检查当前G的stackguard0字段
    • 如果是StackPreempt,让出当前线程

比较不好的是,只有调用函数才能触发切换,如果是长函数就不太好了

基于信号的抢占式调度

实际上利用了系统的线程之间的信号,通过对应信号的注册函数来实现G的切换

  • 在启动程序的时候,会去注册 SIGURG 信号对应的函数runtime.doSigPreempt
  • 在GC的时候会触发栈的扫描
    • 将_Grunning状态的G标记为可抢占
    • 调用runtime.preemptM触发抢占
      • 会向线程发送SIGURG信号
      • 陷入内核态,调用注册的runtime.doSigPreempt函数
        • 会处理抢占信号SIGURG,获取当前运行的PC SP
        • 修改返回用户态的时候执行的函数为asyncPreempt
      • 返回用户态,调用asyncPreempt
        • 调用asyncPreempt2
          • 调用preemptPark
          • 修改当前G状态为_Gpreempted,并且调用schedule让当前函数陷入休眠,切换G

关于什么是安全点:zhuanlan.zhihu.com/p/286110609 我理解就是在这一个地方确定下所有的引用关系,并且不会在这个地方发生引用的改变\

STW、栈扫描是安全点,优先在此添加抢占

调度过程

调度启动

  • 初始化的时候赋值为10000个(一个go程序最大的线程数),但是调用procresize使用的是GOMAXPROCS个
    • 如果M数量不足,会调用newm生成
  • 初始化新的调度器P,放到allp[0]中,为队列头
  • 绑定m0与allp[0]
  • 释放不再使用的P
  • 截断allp使其与GOMAXPROCS保持一致
  • 将allp中的P状态初始化为_Pidle(除了allp[0])

m0应该是当前运行的最开始的那个M

创建新的G

使用go关键字可以创建新的G

  • 调用runtime.newproc
    • 传入go关键字对应函数以及其参数
    • 获取调用方的计数器
    • 调用newproc1构建G,放入队列
    • 如果条件满足,启动新的P来处理
      • 如果有闲置的M就绑定闲置的M
      • 没有就新建
  • newproc1
    • 寻找空闲的G,根据G的状态来判断是否空闲
    • 无空闲则new一个,并且分配栈空间,追加到全局的allgs里面
    • 拷贝参数到栈上,整个内存空间拷贝上去
    • 更新新的G,包括栈指针、计数器
      • g.sched.pc 与 g.gopc 有啥区别?为啥sched.pc是goexit的PC?
      • 两者存储的似乎都是执行函数的地址
  • 实际上有两个队列
    • M自己有个队列为本地运行队列
    • P调度起持有的全局队列

调度循环

P启动后会调用runtime.shedule开始调度循环

  • 获取待执行的G
    • 通过schedtick的方式,有一定几率从全局运行队列获取G
    • 从M本地队列查找待执行的G
    • 都没有则会调用findrunable阻塞查找可执行的G
      • 可以从其他的M队列中窃取G
  • 获取到G之后
    • 获得当前G的M
    • 修改要执行的G的状态
    • 调用gogo将要执行的G调度到当前M上
      • 会将sched.pc放到栈SP上,利用了go call的特性,返回地址都会放在SP中
      • 这就是为sched.pc与pc不一致,且使用的是goexit
      • 将pc的值放在BX,在完成调用前的准备后,JMP BX
  • 完成调用后会调用goexit
    • 标记G为_Gdead状态,清理字段啥的
    • 放到空闲G的队列
    • 重新调用schedule函数

触发调度

主要、重要的几个触发路径

  • 主动挂起:gopark
  • 系统调用:exitsyscall
  • 协作调度:Gosched
  • 系统监控:sysmon

主动挂起

  • 调用gopark
    • 暂停当前G
  • 调用park_m
    • 修改当前G为_Gwaiting
    • 调用dropg解除G与M之间的关系
    • 触发schedule开始新一轮调度
    • 在满足一定条件后,会将_Gwaiting状态的G修改外_Grunnable并且加入M的队列

系统调用

  • G中存在_Gsyscall状态
  • go实现了其他新的汇编方法
    • 在进行syscall的时候会先调用entersyscall以及exitsyscall
    • entersyscall
      • 保存当前的PC以及栈指针SP
      • G状态修改为_Gsyscall
      • P状态修改为_Psyscall
      • 分离P、M
        • P与M并不是不可分离的,防止P中的其他G无法获取M资源而饿死
    • 在进行系统调用的时候,M实际上也是在运行代码的,在完成系统调用之后,会回调
    • exitsyscall
      • 原P处于_Psyscall状态,调用wirep将G与P重新绑定
      • 获取闲置的P,绑定当前G
    • 触发新的schedule

协作式调度

在栈中会插入对应的函数Gosched,在进行函数调用的时候会触发

实现较为简单

  • 判断一下是否超时
  • 切换G状态为_Grunnable
  • 分离G、M
  • 将G塞到队列
  • 调用schedule触发调度

P只是一个调度器,处理G、M之间的关系,为M切换G使用的中间代码