线程池的缺陷
在高并发中,如果去频繁的创建线程会产生不必要的开销,所以有了线程池,它可以预先保存一定数量的线程,新的任务不必再去创建线程,而是将任务发布到任务队列,线程池中的线程不断的从任务队列中取出任务并执行,这样可以有效的减少线程创建和销毁所带来的开销。
如上图,我们把任务队列中的每个任务称为 G ,G 往往代表一个函数。线程池中的 worker 线程不断的从任务队列中取出任务执行,worker 线程的调度是由操作系统来进行调度的。
若 worker 线程执行的 G 任务中发生系统调用,则操作系统会将该线程置为阻塞状态,意味着该线程在怠工、消费任务队列的 worker 线程变少了,也就是说线程池消费任务队列的能力变弱了。
如果任务队列中的大部分任务都进行系统调用,大部分 worker 线程进入阻塞状态,导致任务队列中的任务产生堆积。
解决这个问题的一个思路就是重新审视线程池中线程的数量,增加线程池中线程数量可以一定程度上提高消费能力,但随着线程数量增多,过多线程会争抢 CPU,消费能力会有上限,甚至出现消费能力下降。 如下图所示:
Goroutine 调度器
线程数过多,那么操作系统会频繁的切换线程,频繁的上下文切换就成了性能瓶颈。Go 可以在线程中自己实现调度,上下文切换可以更轻量,达到了线程数少,而并发数并不少的效果。而线程中调度的就是 Goroutine.
Goroutine主要概念如下:
G(Goroutine): 即 Go 协程,每个 go 关键字都会创建一个协程。M(Machine): 工作线程,在 Go 中称为 Machine。P(Processor): 处理器(Go中定义的一个概念,不是指CPU),包含运行 Go 代码的必要资源,也有调度 goroutine 的能力。
- M 必须拥有 P 才可以执行 G 中的代码
- P 含有一个包含多个 G 的队列,P 可以调度 G 交由 M 执行。
其关系如下图所示:
图中 M 是交给操作系统调度的线程,M 持有一个 P,P 将 G 调度进 M 中执行。P 同时还维护着一个包含 G 的队列(图中灰色部分)。
P 的个数是在程序启动时决定的,默认等同于 CPU 的核数,由于 M 必须持有一个 P 才可以运行 Go 代码,所以同时运行的 M 个数(线程数)一般等同于 CPU 的个数,以达到尽可能的使用 CPU 而又不至于产生过多的线程切换开销。
程序中可以使用 runtime.GOMAXPROCS() 设置 P 的个数,在某些 IO 密集型的场景下可以在一定程度上提高性能。
Goroutine调度策略
队列轮转
上图中可见每个 P 维护着一个包含 G 的队列,不考虑 G 进入系统调用或 IO 操作的情况下,P 周期性的将 G 调度到 M 中执行,执行一小段时间,将上下文保存下来,然后将 G 放到队列尾部,然后从队列中重新取出一个G进行调度。
除了每个 P 维护的 G 队列以外,还有一个全局的队列。每个 P 会周期性的查看全局队列中是否有 G 待运行并将其调度到 M 中执行,全局队列中 G 的来源,主要有从系统调用中恢复的 G。之所以 P 会周期性的查看全局队列,也是为了防止全局队列中的 G 被“饿死”。
系统调用
前面说到 P 的个数默认是 CPU 核数,每个 M 必须持有一个 P 才可以执行 G,一般 M 的个数会略大于 P 的个数,多出来的 M 会在 G 产生系统调用时发挥作用。类似线程池,Go 也提供一个 M 的池子,需要时从池子中获取,用完放回池子,不够时就再创建一个。
当 M 运行的某个 G 产生系统调用时,如下图所示:
如图,当 G0 即将进入系统调用时,M0 将释放 P,进而某个空闲的 M1 获取 P,继续执行 P 队列中剩下的 G。而 M0 由于陷入系统调用而被阻塞,M1 接替 M0 的工作,只要 P 不空闲,就可以保证充分利用 CPU。
M1 可能是来自 M 的缓存池,也可能是新建的。当 G0 系统调用结束后,根据 M0 是否能获取到 P,会将 G0 做不同的处理:
- 如果有空闲的 P,则获取一个继续执行 G0。
- 如果没有空闲的 P,则将 G0 放入全局队列,等待被其他的 P 调度。之后 M0 进入缓存池睡眠。
工作量窃取
多个 P 中维护的 G 队列有可能是不均衡的,比如下图:
在竖线左侧中右边的 P 已经将 G 全部执行完,再去查全局队列,全局队列中也没有 G,而另一个 M 中除了正在运行的 G 外,队列中还有3个 G 待运行。此时,空闲的 P 会将其他 P 中的 G 偷取一部分过来,一般是每次偷取一半。
GOMAXPROCS 设置对性能的影响
一般,GOMAXPROCS 的大小设置为 CPU 的核数,使 Go 程序能充分利用 CPU。在 IO 密集型的应用里,这样设置可能性能并不是最好。理论上讲当某个 Goroutine 进入系统调用时,会有一个新的 M 被启用或创建,继续占满 CPU。但 Go 旧的 M 被阻塞和新的 M 得到运行之间是有一定间隔的(延迟),所以在 IO 密集型应用中可以把 GOMAXPROCS 设置大一些,效果或许会更好。