Golang协程调度(待续)

    • Golang 调度机制
      • Go起源
      • Go 的 Runtime
        • G
        • M
        • S
      • 待改进的地方
      • Dmitry Vyokov 的改进
        • P

Golang 调度机制

Go起源

Go的并发模型是基于 C.A.R Hoare 1978 年的一篇论文——”Communicating Sequential Processes”(以下简称CSP)。在多核处理器开始普及的时候,Hoare就发现了多核处理器上的单个处理器通信的一致性问题。比如计算的阶乘问题,缓存的边界,生产者-消费者问题,哲学家进餐问题,矩阵乘法问题。当时的通信模型包括了很多沿用至今的模型,比如线程通信模型,即在访问共享内存时通过锁来保护临界区。这些模型非常难用,因此经常出现很多bug。Hoare于是在CSP中提出了一系列的解决方案,核心思想就是通过通信来共享内存,而不是通过共享内存来通信。

Go 里使用的很多机制都来自于Hoare的这篇论文,可以认为Go是Hoare的理论在工程上的实现。比如使用协程,协程之间的通信通过channel ,channel是一个同步的消息队列,甚至 select 关键字都来自于Hoare的思想。和线程相比更加轻量化,Go协程的执行更加独立,同时也能保证协程之间的一致性。。

Go 的 Runtime

在讲 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的调度中,先要了解以下三个核心数据结构和概念

G

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,只不过对用户是透明的 )

M

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队列的指针等数据

S

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实现还有很多待改进的地方,比如

  1. 调度器过于依赖全局调度锁

    当需要更改M或G或任何全局的调度字段时,都要获取这个全局唯一的锁,这对大型的并发系统和并行计算带来了很大的限制。

  2. 其次即使M不运行任何G,也会申请大概2M左右的内存,这通常是不必要的。当空闲的M数越来越多的时候就会造成大量的内存浪费

  3. 如果系统调用过多,就会导致大量的G在这些阻塞的M和未阻塞的M之间频繁的切换,造成了CPU不必要的浪费。(个人观点,这种情况对内存密集型的业务还是很友好的)

Dmitry Vyokov 的改进

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

P的加入搞定了上述问题的前两个,但是M在阻塞和非阻塞状态之间持续切换带来的高负载还是没有解决,于是Vyokov提出了不断查找的方式来代替阻塞的方案。。

  1. 一个空闲的M和与之关联的P全力查找新的G
  2. 一个没有任何P关联的M自旋的等待可用的P

你可能感兴趣的:(Golang协程调度(待续))