声明
本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。
引入
还记得我们在上一篇文章中提到的例子吗:
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}
现在我们分析一下这段代码,循环十次,每次使用go语句创建一个协程,并在每个协程中打印i值,注意这个i值是这条打印语句真正得到执行的时候,从外部for语句代码块中取的的当前的i值。那么为什么在上一篇文章中,我们说每次打印的i值是不确定的呢?答案就在于Go协程的调度机制的不确定性。下面我们从Go协程演化的角度,来逐步揭开协程调度机制的面纱。
起源
单进程
我们在上一篇文章中已经了解到,在单进程的计算机时代,计算机只能一个任务一个任务处理,而且如果有I/O阻塞,CPU就会一直等待这个进程直到阻塞返回,后面的任务完全得不到机会执行。这里根本不需要调度器。
多进程/多线程
为了解决这个问题,我们有了多进程/多线程,一旦某个进程或线程阻塞了,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的队列都满了,怎么办呢?在这个模型中还有一个全局共享的任务队列,因为其仍有我们第一版实现中需要加锁的缺点,所以任务实在放不下的时候才会使用全局队列。所以全局队列在调度器中的地位也是非常低的。只有本地队列无法找到任务来运行的时候,才会到全局队列中拿到任务来运行。
总结
我们用一张图来总结一下整体的数据结构:
接下来我们总结一下我们从中学到的几个思想:
- 复用思想:当M绑定的P无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。这个机制被叫做work stealing。
- 非阻塞思想:当本线程因为G进行系统调用阻塞时,M释放绑定的P,P会转移给其他空闲的线程执行,最大化压榨CPU,提高了CPU的利用率。这个机制被叫做hand off。
- 中间层思想:当一个实体承载的任务过多,可以加一个中间层以减轻负担,同时能够解除双方的耦合,更加灵活。
- 架构的边界划分:M的加入让内核只需要执行任务即可,P让M中不再与任务G耦合,让M更专注线程资源本身的管理,而非任务队列的管理。
参考资料
Golang调度器GMP原理与调度全分析
Go并发编程-Goroutine如何调度的?
关注我们
欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。