Go的并发模型是基于 C.A.R Hoare 1978 年的一篇论文——”Communicating Sequential Processes”(以下简称CSP)。在多核处理器开始普及的时候,Hoare就发现了多核处理器上的单个处理器通信的一致性问题。比如计算的阶乘问题,缓存的边界,生产者-消费者问题,哲学家进餐问题,矩阵乘法问题。当时的通信模型包括了很多沿用至今的模型,比如线程通信模型,即在访问共享内存时通过锁来保护临界区。这些模型非常难用,因此经常出现很多bug。Hoare于是在CSP中提出了一系列的解决方案,核心思想就是通过通信来共享内存,而不是通过共享内存来通信。
Go 里使用的很多机制都来自于Hoare的这篇论文,可以认为Go是Hoare的理论在工程上的实现。比如使用协程,协程之间的通信通过channel ,channel是一个同步的消息队列,甚至 select 关键字都来自于Hoare的思想。和线程相比更加轻量化,Go协程的执行更加独立,同时也能保证协程之间的一致性。。
在讲 Go 的调度机制之前,必须先搞清楚Go的 Runtime。Go 的 Runtime 是整个Go语言的核心,负责协程的调度,垃圾回收,以及协程运行环境维护等等。
Go代码由Go提供的专门的编译器编译。而 runtime 其实就是一个静态链接库,Go编译器会在链接阶段将runtime的部分与go代码进行静态链接。Runtime 相当于用户代码和系统内核的一个中间层。用户层使用channel,goroutine等等,都是调用的Runtime提供的接口,对于Go的业务代码来说,是接触不到真正的内核调用的。这样,Runtime 就全面接管了用户与内核的交互,才能实现垃圾回收,协程调度,协程之间的通信等等。
其实很多语言都会实现类似这种中间件的机制,比如 Java 的垃圾回收的机制肯定是建立在内存分配由中间件接管的基础上,底层的malloc(), free() 等系统调用对用户层屏蔽了。
以下是Go用户层,runtime层,内核之间的关系。
Runtime 会保存用户层创建的每个协程的信息,并基于每个处理器都创建一个线程池,runtime会在线程空闲的时候去调度相应的协程来执行。协程和线程是分离的,可以理解为用户态的线程,比通过内核创建的线程要更加轻量和高效,协程的运行依赖于线程,因此协程调度的效率将直接关系到 Go 程序运行的效率。
内核可以创建多个线程来运行Go程序,而协程和线程又是N:1的关系。多线程常常可以保证协程的运行最小化避免被阻塞。当一个协程进行了阻塞调用时,相应的线程也会被阻塞,因此runtime必须创建多个线程。线程数量可以用 GOMAXPROCS 来设置。
在runtime的调度中,先要了解以下三个核心数据结构和概念
struct G
{
byte* stackguard; // stack guard information
byte* stackbase; // base of stack
byte* stack0; // current stack pointer
byte* entry; // initial function
void* param; // passed parameter on wakeup
int16 status ; // status
int32 goid; // unique id
M* lockedm; // used for locking M's and G's
...
}g;
一个G表示一个单个的 goroutine,对于用户层来说,每次调用 go 关键字就会创建一个G,编译器会将使用 go 关键字的代码片段(或函数)通过 newProc() 生成一个G,G的结构包括了这个代码段的上下文环境,最终这些G会交给runtime去调度。(除了使用go关键字会生成G之外,对阻塞的系统调用也会产生G,只不过对用户是透明的 )
struct M
{
G* curg; // current running goroutine
int32 id ; // unique id
int32 locks ; // locks held by this M
MCache* mcache; // cache for this thread
G* lockedg; // used for locking M's and G's
uintptr createstack [32]; // Stack that created this thread
M* nextwaitm; // next M waiting for lock
...
}m;
M是runtime在OS层创建的线程,每个M都有一个正在M上运行的G,还有诸如缓存,锁,以及一个指向全局的G队列的指针等数据
struct Sched
{
Lock; // global sched lock .
// must be held to edit G or M queues
G *gfree; // available g' s ( status == Gdead)
G *ghead; // g' s waiting to run queue
G *gtail ; // tail of g' s waiting to run queue
int32 gwait; // number of g's waiting to run
int32 gcount; // number of g's that are alive
int32 grunning; // number of g's running on cpu
// or in syscall
M *mhead; // m's waiting for work
int32 mwait; // number of m's waiting for work
int32 mcount; // number of m's that have been created
...
}s;
S 是全局唯一的,保存了所有的G队列和M队列,以及调度相关的信息,比如全局调度锁。从上面的结构可以看到,S有两个包含G的队列,一个是可运行的队列,一个是空闲队列。只有一个包含M的队列,队列里的M都是空闲的。如果要修改这些G和M队列,就必须先获取全局锁Lock。
程序在初始化运行时只创建一个M,当随着程序运行,越来越多的G被用户创建时,runtime就会随之创建更多的M来运行G,当然前提是,M的数量小于GOMAXPROCS,因此在任何一个时刻,活跃的M的数量都不会大于 GOMAXPROCS。
当M当前没有和任何一个G绑定时,就会从全局的可运行G队列中挑一个出来运行。如果G运行时被阻塞,比如进行了系统调用,运行这个G的M就会阻塞,此时在全局的空闲M队列中,就会有一个M被唤醒,来运行这个被阻塞的M剩下的G。这样就保证了某个M被阻塞的时候,处该M运行队列中的其余的G不会被阻塞,而是能切到另一个M上去运行。
M在channel上的阻塞和在系统调用上的阻塞是不一样的。因为M在系统调用上被阻塞是因为需要等待内核数据返回,阻塞的这段时间内,M是无法做任何事情的。但是channel完全是runtime层的产物,跟OS没有一毛钱关系。如果一个goroutine使用channel通信而产生阻塞,运行这个goroutine的M是没有必要为此阻塞的。简而言之,陷入系统调用而被阻塞是没有办法的,因为OS层的东西应用层接触不到,你只能等它返回。但是channel 是runtime自己造的轮子,在channel上的阻塞当然不应该使M也阻塞掉。在这种情况下,G的状态会被设置为 waiting,然后这个M就去运行别的G了,直到这个waiting的G的channel通信可以继续进行下去了,G就会再次被设置为可运行状态(runnable),放在全局队列里,一旦有空闲的M就会来运行它。
以上版本的Go实现还有很多待改进的地方,比如
调度器过于依赖全局调度锁
当需要更改M或G或任何全局的调度字段时,都要获取这个全局唯一的锁,这对大型的并发系统和并行计算带来了很大的限制。
其次即使M不运行任何G,也会申请大概2M左右的内存,这通常是不必要的。当空闲的M数越来越多的时候就会造成大量的内存浪费
如果系统调用过多,就会导致大量的G在这些阻塞的M和未阻塞的M之间频繁的切换,造成了CPU不必要的浪费。(个人观点,这种情况对内存密集型的业务还是很友好的)
Vyokov 提出了一个新的结构来改进上述的问题——P,用来对处理器进行抽象。M和G的概念保持不变。M原先是相当于OS的线程的抽象,加入P之后,M的运行将必须依赖P才能运行。
P的结构成员有很多来自于S和M,比如M的缓存放到P中了,并且每个P都有一个局部的可运行的G队列,以此代替之前全局的G队列。这些局部的G队列,解决了之前全局队列的修改需要加锁带来的性能问题,而将M的缓存移动到P中,解决了空闲的M产生内存浪费的问题。
具体的调度规则是,每创建一个G,就会放在一个P的可运行的G队列中,这样可以保证G最终会被调度到P上运行。除此之外还有一个盗窃算法,在P队列里,队首的P自身的G队列如果是空的,就会随机从别的P的G队列那里偷走队尾一半的G放在自己的队列里运行。在查找G的过程中,如果发现G已经与某个空闲的M绑定了,就会唤醒相应的M去处理。
P的加入搞定了上述问题的前两个,但是M在阻塞和非阻塞状态之间持续切换带来的高负载还是没有解决,于是Vyokov提出了不断查找的方式来代替阻塞的方案。。