【Go语言踩坑系列(八)】Goroutine(下)

声明

本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引发的一些思考。html

引入

还记得咱们在上一篇文章中提到的例子吗:算法

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}

如今咱们分析一下这段代码,循环十次,每次使用go语句建立一个协程,并在每一个协程中打印i值,注意这个i值是这条打印语句真正获得执行的时候,从外部for语句代码块中取的的当前的i值。那么为何在上一篇文章中,咱们说每次打印的i值是不肯定的呢?答案就在于Go协程的调度机制的不肯定性。下面咱们从Go协程演化的角度,来逐步揭开协程调度机制的面纱。编程

起源

单进程

咱们在上一篇文章中已经了解到,在单进程的计算机时代,计算机只能一个任务一个任务处理,并且若是有I/O阻塞,CPU就会一直等待这个进程直到阻塞返回,后面的任务彻底得不到机会执行。这里根本不须要调度器。segmentfault

多进程/多线程

为了解决这个问题,咱们有了多进程/多线程,一旦某个进程或线程阻塞了,CPU能够在多个进程或线程之间使用时间片轮转调度算法来回切换执行的进程/线程,让CPU再也不去等待阻塞返回,这样极大的提升了CPU的利用率。这个时候,就须要调度器来作这个工做了,何时、哪一个进程任务容许CPU去执行。刚才时间片轮转调度算法就是一个例子。这样咱们就实现了在一个CPU上面"同时"运行多个任务。这个同时只是咱们看起来是同时,CPU在同一时间只能运行一个任务,只是多个任务之间切换的速度较快,咱们看起来好像是同时在运行的,这个就叫作并发。而并行则是完彻底全在同一时刻,可以执行多个任务。在多核CPU的时代,咱们就能够作到并行。
可是,多进程/多线程仍然是操做系统内核级别的东西,内核仍然须要全权负责他们整个生命周期。其每次建立、销毁、切换的开销都是很是大的,并且内核的调度算法可能并不符合咱们的需求,灵活性较差,那么怎么解决内核线程的问题呢?数据结构

用户态须要作更多的事情

而用户态线程则解决了这个问题,它与内核态线程有一个对应关系,能够是1:1 、N:1或者 M:N。用户态线程全部的建立、切换等操做都在用户态完成,开销更小也更灵活。内核再也不须要作那么多的切换或者调度工做。Goroutine(协程)就是一种用户态线程的实现。多线程

Go协程的演化

咱们想了一下,设计一个协程无非须要考虑这三个因素:资源、任务、调度器。
资源就是操做系统的内核态线程,而任务就是咱们用go语句启动的一堆Goroutine,而调度器就是如何将资源分配给这些任务,在有限的操做系统资源中,最大化利用CPU与多线程的能力,且让每个任务公平且快速的获得执行。那么,Go语言中这三要素是如何演化的呢?架构

先本身实现一个

咱们先想一个最简单的方案,先说如何存听任务。说到公平,那么咱们首先想到的数据结构就是队列,先来的任务先执行就好,那么咱们用队列去存这一大堆的任务。那么资源呢就直接让内核中多个线程去消费这个队列,拿到一个任务执行就好。咱们把任务简单叫作G(Goroutine):

咱们来分析一下这里面的问题。首先,多个内核态线程共享一个任务队列,会存在并发问题。若是多个线程同一时刻拿到同一个任务G,那么会致使两个内核态线程全都在处理同一个任务G,会致使重复的任务处理。这显然须要加锁,才能解决这个问题。并且,这个时候仍然是操做系统内核直接调度整个任务队列,咱们在用户态并无帮助内核作太多调度的事情。并发

G-M模型

因此,咱们让多个任务队列对应多个内核线程,这样就能够不用加锁了,提升了内核线程的处理效率:

可是这个版本仍然是有问题的。咱们仅仅是在用户态实现了一个任务队列而已。而内核态仍然须要负责从任务队列里拿出任务、判断任务当前的状态是否能够运行、而后才真正运行这个任务,内核线程的负担太重。
计算机科学中有一个经典的理论:计算机上的全部问题均可以经过增长一个抽象层来解决。因此,咱们给他加一个帮手,把任务直接喂到线程的嘴里,内核线程只管运行就行了,至于怎么调度的,何时会运行哪一个任务,内核态线程不用再关心了。这样,内核的任务逐渐减小,一个真正的完整用户态线程的调度机制浮出水面,咱们把这个帮手叫作M。M是Machine的缩写,每个M就表明一个内核态线程,就是以前咱们说的可用的线程资源(Machine):

