go 调度机制简介

goroutine是go中最重要的功能之一,正是因为有了goroutine这样强大的工具,go在并发方面表现的特别优秀。

那么goroutine和普通的线程和协程有什么区别呢?首先,我们需要明白线程和协程的区别,线程是内核态的,而协程是用户态的。什么意思呢?就是说线程之间的切换主要由内核去调度,而协程之间的切换则需要用户去操作。线程切换需要保存上下文信息,切换到另一个线程,过段时间,恢复到之前的线程继续执行。cpu时间片的让渡,上下文的保存等等复杂操作都是由内核实现的,程序员不需要关注其中的细节。对程序员更加友好。但是为了支持这些操作,线程需要使用大量的资源。所以一个进程之间只能支持少量的线程,一般几个,十几个就会将资源耗尽。而协程则不同,它将协程之间的调度交给程序员去处理,优秀的程序员可以通过各种操作降低协程之间上下文切换资源占用,处理切换时机等等。对程序员的水平要求更高,由于调度由用户控制,那么使用的资源相对来说会更少,所以一个进程可以启动的协程数量比线程更多。

goroutine结合了线程和协程的优点。主要表现在,从资源占用上看,goroutine更像是协程,占用的资源都很少,支持一个进程开千个万个goroutine。而从切换角度来看,goroutine更像是线程,不需要用户实现goroutine之间的调度。goroutine之间的调度不由内核来操作,也不由用户操作。由go自己来操作,go中自己实现了调度器。

简单的说,goroutine既拥有协程的优点,对系统资源的占用低,又拥有线程的优点,用户不需要实现协程的调度。只需要学习如何使用go自身的调度器即可。

goroutine中很重要的一个概念是

不要通过共享内存来通信,要通过通信来共享内存

通过通信来共享内存,降低了对内存的占用,go中使用channel通道来实现通信。这里不详细解释。

我们主要介绍下go 的调度机制,学会了go的调度机制,可以帮助我们学习和理解如何正确使用goroutine,定位和解决goroutine中出现的问题。

整个调度模型由 Goroutine/Processor/Machine 以及全局调度信息 sched 组成。

上述是个人的理解,有不正确的地方欢迎指出。

调度机制已经有很多人写了,写的也很简单明了,就直接转载过来。后面有时间会去阅读源码,如果下面的某些阐述与源码有冲突,后续会改正。

GO并发模型的实现原理

我们先从线程讲起,无论语言层面何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间;内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源,用户空间呢就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。

我们现在的计算机语言,可以狭义的认为是一种“软件”,它们中所谓的“线程”,往往是用户态的线程,和操作系统本身内核态的线程(简称KSE),还是有区别的。

线程模型的实现,可以分为以下几种方式:

用户级线程模型

 

如图所示,多个用户态的线程对应着一个内核线程,程序线程的创建、终止、切换或者同步等线程工作必须自身来完成。它可以做快速的上下文切换。缺点是不能有效利用多核CPU。

内核级线程模型

 

这种模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作,都由内核来完成。一个用户态的线程对应一个系统线程,它可以利用多核机制,但上下文切换需要消耗额外的资源。C++就是这种。

两级线程模型

 

这种模型是介于用户级线程模型和内核级线程模型之间的一种线程模型。这种模型的实现非常复杂,和内核级线程模型类似,一个进程中可以对应多个内核级线程,但是进程中的线程不和内核线程一一对应;这种线程模型会先创建多个内核级线程,然后用自身的用户级线程去对应创建的多个内核级线程,自身的用户级线程需要本身程序去调度,内核级的线程交给操作系统内核去调度。

M个用户线程对应N个系统线程,缺点增加了调度器的实现难度。

Go语言的线程模型就是一种特殊的两级线程模型(GPM调度模型)。

以上转载自https://www.jianshu.com/p/4afa0679851d

调度器

主要基于三个基本对象上,G,M,P(定义在源码的src/runtime/runtime.h文件中)

1.     G代表一个goroutine对象,每次go调用的时候,都会创建一个G对象

2.     M代表一个线程,每次创建一个M的时候,都会有一个底层线程创建;所有的G任务,最终还是在M上执行

3.     P代表一个处理器,每一个运行的M都必须绑定一个P,就像线程必须在么一个CPU核上执行一样

