之前春招的时候写过一篇GMP文章,现在过了一年了重新梳理一下GMP过程。温故而知新,这次复习还是有新的收货的。
线程池
其他语言(java)没有协程的概念,但是也需要实现高并发的业务,一般会采用线程池的概念。就是会在线程池中维护一定数量的线程,新任务将不会创建新的线程,而是放进任务队列中。线程池中线程将不断从任务队列中取任务并执行
但是如果频繁的发生系统调用,线程就会被阻塞,线程池中的可以用线程就会越来越少,运行效率就会变低
golang goroutine调度器
线程数过多,协程上下文切换就会消耗很多的cpu资源。golang为了解决这种问题,设计了gorutine机制,goroutine可以在线程中进行调度,而且上下文切换开销很小
goroutine
goroutine占用的资源很小,每个goroutine栈空间大小默认是2kb,后续会根据需求动态扩容
因此一个go程序中可以创建成千上万个goroutine,而将这些goroutine按照一定算法放到'cpu'上执行,就被称为goroutine调度器
不过对于操作系统来说,cpu对协程是无感知的,只对线程负责,甚至可以说它都不知道有goroutine存在。那么,goroutine的调度就要依靠自己来完成。在操作系统层面,线程要竞争的资源是物理cpu。goroutine竞争的cpu资源是线程。
就是多个goroutine竞争多个线程,这就是GMP的M:N模型。goroutine挂靠在M上执行,而且换协程只需要切换G就可以了,这样切换的话就不会消耗大量的cpu资源,而仅仅只需要goroutine自己调度即可。
G-M 模型
G:goroutine抽象成结构
M:实体线程抽象成的结构
全局队列:所有的G都在这个全局队列上,go调度器会将G调度到M上执行
缺点:这个全局队列是加锁的,G的创建、调度也都需要上锁,频繁的锁操作就会导致调度延迟和性能损耗
GMP模型
后来设计师就改进了这个模型,增加了一个中间层,就是现在大家熟知的GMP
G:就是goroutine,储存了goroutine的堆栈信息和任务函数
M:就是实体线程抽象成的结构,G只有挂靠在M上才能真正的执行
P:就是process逻辑处理器,P维护了一个本地队列,里面存放了G,p的数量就决定了最大并行的数量。P同时也控制了G的调度
调度策略:
-
队列轮转机制
- p会周期性的调度本地队列中的G到M中执行,执行完毕后会把G放到队尾,然后调本地队列中下一个G执行。
- zP除了维护本地的G队列之外,还会周期性的检查全局队列是否有待运行的G执行。这些G的来源主要是从系统调用中恢复的G,之所以会周期性的检查,就是为了防止这个G饿死
-
工作量窃取机制
- 当P的本地队列中的G都执行完毕之后,会先去查询全局队列,如果全局队列中也没有待运行的G,就会从别的P偷取一部分G过来,一次偷一半
-
系统调用
- P的个数默认约等于CPU核数,可以通过setmaxprocess设置。当发生系统调用的时候,M就会释放P。某个空闲的M1会获取P,进而继续执行剩下P本地队列中剩下的G,这样能保证充分利用CPU核数
- 当系统调用结束之后,原本的M会和G解绑,此时如果有空闲的P,M就会获取一个P,然后继续执行G。如果没有空闲的P,这个G就会被放入全局队列中,等待被P捕获
调度机制
-
主动调度:业务程序主动调用 runtime.Gosched() 函数,会主动让出处理器资源,从g切换到g0
-
被动调度:
- 因为非系统调用而发生的阻塞,比如读写channel时发生阻塞、网络IO操作时。G会被放入某个等待队列上,这时候M会尝试运行P的下一个可运行的G。如果没有,M就会解绑P,然后进入挂起状态。当Io或者channel操作完成字后,G会从等待队列中被唤醒,并放入某个P的本地队列中,绑定一个M后继续执行
- 系统调用发生阻塞,hand of机制
-
抢占调度:main函数会启动一个sysmon监控线程,如果一个 G 任务运行 10ms,sysmon 就会认为它的运行时间太久而发出抢占式调度的请求,一旦 G 的抢占标志位被设为 true,就会通过retake 函数对goroutine发起抢占。