Go 语言,协程的深入剖析
线程池的缺陷
在高并发中,如果去频繁的创建线程会产生不必要的开销,所以有了线程池,它可以预先保存一定数量的线程,新的任务不必再去创建线程,而是将任务发布到任务队列,线程池中的线程不断的从任务队列中取出任务并执行,这样可以有效的减少线程创建和销毁所带来的开销。
如上图,我们把任务队列中的每个任务称为 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 执行。
其关系如下图所示:
如上图:
有 2 个物理线程 M,每一个 M 都拥有一个处理器 P,每一个也都有一个正在运行的 goroutine。
P 的数量可以通过 GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个 goroutine 可以同时运行。
图中灰色的那些 goroutine 并没有运行,而是出于 ready 的就绪态,正在等待被调度。P 维护着这个队列(称之为 runqueue),
Go 语言里,启动一个 goroutine 很容易:go function 就行,所以每有一个 go 语句被执行,runqueue 队列就在其末尾加入一个
goroutine,在下一个调度点,就从 runqueue 中取出(如何决定取哪个 goroutine?)一个 goroutine 执行。
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 很快就执行完了(分配不均),这就导致了这个处理器 P 很忙,但是其他的 P 还有任务,此时如果 global runqueue 没有任务 G 了,那么 P 不得不从其他的 P 里拿一些 G 来执行。一般来说,如果 P 从其他的 P 那里要拿任务的话,一般就拿 run queue 的一半,这就确保了每个 OS 线程都能充分的使用,如上图。
GPM 创建相关
M 和 P 的数量如何确定?或者说何时会创建 M 和 P?
P 的数量:
由启动时环境变量GOMAXPROCS 个 goroutine 在同时运行。
M 的数量:
go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略。runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量一个 M 阻塞了,会创建新的 M。M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。
P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
GOMAXPROCS 设置对性能的影响
一般,GOMAXPROCS 的大小设置为 CPU 的核数,使 Go 程序能充分利用 CPU。在 IO 密集型的应用里,这样设置可能性能并不是最好。理论上讲当某个 Goroutine 进入系统调用时,会有一个新的 M 被启用或创建,继续占满 CPU。但 Go 旧的 M 被阻塞和新的 M 得到运行之间是有一定间隔的(延迟),所以在 IO 密集型应用中可以把 GOMAXPROCS 设置大一些,效果或许会更好。
评论