P的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改; M的个数和P的个数不一定一样多(会有休眠的M或者不需要太多的M)(最大10000);每一个P保存着本地G任务队列,也有一个全局G任务队列;

全局G任务队列会和各个本地G任务队列按照一定的策略互相交换(满了,则把本地队列的一半送给全局队列)

P是用一个全局数组(255)来保存的,并且维护着一个全局的P空闲链表

每次go调用的时候,都会:

1.     创建一个G对象,加入到本地队列或者全局队列

2.     如果还有空闲的P,则创建一个M

3.     M会启动一个底层线程,循环执行能找到的G任务

4.     G任务的执行顺序是,先从本地队列找,本地没有则从全局队列找(一次性转移(全局G个数/P个数)个,再去其它P中找(一次性转移一半),

5.     以上的G任务执行是按照队列顺序(也就是go调用的顺序)执行的。

对于上面的第2-3步,创建一个M,其过程:

1.     先找到一个空闲的P,如果没有则直接返回,(这个地方就保证了进程不会占用超过自己设定的cpu个数)

2.     调用系统api创建线程,不同的操作系统,调用不一样,其实就是和c语言创建过程是一致的,(windows用的是CreateThread,linux用的是clone系统调用)

3.     然后创建的这个线程里面才是真正做事的,循环执行G任务

那就会有个问题,如果一个系统调用或者G任务执行太长,他就会一直占用这个线程,由于本地队列的G任务是顺序执行的,其它G任务就会阻塞了,怎样中止长任务的呢?

这样滴,启动的时候,会专门创建一个线程sysmon,用来监控和管理,在内部是一个循环:

1.     记录所有P的G任务计数schedtick,(schedtick会在每执行一个G任务后递增)

2.     如果检查到 schedtick一直没有递增,说明这个P一直在执行同一个G任务,如果超过一定的时间(10ms),就在这个G任务的栈信息里面加一个标记

3.     然后这个G任务在执行的时候,如果遇到非内联函数调用,就会检查一次这个标记,然后中断自己,把自己加到队列末尾,执行下一个G

4.     如果没有遇到非内联函数(有时候正常的小函数会被优化成内联函数)调用的话,那就惨了,会一直执行这个G任务,直到它自己结束;如果是个死循环,并且GOMAXPROCS=1的话,程序会挂死。

对于一个G任务,中断后的恢复过程:

1.     中断的时候将寄存器里的栈信息,保存到自己的G对象里面

2.     当再次轮到自己执行时,将自己保存的栈信息复制到寄存器里面,这样就接着上次之后运行了。
————————————————
版权声明:本文为CSDN博主「正版两只羊」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/liangzhiyang/article/details/52669851

总结,执行go程序时,从main开始,main goroutine创建一个新的M,绑定一个P。如果main函数里有其他的goroutine,查询是否有空闲的P,如果有,创建一个新的M,加入新P的本地队列中,绑定其他空闲的P。如果没有空闲的P,那么将G加入全局队列中。每次执行时,循环执行本地队列任务(本地队列和全局队列会交换任务),然后执行全局任务,都没有的话,去其他P里面偷,偷一半。

以银行办理业务为例,客户代表G,窗口代表P,M代表排队通道。sched代表一个全局的排队通道。来了一个客户,将安排到一个空闲的排队通道M里,这个排队通道M绑定一个窗口P(每个排队通道都要绑定一个窗口P,一个窗口P可以有多个排队通道M,N:M)。如果来新客户,看下有没有空闲的窗口P,有的话,开一个新的排队通道M,将新客户安排到这个排队通道里。如果没有空闲的窗口P,此时,每个排队通道M都有客户G,如果排队通道M没有满,那么G安排到一个有空闲位置的M。如果所有的M都满了,那么安排客户G到全局排队通道sched。执行过程中,如果某个客户的任务执行太久,(使用任务计数schedtick判断),将客户安排到排队通道M末尾。(重新排队)。如果某个窗口P对应的排队通道M都空了(一个窗口P可能有多个排队通道M),那么看下全局排队通道有没有客户G,有就过来(M的本地任务和sched全局任务有定期的交换策略),没有的话,去其他窗口P里面偷一半客户G过来执行。

不知道上面的例子描述的恰当不恰当,如果有错误欢迎指出。

posted @ 2019-09-22 10:41  菲菲菲菲菲常新的新手  阅读(...)  评论(... 编辑 收藏