写点什么

Go 语言,协程的深入剖析

作者:微客鸟窝
  • 2021 年 11 月 09 日
  • 本文字数:2303 字

    阅读完需:约 8 分钟

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 做不同的处理:


  1. 如果有空闲的 P,则获取一个继续执行 G0。

  2. 如果没有空闲的 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?


  1. P 的数量:


由启动时环境变量GOMAXPROCS 个 goroutine 在同时运行。


  1. M 的数量:


go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略。runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量一个 M 阻塞了,会创建新的 M。M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。


  1. P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

  2. M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

GOMAXPROCS 设置对性能的影响

一般,GOMAXPROCS 的大小设置为 CPU 的核数,使 Go 程序能充分利用 CPU。在 IO 密集型的应用里,这样设置可能性能并不是最好。理论上讲当某个 Goroutine 进入系统调用时,会有一个新的 M 被启用或创建,继续占满 CPU。但 Go 旧的 M 被阻塞和新的 M 得到运行之间是有一定间隔的(延迟),所以在 IO 密集型应用中可以把 GOMAXPROCS 设置大一些,效果或许会更好。

用户头像

微客鸟窝

关注

还未添加个人签名 2019.11.01 加入

公众号《微客鸟窝》笔者,目前从事web后端开发,涉及语言PHP、golang。获得美国《时代周刊》2006年度风云人物!

评论

发布
暂无评论
Go语言,协程的深入剖析