这是我参与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使用的中间代码