Go高阶指南13,协程的深入剖析

411 阅读5分钟

本文已参与掘金创作者训练营第三期「高产更文」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

线程池的缺陷

在高并发中,如果去频繁的创建线程会产生不必要的开销,所以有了线程池,它可以预先保存一定数量的线程,新的任务不必再去创建线程,而是将任务发布到任务队列,线程池中的线程不断的从任务队列中取出任务并执行,这样可以有效的减少线程创建和销毁所带来的开销。

如上图,我们把任务队列中的每个任务称为 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 做不同的处理:

  1. 如果有空闲的 P,则获取一个继续执行 G0。
  2. 如果没有空闲的 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 设置大一些,效果或许会更好。