事实上,在Go1.1版本以前,Go语言就是采用的G-M模型来进行协程调度。可是这种调度模型仍然有一个问题。试想一下,若是咱们M与这个队列一对一绑定死,那么若是M中的全部G都运行完了,咱们就须要从另外一个M结构中拿出一些未执行的任务G,而后放到本身的结构中,继续执行。这样作实际上是很是麻烦且不灵活的。学习

G-M-P模型

若是有一个结构,能让咱们动态的去绑定M与任务队列就行了,M只关心和他绑定的这个结构,能让我执行任务便可,并不关心这个任务我要如何存储,更不用关心要不要从另外一个M的队列里拿一些任务放到本身这边。因此,一个M与任务队列的中介出现了,那就是P:

P是Go1.1版本新加入的一个数据结构。这个中间层让咱们能够更加灵活的、随时切换任务队列运行所须要的线程资源M,真正实现了M与任务的动态1:N的绑定方式。
回到咱们最开始的问题,打印字符串是一个耗时的I/O操做,须要使用系统调用,将字符写到标准输出中。那么假设执行这个任务的G执行系统调用的时间较长,一直未能等到系统调用完成返回,那么当前的M就会一直阻塞在这个任务G上,不能执行其余的任务。为了解决这个问题,P解除和原有M的绑定,带着剩余的任务G小弟们去寻找另外一个下家M,否则G要一直等待阻塞结束,那就要饿死了。
因此,经过P,咱们能够灵活的将任务队列迁移到任意一个可用的线程资源M上,让剩下的任务可以继续获得执行,再也不让线程资源傻傻的等待。注意每一个任务G须要保存当前执行的上下文,以便阻塞的任务完成的时候,可以让M继续任务执行后续的逻辑。因此,有了P这个中间层,一个M就能够动态绑定多个任务队列了,而再也不将任务队列写死放到M的数据结构内部,解除了M与任务G的直接耦合。
正由于Go协程有这种调度机制,因此咱们开篇那个例子,循环并不会等待打印操做执行完再建立下一个协程,而是直接进行下一个循环,马上建立新协程,一共建立了10个协程。而这10个协程的调度时机又是不肯定的,因此打印的因此咱们也没有办法确认最终的打印顺序。

相比前文的G-M调度模型。若是上文的M管辖的队列已经没有任务了,M还须要本身去找其余队列,并把任务加到本身的数据结构中。而有了P以后,那M直接从其余有G的P那里偷取一半G过来,放到本身的P本地队列便可。看到区别了吗,经过加入P这个中间层,真正实现了任务与M的动态绑定,与G-M模型相比更加灵活。这个机制叫作work stealing。

假设咱们又想添加一个任务G,可是全部P的队列都满了,怎么办呢?在这个模型中还有一个全局共享的任务队列,由于其仍有咱们初版实现中须要加锁的缺点,因此任务实在放不下的时候才会使用全局队列。因此全局队列在调度器中的地位也是很是低的。只有本地队列没法找到任务来运行的时候,才会到全局队列中拿到任务来运行。spa

总结

咱们用一张图来总结一下总体的数据结构:

接下来咱们总结一下咱们从中学到的几个思想:

  • 复用思想:当M绑定的P无可运行的G时,尝试从其余线程绑定的P偷取G,而不是销毁线程。这个机制被叫作work stealing。
  • 非阻塞思想:当本线程由于G进行系统调用阻塞时,M释放绑定的P,P会转移给其余空闲的线程执行,最大化压榨CPU,提升了CPU的利用率。这个机制被叫作hand off。
  • 中间层思想:当一个实体承载的任务过多,能够加一个中间层以减轻负担,同时可以解除双方的耦合,更加灵活。
  • 架构的边界划分:M的加入让内核只须要执行任务便可,P让M中再也不与任务G耦合,让M更专一线程资源自己的管理,而非任务队列的管理。

参考资料

Golang调度器GMP原理与调度全分析
Go并发编程-Goroutine如何调度的?

关注咱们

欢迎对本系列文章感兴趣的读者订阅咱们的公众号,关注博主下次不迷路~

Nosay

相关文章
相关标签/搜索