GMP调度模型

1. 调度器由来

  调度器分为进程调度器和线程调度器。

1.1. 单进程时代

  单进程系统存在一定问题:1. 单一执行流程、计算机只能一个任务一个任务处理 2. 进程阻塞所带来的CPU浪费时间。

1.2. 多进程/多线程时代

  多进程/多线程问题: 多进程和多线程的设计模式,可以宏观的实现同时执行多个任务。多进程/多线程模式解决了阻塞问题,但是带来了另外一个问题,进程或者线程之间的切换,会产生一定的切换成本,而且多线程随着同步竞争(锁,竞争资源冲突等)开发设计变得越来越复杂。

  高内存占用 : 在32位OS下,进程的虚拟内存空间可以达到4GB;线程大约是4MB左右。这两个特点就造成了高内存的占用,因此多进程系统有高消耗的CPU调度的瓶颈。

1.3. 协程(goroutine)

  协程: CPU只能关注到处于内核态的线程,因此在发生syscall时,需要进行用户态和内核态的来回切换。是否有一种机制,把并发执行的任务单独交给一个代理来完成,然后将代理和内核态线程进行绑定,就不用来回进行切换了。当然这个代理就是协程(co-routine)

N:1, 1:1, M:N
goroutinegoroutinegoroutinegoroutine

  Golang中使用的GMP调度模型是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度线程(系统调用)。

2. Golang中协程调度器

  早期Golang调度器做的非常差,线程M需要从全局队列中拿到每个goroutine来执行,当然在取goroutine的同时会进行加锁,解锁。

2.1. 早期多线程调度器

  Go 语言在 1.0 版本正式发布时就支持了多线程的调度器,与上一个版本几乎不可用的调度器相比,Go 语言团队在这一阶段实现了从不可用到可用的跨越。

  线程M需要从全局队列中拿到每个goroutine来执行,当然在取goroutine的同时会进行加锁,解锁。这样会产生很大问题:

  1. 创建,销毁,调度G都需要每个M获取锁,就形成了激烈的锁竞争。
  2. M转移G会造成延迟和额外的系统负载。
  3. 系统调用(CPU在M间切换)导致频繁的线程阻塞和取消阻塞操作,增加了系统开销。

2.2. GMP调度模型

  2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:

  1. 在当前的 G-M 模型中引入了处理器 P,增加中间层;
  2. 在处理器 P 的基础上实现基于工作窃取的调度器

  基于任务窃取的 Go 语言调度器使用了沿用至今的 G-M-P 模型。

调度器设计策略:

  1. 复用线程:work stealing机制和hand off机制

work stealing机制: 空闲M从非空闲M的本地队列中偷取一个goroutine执行。

hand off 机制:

  1. M1绑定的P正在执行G1,但G1发生了阻塞
  2. 创建或唤醒一个M3将P本地队列转移到M3上去执行
  3. M1进入休眠状态,G1阻塞完如需继续执行,会加入到其他队列,如果不执行会直接销毁。
  1. 利用并行:GOMAXPROCS限定P个数
  2. 抢占
  1. 早期co-routine,执行完主动释放cpu,让给其他co-routine。
  2. goroutine中,不管10ms(一定时间内)是否主动释放,都会被其他goroutine抢占。
  1. 全局G队列

  work-stealing机制的补充,优先从其他M的本地队列中偷,如果也没有,就会去全局队列中偷,当然在执行过程中会进行加锁,解锁。

3. 总结

  Go 语言的调度器在最初的几个版本中迅速迭代,但是从 1.2 版本之后调度器就没有太多的变化,直到 1.14 版本引入了真正的抢占式调度才解决了自 1.2 以来一直存在的问题。在可预见的未来,Go 语言的调度器还会进一步演进,增加触发抢占式调度的时间点以减少存在的边缘情况。