大家好,我是木川
Go 语言的并发性能的关键组成部分在于其调度原理,Go 使用一种称为 M:N 调度的模型,其中 M 代表操作系统的内核态线程,而 N 代表 用户态线程 Goroutines( Go 语言的轻量级线程)
实质上,Goroutine 调度是将 Goroutine(G)按照特定算法分派到 CPU 上执行的过程。由于 CPU 无法感知Goroutines,只能感知内核线程,因此需要 Go 调度器将 Goroutines 调度到内核线程M上,然后由操作系统调度器将内核线程 M 放入 CPU 上执行。M 实际上是对内核级线程的封装,因此 Go 调度器的核心任务是 Goroutines 分配给 M
Go 调度器的实现经历了多次演化,包括从最初的 GM 模型 到 GMP 模型,从不支持抢占到支持协作式抢占,再到支持基于信号的异步抢占。这个演化过程经历了不断的优化与打磨,以提高 Go语言的并发性能
在Go语言中,并发处理的基本单位是 Goroutines,它们是轻量级的线程,由 Go 运行时调度和管理。这一调度系统的核心是GMP模型,包括三个主要组件:
G(Goroutines):用户线程,通过 go 关键字创建
M(Machine):操作系统线程
P(Processor):调度上下文,维护了一组 Goroutine 队列
其中 Goroutines 相对于传统线程占用内存更低,它们的创建和销毁成本非常低,因此可以轻松创建成千上万个Goroutines,而不会导致大量的资源消耗。这一特性在高并发应用中非常有用,例如我们需要编写一个网络服务器,每个客户端连接都需要一个独立的Goroutine来处理请求。在传统的线程模型下,为每个连接创建线程可能会导致资源耗尽,但在Go中,可以轻松创建成千上万个Goroutines来同时处理客户端请求,而不会带来明显的性能问题。
多个协程绑定不同的操作系统线程,可以利用多核 CPU
work stealing 机制 :线程 M ⽆可运⾏的 G 时,尝试从其他 M 绑定的P 偷取 G,减少空转 hand off 机制:线程 M 因为 G 系统调用阻塞时,将 P 转交给其他空闲的 M 执行,M 执行 P 的剩余G
避免某些 Goroutine 长时间占用线程,造成其它 Goroutine 饥饿,解决公平性问题
Go 调度器负责调度 G 给 M, Go 调度器是属于 Go 运行时中的一部分,Go 运行时 、负责实现Go的并发调度、垃圾回收、内存堆栈管理等关键功能
G 的来源
P的runnext(只有1个G,局部性原理,永远会被最先调度执行)
P的本地队列(数组,最多256个G)
全局G队列(链表,无限制)
网络轮询器_network poller_(存放网络调用被阻塞的G)
P 的来源
全局P队列(数组,GOMAXPROCS个P)
M 的来源
休眠线程队列(未绑定P,长时间休眠会等待GC回收销毁)
运行线程(绑定P,指向P中的G)
自旋线程(绑定P,指向M的G0)
在以下情形下,会切换正在执行的goroutine
抢占式调度
sysmon 检测到协程运行过久(比如sleep或死循环),切换到g0,进入调度循环
主动调度
新起一个协程和协程执行完毕,触发调度循环
主动调用runtime.Gosched(),切换到g0,进入调度循环
垃圾回收stw之后,会重新选择g开始执行
被动调度
系统调用阻塞(同步),阻塞G和M,P与M分离,将P交给其它M绑定,其它M执行P的剩余G
网络IO调用阻塞(异步),阻塞G,G移动到NetPoller,M执行P的剩余G
atomic/mutex/channel等阻塞(异步),阻塞G,G移动到channel的等待队列中,M执行P的剩余G
协程的调度采用了生产者-消费者模型,实现了用户任务与调度器的解耦
生产端我们开启的每个协程都是一个计算任务,这些任务会被提交给 go 的 runtime。如果计算任务非常多,有成千上万个,那么这些任务是不可能同时被立刻执行的,所以这个计算任务一定会被先暂存起来,一般的做法是放到内存的队列中等待被执行。
G的生命周期:G 从创建、保存、被获取、调度和执行、阻塞、销毁,步骤如下:
执行 go func 的时候,主线程 M0 会调用 newproc()生成一个 G 结构体
创建的 G 优先保存到本地队列 P,如果 P 满了,则会平衡部分P到全局队列中
每个协程 G 都会被尝试先放到 P 中的 runnext,若 runnext 为空则放到 runnext 中,生产结束
若 runnext 满,则将原来 runnext 中的 G 踢到本地队列中,将当前 G 放到 runnext 中,生产结束
若本地队列也满了,则将本地队列中的 G 拿出一半,放到全局队列中,生产结束
找到一个M进入调度循环:重复步骤4、5、6
具体见下面的调度策略
M调用 G.func() 函数执行 G
如果 M在执行 G 的过程发生系统调用阻塞(同步),会阻塞G和M(操作系统限制),此时P会和当前M解绑,并寻找新的M,如果没有空闲的M就会新建一个M ,接管正在阻塞G所属的P,接着继续执行 P中其余的G,这种阻塞后释放P的方式称之为hand off。当系统调用结束后,这个G会尝试获取一个空闲的P执行,优先获取之前绑定的P,并放入到这个P的本地队列,如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入到全局队列中。
如果M在执行G的过程发生网络IO等操作阻塞时(异步),阻塞G,不会阻塞M。M会寻找P中其它可执行的G继续执行,G会被网络轮询器network poller 接手,当阻塞的G恢复后,从network poller 被移回到P的本地队列中,重新进入可执行状态。异步情况下,通过调度,Go scheduler 成功地将 I/O 的任务转变成了 CPU 任务,或者说将内核级别的线程切换转变成了用户级别的 goroutine 切换,大大提高了效率。
M执行完G后清理现场,重新进入调度循环(将M上运⾏的goroutine切换为G0,G0负责调度时协程的切换)
使用什么策略来挑选下一个goroutine执行:由于 P 中的 G 分布在 runnext、本地队列、全局队列、网络轮询器中,则需要挨个判断是否有可执行的 G,大体逻辑如下:
每执行61次调度循环,从全局队列获取G,若有则直接返回(主要避免全局队列中的G饿死)
从P 上的 runnext 看一下是否有 G,若有则直接返回
从P 上的 本地队列 看一下是否有 G,若有则直接返回
上面都没查找到时,则去全局队列、网络轮询器查找或者从其他 P 中窃取,一直阻塞直到获取到一个可用的 G 为止
源码实现如下:
func schedule() {
_g_ := getg()
var gp *g
var inheritTime bool
...
if gp == nil {
// 每执行61次调度循环会看一下全局队列。为了保证公平,避免全局队列一直无法得到执行的情况,当全局运行队列中有待执行的G时,通过schedtick保证有一定几率会从全局的运行队列中查找对应的Goroutine;
if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
// 先尝试从P的runnext和本地队列查找G
gp, inheritTime = runqget(_g_.m.p.ptr())
}
if gp == nil {
// 仍找不到,去全局队列中查找。还找不到,要去网络轮询器中查找是否有G等待运行;仍找不到,则尝试从其他P中窃取G来执行。
gp, inheritTime = findrunnable() // blocks until work is available
// 这个函数是阻塞的,执行到这里一定会获取到一个可执行的G
}
...
// 调用execute,继续调度循环
execute(gp, inheritTime)
}
在实际应用中,Go 已经证明了其在高并发环境中的优越性能,例如,高并发的Web服务器、分布式系统和并行计算都受益于 GMP 模型。了解和利用GMP模型将使你的程序更具竞争力,并能够有效地处理大规模并发。
最后给自己的原创 Go 面试小册打个广告,如果你从事 Go 相关开发,欢迎扫码购买,目前 10 元买断,加下面的微信发送支付截图额外赠送一份自己录制的 Go 面试题讲解视频
如果对你有帮助,帮我点一下在看或转发,欢迎关注我的公众号