goroutine 调度器原理详解

goroutine 调度器的概念

说到“调度”,首先会想到操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。传统的编程语言比如 C、C++ 等的并发实现实际上就是基于操作系统调度的,即程序负责创建线程,操作系统负责调度。

尽管线程的调度方式相对于进程来说,线程运行所需要资源比较少,在同一进程中进行线程切换效率会高很多,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等。

线程是操作系统调度时的最基本单元,而 Linux 在调度器并不区分进程和线程的调度,只是说线程调度因为资源少,所以切换的效率比较高。

使用多线程编程会遇到以下问题:

  • 并发单元间通信困难,易错:多个 thread 之间的通信虽然有多种机制可选,但用起来是相当复杂;并且一旦涉及到共享内存,就会用到各种 lock,一不小心就会出现死锁的情况。
  • 对于线程池的大小不好确认,在请求量大的时候容易导致 OOM 的情况
  • 虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1 兆以上的内存空间,在对线程进行切换时不仅会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁对应的资源,每一次线程上下文的切换仍然需要一定的时间(us 级别)
  • 对于很多网络服务程序,由于不能大量创建 thread,就要在少量 thread 里做网络多路复用,例如 JAVA 的Netty 框架,写起这样的程序也不容易。

这便有了“协程”,线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU 并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程。

用户态线程实际有个名字叫协程(co-routine),为了容易区分,使用协程指用户态线程,使用线程指内核态线程。

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

Go中,协程被称为 goroutine(但其实并不完全是协程,还做了其他方面的优化),它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为 goroutine 分配。

而将所有的 goroutines 按照一定算法放到 CPU 上执行的程序就称为 goroutine 调度器goroutine scheduler

不过,一个 Go 程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有 thread,它并不知道有什么叫 Goroutine 的东西的存在。goroutine 的调度全要靠 Go 自己完成,所以就需要 goroutine 调度器来实现 Go 程序内 goroutine 之间的 CPU 资源调度。

在操作系统层面,Thread 竞争的 CPU 资源是真实的物理 CPU,但对于 Go 程序来说,它是一个用户层程序,它本身整体是运行在一个或多个操作系统线程上的,因此 goroutine 们要竞争的所谓 “CPU” 资源就是操作系统线程。这样 Go scheduler 的要做的就是:将 goroutines 按照一定算法放到不同的操作系统线程中去执行。这种在语言层面自带调度器的,称之为原生支持并发。

goroutine 调度器的演进

调度器的任务是在用户态完成 goroutine 的调度,而调度器的实现好坏,对并发实际有很大的影响。

G-M模型

现在的 Go语言调度器是 2012 年重新设计的,在这之前的调度器称为老调度器,老调度器采用的是 G-M 模型,在这个调度器中,每个 goroutine 对应于 runtime 中的一个抽象结构:G,而 os thread 作为物理 CPU 的存在而被抽象为一个结构:M(machine)。M 想要执行 G、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局 G 队列是有互斥锁进行保护的。

G-M模型

这个结构虽然简单,但是却存在着许多问题。它限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。主要体现在如下几个方面:

  • 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有 goroutine 相关操作,比如:创建、重新调度等都要上锁,这会造成激烈的锁竞争
  • goroutine 传递问题:M 经常在 M 之间传递可运行的 goroutine,这导致调度延迟增大以及额外的性能损耗
  • 每个 M 做内存缓存,导致内存占用过高,数据局部性较差
  • 系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销

所以用了 4 年左右就被替换掉了。

G-P-M 模型

面对之前调度器的问题,Go 设计了新的调度器,并在其中引入了 P(Processor),另外还引入了任务窃取调度的方式(work stealing)

  • PProcessor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
  • work stealing:当 M 绑定的 P 没有可运行的 G 时,它可以从其他运行的 M 那里偷取G。

G-P-M 模型的结构如下图:

G-P-M模型

从上往下是调度器的4个部分:

  1. 全局队列(Global Queue):存放等待运行的 G。
  2. P 的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建 G 时,G 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  3. P列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS 个。
  4. M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了1个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行

有关 P 和 M 的个数问题

P 的数量
  • 由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
M 的数量
  • go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000。但是内核很难支持这么多的线程数,所以这个限制可以忽略。
  • runtime/debug 中的 SetMaxThreads 函数,可以设置 M 的最大数量
  • 一个 M 阻塞了,会创建新的 M。

M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

抢占式调度

G-P-M 模型中还实现了抢占式调度,所谓抢占式调度指的是在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用CPU 10ms,防止其他 goroutine 被饿死,这也是 goroutine 不同于 coroutine 的一个地方。在 goroutine 中先后实现了两种抢占式调度算法,分别是基于协作的方式和基于信号的方式。

基于协作的抢占式调度

G-P-M 模型的实现是 Go scheduler 的一大进步,但此时的调度器仍然有一个问题,那就是不支持抢占式调度,导致一旦某个 G 中出现死循环或永久循环的代码逻辑,那么 G 将永久占用分配给它的 P 和 M,位于同一个 P 中的其他 G 将得不到调度,出现“饿死”的情况。当只有一个 P 时(GOMAXPROCS=1)时,整个 Go 程序中的其他 G 都会被饿死。所以后面 Go 设计团队在 Go 1.2 中实现了基于协作的抢占式调度

这种抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让 runtime 有机会检查是否需要执行抢占调度。

基于协作的抢占式调度的工作原理大致如下:

  1. 编译器会在调用函数前插入一个 runtime.morestack 函数
  2. Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求,此时会设置一个 StackPreempt 字段值为 StackPreempt ,标示当前 Goroutine 发出了抢占请求。
  3. 当发生函数调用时,可能会执行编译器插入的 runtime.morestack 函数,它调用的 runtime.newstack 会检查 Goroutine 的 stackguard0 字段是否为 StackPreempt
  4. 如果 stackguard0</
  • 8
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值