golang语言自出生就天然支持goroutine协程的概念。本文主要分析一下go语言协程实现模型,其协程模型的实现过程也是协程实现的典型演进过程。
多线程模型实现:1.0版本
1.0的版本的实现比较简单,也比较的原始:有一个全局的队列来保存所有ready的goroutine。多个线程分别从全局队列中获取任务并执行。
这个模型的优点是:1)实现简单 2)如果一个goroutine阻塞,不会影响其它在全局队列中待运行的goroutine,其它线程会获取后续任务并执行。 缺点比较明显:多个线程访问同一个全局的队列,锁的竞争比较严重。
GMP调度模型
从1.1版本开始,引入了经典的GMP调度模型。
G:goroutine 协程的概念,M:machine 机器线程的概念,P:processor协程的处理器或者更准确的说是调度器。
和多线程模型相比,GMP模型增加了如下的概念:除了任然保留一个全局队列外,为每个内核线程(也就是M)保留一个本地的队列,用来保存M对应的本地的G任务。这样做的目的就是解决多线程模型中锁竞争的问题,每个M都从本地队列去取要执行的任务G。正常情况下,M和P一一对应,P对应多个准备执行的G
任然有一个问题需要解决,就是协程的阻塞问题。go的协程目前是协作式而不是抢占式的,所以如果go的协程阻塞了,不会主动放弃当前的执行权,导致P本地队列中其它的协程无法执行。
阻塞处理
当一个P上的G在M上阻塞时。GMP的模型处理如下:创建一个新的M1(在机器上就对应创建一个新的线程),把P重新绑定到M1上,P上的后续的G任务就由M1执行。当阻塞的G完成后,会被加入到全局队列中,由后续的空闲的M继续执行。
所以M的数量一般大于P的数量。P的数量一般默认设置为等于机器上CPU核心数。M对应的就是内核线程,内核线程可以创建多个,数量可以大于P(go语言默认1000个)构成一个线程池(M池),避免频繁创建。当有M阻塞时,会有空闲的M接替继续执行P上的G任务。
任务窃取
任务窃取主要解决负载均衡的问题。当一个M对应的P上没有G任务可执行。从全局队列里也没有可以执行的G,当其它M上有大量可执行的任务时,就可以从其它P上窃取任务执行。
抢占式调度
goroutine设计之初为协作式调度,用户负责在各个goroutine之间协作式执行任务。也就是希望自己会主动让出执行权。用户在加锁,读写通道时会主动让出执行权。
垃圾回收器是需要stop the world的。如果垃圾回收器想要运行了,那么它必须先通知其它的goroutine合作停下来,这会造成较长时间的等待时间。考虑一种很极端的情况,所有的goroutine都停下来了,只有其中一个没有停,那么垃圾回收就会一直等待着没有停的那一个。
抢占式调度可以解决这种问题,在抢占式情况下,如果一个goroutine运行时间过长,它就会被剥夺运行权。
基于协作的强占式调度 1.2~1.13版
基本原理:编译器在函数调用时插入抢占指令。
go编译器原有的机制:在每个函数调用的stack上插入stackguard标记。该标志的初衷是用于:go语言会在每个函数的入口通过比较stackguard的值来决定是否触发morestack函数,该函数用于为当前的stack提供更多的stack空间。而这一机制被很tricky的用来实现执行权的转移。
每个goroutine需要能够运行,所以它们都有自己的栈。假如每个goroutine分配固定栈大小并且不能增长,太小则会导致溢出,太大又会浪费空间,无法存在许多的goroutine。
为了解决这个问题,goroutine可以初始时只给栈分配很小的空间,然后随着使用过程中的需要自动地增长。这就是为什么Go可以开千千万万个goroutine而不会耗尽内存。
每次执行函数调用时Go的runtime都会进行检测,若当前栈的大小不够用,则会触发“中断”,从当前函数进入到Go的运行时库,Go的运行时库会保存此时的函数上下文环境,然后分配一个新的足够大的栈空间,将旧栈的内容拷贝到新栈中,并做一些设置,使得当函数恢复运行时,函数会在新分配的栈中继续执行,仿佛整个过程都没发生过一样,这个函数会觉得自己使用的是一块大小“无限”的栈空间
morestack的修改
当sysmon监控该G执行时间过长时,就修改M上的G的stack标记值为StackPreempt。当函数在执行阶段时检查该标记值为StackPreempt,相当于调用了runtime.Gosched操作,主动放弃执行权。
这种方式实现的抢占式调度比较初级。有一个比较严重的问题:如果一个goroutine运行了很久,而没有函数切换,则不会有被抢占的机会。
基于信号的抢占式调度 1.4版本~至今
在golang语言1.4版本后实现了基于信号的真正的抢占式调度。
抢占流程大致如下:
- M启动前,首先注册绑定SIGURG信号及其信号处理函数handler
- sysmon间隔性的检测运行超时的P,然后发信号给P对应的M
- M收到信号后休眠当前goroutine并重新进行调度
goroutine通信
协程之间通信基本有两种方式:
- 共享内存
- CSP(communicating sequential processes)并发模型: 也就是go语言 channel的方式来通信。
DO NOT COMMUNICATE BY SHARING MEMORY; INSTEAD, SHARE MEMORY BY COMMUNICATING.“不要以共享内存的方式来通信,相反,要通过通信来共享内存”