前言
并发(并行)一致都是编程语言的核心主题,不同于其他语言,例如C/C++语言用户序自行借助pthread创建线程,Golang天然就给出了并发解决方案:goroutine。
Goroutine
写过Golang程序的朋友都知道,go func就可以启动一个goroutine,但是goroutine究竟是什么呢?goroutine是一个用户态的线程,或者说是逻辑线程,或者说是golang实现的协程。但是操作系统并不知道goroutine,goroutine的调度由golang runtime负责,对应的操作系统执行体线程也是由runtime负责创建。这就涉及goroutine的G-P-M调度模型。
G-P-M调度模型
Golang能够拥有强大的并发能力需要归功于G-P-M调度模型,首先需要解释G、P、M分别代表什么:
- G 代表Goroutine,每个Goroutine对应一个G结构体,G存储Goroutine的运行栈、状态以及任务信息,可重用。Goroutine栈采用按需动态分配的方式,初始化大小为2KB,最大为1GB(64位机器)。
- P 代表Processor,逻辑处理器。P维护Goroutine各种队列,mcache和状态。P的数量决定了最大可并行的Goroutine数量(前提:系统物理CPU核数>=P数量)。P的数量可以通过GOMAXPROCS设置。但是不能超过256,超过256会设置为256。
- M 代表内核级别线程,一个M就是一个线程。默认最大限制为10000个。
调度逻辑
从图中可以看出,一共有两个物理线程M,每个M都绑定一个处理器P,每个P维护一个就绪状态的Goroutine队列,灰色的表示在等待P调度,蓝色的G代表正绑定P在M中执行。当程序中出现go func时,会将func挂载在灰色的等待队列中。当执行的Goroutine(G0)调度阻塞的系统调度时,P会切到另外的M'中,如果没有可用的M'就会创建一个,继续执行队列中的G。待系统调用返回时M0会重新绑定可用的P,如果没有可用的P就会把G0放到Global队列中,然后自己进入休眠。所有的P会周期性的检查Global队列,并且执行其中的G。如下图所示:
work-stealing算法
当P分配的G任务很快就执行完成时,P会先看Global runqueue还有G可以执行,如果没有就会到其他P的local runqueue中偷G。一般都会偷走一半,确保操作系统的每个M都能得到充分利用。例如下图中的第二个P没有G可以执行,所以把第一个P的Gm和G偷过来执行。
抢占式调度
按照上面的已经介绍过的理论,假如我将GOMAXPROC设为1,表示只有一个P,同时运行A,B两个Goroutine,其中A,B都是死循环,那岂不是有一个Goroutine永远都没有办法得到调度?
示例代码如下:
package main import (
"fmt"
"runtime"
) func main() {
runtime.GOMAXPROCS()
go func(){
for {
fmt.Print("A")
}
}()
for {
fmt.Print("B")
}
}
但是实验的结果大概是先输出A 10ms然后再输出B 10ms,如此交替。说明并没有Goroutine由于其他的Goroutine“贪婪”而“饥饿”。这需要归功于Golang runtime的后台监控线程sysmon,这是一个特殊的m,不需要绑定p可以执行。每隔10ms运行一次,将运行时间太久的G发出抢占式调度的请求。一旦G的抢占位设置为true,那么这个G下次调用函数或者方法时,runtime便可以将G抢占,并将其移出运行态。
channel阻塞或network IO情况下的调度
如果G被阻塞在某个channel或者网络IO操作上时,G会被放到某个wait队列中,而P会尝试调度下一个runnable的G。等channel 或者网络IO操作完成后,在wait队列中的G会被唤醒,标记为runnable重新排队执行。
总结
文章介绍了Golang自带的goroutine调度器G-P-M调度模型,G-P-M调度算法最大限度的发挥了并发性能,同时在一些异常情况下也能正常快速调度。
参考
http://morsmachine.dk/go-scheduler
https://tonybai.com/2017/06/23/an-intro-about-goroutine-scheduler/