线程池的缺陷

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

为了方便下面的叙述,把任务队列中的每个任务称作G,而G往往代表一个函数。线程池中的线程worker线程不断的从任务队列中取出任务并执行。而worker线程的调度则交给操作系统进行调度。

worker线程执行的G任务中发生系统调用,则操作系统会将线程置为阻塞状态,也意味着该线程在怠工,同样意味着消费任务队列的worker线程变少了,也就是说线程池消费队列的能力变弱了

如果队列中的大部分任务都会进行系统调用,则会让这种状态恶化,大部分worker线程进入阻塞状态,从而任务队列中的任务产生堆积。

虽然一定程度上可以增加线程池中线程的数量来解决任务堆积的问题,但是随着线程增多,由于过多线程增强CPU,消费能力会有上限,甚至出现消费能力下降,如下图所示:

Goroutine调度器

GO提供一种机制,可以在线程中自己实现调度,上下文切换更轻量,从而达到了线程数少,而并发数并不少的效果,而线程中调度的就是Goroutine。

调度器的工作就是将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的队列,可以按照一定的策略将G调度进M中执行。

P的个数在程序启动时决定,默认情况下等同于CPU的核数,由于M必须要持有一个P才可以执行Go代码,所以同时运行的M个数,也即线程数一般等同于CPU的个数,以达到尽可能使用CPU而又不至于产生过多的线程切换开销。

程序可以使用runtime.GOMAXPROCS()设置P的个数,在某些IO密集型的场景下可以在一定程度上提高性能。

Goroutine调度策略

队列轮转

每个P维护着一个包含G的队列,不考虑进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度

除此每个P维护的队列外,还有一个全局的队列,每个P会周期性的查看全局队列中是否有G待运行并将其调度到M中执行。全局队列中G的来源,主要有从系统调用中恢复的G,P周期性地查看全局队列,也是为了防止全局队列中地G被饿死

系统调用

前面讲过P的个数默认等于CPU核数,每个M必须持有一个P才可以执行,一般情况下M的个数会略大于P的个数,多出来的M会在G产生系统调用的时候发挥作用。类似于线程池,Go也提供一个M池子,需要时从池子里面取,用完放回池子,不够再创建一个。

在上图,当G0即将进入系统调用的时候,M0将释放P,进而某个空闲的M1获得P,继续执行P队列中剩余的G。而M0由于陷入系统调用而被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU

M1的来源可能是缓存池,也可能是新建的。当G0系统调用结束后,根据M0是否能够获取到P,将会将G0做不同的处理:

  1. 如果有空闲的P,则获取一个P,继续执行G0
  2. 如果没有空闲的P,将G0放入全局队列,等待被其他的P调度,然后M0进入缓存池睡眠。

工作量窃取

多个P中维护的G队列有可能是不均衡的,比如下图: 右侧的P已经将G全部执行完成,然后查询全局队列,全局队列中也没有G,而另一个M中处理正在运行的G外,队列中还有三个。那么空闲的P会将其他P的G偷取一部分过来,一般每次偷取一半

GOMAXPROCS设置对性能的影响

一般来说,程序运行的时候就将GOMAXPROCS大小设置为CPU核数,可让Go程序充分利用CPU。在某些IO密集型的应用里,这个值并不意味着性能最好。理论上当某个Goroutine进入系统调用的时候,会有一个新的M被启用或创建,继续占满CPU。但由于Go调度器检测到M被阻塞是有一定延迟的,也即旧的M被阻塞和新的M得到运行之间是有一定间隔的,所以IO密集型应用中可以把GOMAXPROCS设置的大一些,或许效果更好