为什么会需要调度器?
Go 语言中使用 Goroutine 来代替线程,在编程语言层面中实现,它的特点就是占用的内容空间比线程更小,而且上下文切换不需要经过内核态,同时这意味着上下文切换的开销也更低。
一个线程分为 “内核态”线程和“用户态”线程。 一个用户态线程必须要绑定一个内核态线程,但是 CPU并不知道有 用户态线程 的存在,它只知道它运行的是一个 内核态线程。
一个协程可以绑定一个线程,那么也可以通过调度器把多个协程与一个或者多个线程进行绑定。
不同于其他语言直接使用操作系统来调度线程,Go 有一个专门的调度器来调度 Goroutine 。
如果 Goroutine 直接由操作系统调度,那会出现什么问题?
1.操作系统去操作 Goroutine 可能不会做出好的调度决策。比如 Go GC 在执行回收任务时,会需要所有的 Goroutine 停止工作,然后去标记需要清理的内存,操作系统去对 Goroutine 做操作可能没有 Go 调度器直接对 Goroutine 操控更方便。
2.当 Goroutine 过多时,有时候,比如 GC 时,必须等待它们达到一致性状态。而 Go 调度器可以更容易确认内存是否是一致。
作者认为:Go 会有一个调度器,一方面是因为 Goroutine 的轻量可以轻而易举地创建多个,直接使用操作系统去调用 Goroutine 会导致几个不便,例如上文提到的 GC 执行回收时确定内存一致性。另一方面,由 Go 语言调度器去调度 Goroutine 可以减少很多额外的开销,以提供高并发能力。 Goroutine 和调度器是 Go 语言能够高效地处理任务并且最大化利用资源的基础。
Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。
抢占式调度
解决某些 Goroutine 可以长时间占用线程,造成其他 Goroutine 的饥饿。
基于协作的抢占式调度器 1.2~1.13
- 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
- Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
基于信号的抢占式调度器 - 1.14 ~ 至今
- 实现基于信号的真抢占式调度;
- 垃圾回收在扫描栈时会触发抢占调度;
- 抢占的时间点不够多,还不能覆盖全部的边缘情况;
多线程调度器的最主要问题是调度时的锁竞争会严重浪费资源。
调度器和锁是全局资源,所有的调度状态都是中心化存储的,锁竞争问题严重;
线程需要经常互相传递可运行的 Goroutine,引入了大量的延迟;
每个线程都需要处理内存缓存,导致大量的内存占用并影响数据局部性;
系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外开销;
G-M-P 模型
基于工作窃取的多线程调度器将每一个线程绑定到了独立的CPU。
G
Goroutine 是Go语言调度器中等待执行的任务,通常情况下我们将它视为一个轻型的线程。但是它只存于 Go 语言的运行时中,它作为一种颗粒度更细的资源,更容易在高并发的场景下利用机器的CPU。
M
操作系统线程。调度器最多可以创建10000 个线程,但是大部分会陷入系统调用,只有 GOMAXPROCS 个活跃线程能正常运行。
在默认的情况下,一个四核的机器会创建四个活跃的操作系统线程,每一个线程会对应 Go 语言运行时中的 runtime.m 结构体。大部分情况下都直接使用默认值,因为默认的设置不会频繁触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,这样能减少额外的开销。
P
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能执行多个 Goroutine,它能在 Goroutine 进行 I/O 操作时及时让出计算资源,提高线程的利用率。
处理器持有一个由可运行的 Goroutine 组成的环形的运行队列 runq
,还反向持有一个线程。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行。如下所示的图片展示了 Go 语言中的线程 M、处理器 P 和 Goroutine 的关系。
学习资料
《Go语言设计与实现》
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/
https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit