手撕源码深入浅出 Golang 协程调度模型

一说到 Golang 的协程调度模型,大家一定会脱口而出 GMP 调度模型。G 代表 goroutine 协程,M 代表运行 goroutine 的线程,P 代表 Process 装载 goroutine 的本地队列,三者搭配构成 Golang 的协程调度模型。但大家有没有想过,协程模型是怎样发展到 GMP 模型?GMP 主要为了解决哪些问题?下面就通过源码来深入浅出 Golang 协程调度模型。

本文使用的 Golang 版本是 1.16.2。

前置知识准备

Golang 的协程是什么?

协程实质上是一种在用户空间实现的协作式多线程架构。把这句话分开来看:

第一部分是在用户空间,协程是在用户空间实现的,说明协程的设计初衷是不希望系统内核来管理自己的生命周期,它是完全由程序来控制,可以理解成程序中一种特殊的函数。

第二部分实现的协作式多线程架构,说明协程是区别于线程,它比线程粒度更小,一个线程可以包含多个协程,协程在线程中是串行执行的,但协程实现的最终效果与多线程一样,都是为了高并发。

在 Golang 中大家都知道 goroutine 就是协程的实现,下面来结合一小段示例代码和 goroutine 的结构体源码来具体了解下 Golang 中的协程实现。

// 业务示例代码
func task1() {
  task2()
}
​
func task2() {
  fmt.Println("do task2!")
}
​
func main() {
  go task1()
  
  time.Sleep(time.Minute) // 延时是为了不让 main 函数直接退出,导致 goroutine 中的业务没有执行完就返回
}
复制代码

上面的业务代码是通过 go 关键字创建 goroutine,通过 debugger 示例代码可以看到执行的栈信息,栈信息是表示函数调用的关系,可以看到 task1 调用了 task2,栈底的 runtime.goexit 暂时可以忽略,后面介绍 GMP 模型时会介绍。

image-20220726184729255.png

接下来我们看一下 goroutine 在 golang 中的源码,用 IDE 打开一个 go 项目,在左侧目录栏中可以看到 External Libraries 下有 Go SDK 1.xx 的源码。 goroutine 的结构体在 Go SDK 源码目录下的 runtime 文件夹下 runtime2 文件中为 g 的结构体

image-20220726191211994.png

具体结构体源码比较长,大家可以自己去看,这里列举出其中有几个比较重要的属性:

  1. stack:stack struct,表示堆栈的地址,其中包括栈顶和栈底的指针。
  2. m:表示当前与协程 g 绑定的线程 m。
  3. sched:gobuf struct,表示目前栈中程序运行的位置,其中 sp (Stack Pointer)指针表示正在运行的函数栈帧,pc 指针表示运行到的代码具体位置。
  4. atomicstatus:表示协程状态。
  5. goid:表示协程唯一 id。
  6. schedlink:表示指向下一个协程的指针。
  7. preempt:抢占调度标志。

由此可见协程在 Golang 中是用结构体表示,主要记录了该协程的栈空间,栈内信息以及执行状态。

0.x 时代,单线程如何调度协程

在 0.x 版本, Golang 采用单线程来调度协程,也奠定了 Golang 中线程如何调度 goroutine (协程)的基本原理。

在 Golang 中线程是用 m 结构体来表示,m 结构体与 g 结构体在同一路径文件下,下面先来看一下 m 结构体的重要属性有:

image-20220726223607307.png

  1. g0:表示 g0 协程,是 go 程序执行最开始启动的母协程,它的作用是用来调度后面用户用 go 关键字创建的 goroutine。
  2. curg:表示当前正在运行的 goroutine。
  3. p:当前工作线程绑定的 p。
  4. preemptoff:该字段不为空字符串是,保持 curg 始终在这个 m 上运行。
  5. spinning:布尔值,表示当前 m 是否处于自旋状态,正在窃取其他线程的 g。
  6. mOS:表示不同操作系统的线程模型。

这里我们先只关注 g0,curg 这两个属性,其他属性的详细作用后面会介绍。

由此可见 goroutine 只有依赖线程才能够执行。线程执行 goroutine 具体流程如下图所示:

GMP2.jpg

这是 Golang 中线程启动时所调用的方法,源码地址在 runtime/proc.go 下

image-20220726230502814.png

在 schedule 函数中先创建了一个变量 gp 的 goroutine 结构体。

image-20220726230740121.png

下面是一系列获取队列中 goroutine 赋值给 gp 变量的逻辑,暂时忽略这部分逻辑,后将赋值后 gp 传给 execute 函数。

image-20220726231015576.png

通过函数注释和代码可知 execute 是将传进来的 goroutine(即 gp) 放到对应的线程 m 上执行,最后将 gp.sched 传入,调用了 gogo 的方法。goroutine 中的 sched 属性中包含了目前栈中程序运行的位置。

gogo 这个方法通过搜索发现,是由汇编实现的,与平台相关,这里提供的是 asm_amd64.s 下的 gogo 方法

image-20220726232133851.png

gogo 方法拿到 sched 属性中用户协程栈信息后,先插入了一个 goexit 栈帧到栈底,然后通过 sched 中的 sp 和 pc 信息跳转到业务程序执行的位置继续进行执行。

这里需要结合文章最开始提供的示例代码来构建一下当前栈的模型。如下图:

stack.jpg

此时栈图中有 g0 栈是线程 m 的函数调用关系,go 栈是示例代码中 goroutine 中业务代码的函数调用关系,可以看到此时线程 m 已经执行到业务代码,因为已经开辟出了用户协程 goroutine 的栈空间。

由栈图可知,go stack 中 task2 函数执行完后会出栈,以此类推依次出栈直到 go stack 内只有 goexit 函数开始执行。

image-20220726235548114.png

goexit 是由汇编实现,实际底层还是调用了 runtime.goexit1() 函数

image-20220726235946126.png

goexit1中主要是调用 mcall() 汇编函数进行栈切换,切换到 g0 栈后将此时已执行完成的 goroutine(即 gp)关闭,再重新调用 schedule() 函数获取新的 goroutine 放入 m 线程中进行执行。

这就是 Go 早期版本一个完整的单线程循环调度 goroutine 的过程。

简述一下整体流程:

  1. 线程先调用 schedule() 函数获取待执行的 goroutine
  2. 调用 execute() 函数将待执行 goroutine 绑定到执行线程** m 上
  3. 调用 gogo() 方法执行 goroutine 内的业务程序
  4. goroutine 执行完后退出,线程再次调用schedule() 函数获取下一个待执行的 goroutine

1.0 时代,多线程如何调度协程

进入 1.0 版本,Golang 为了合理利用多核优势,顺理成章的就想到了多线程模型,如下图,M 代表线程,G 代表协程:

多个线程从一个全局的协程队列中抢占式获取协程执行,多线程下的执行效率在多核机器上会比 0.x 版本的单线程高很多,但是仍然存在两个问题会严重影响效率:

  1. 因为 G 在 M 中是串行执行,无法并发,而且当遇到 G 中包含长任务时会阻塞住整个线程运行,严重影响执行效率。
  2. 多线程并发抢夺协程队列时,一定是需要加全局锁,否则会引起并发问题。但是大家都知道抢锁这种模型并不高效。

GMP 时代的协程调度模型

Golang 为了解决多线程获取全局队列的效率问题,引入了本地队列,给每一个线程 M 都绑定一个本地队列 P,将待执行的 G 放入本地队列供 M 获取,这样就避免了全局锁的效率问题。我们先来看下 P 的结构体,如下:

image-20220727164707720.png

可以看到重要的属性有:

  1. m:表示本地队列服务的线程。
  2. runq:表示本地队列,用数组实现的循环队列。runqhead 表示队头指针,runtail 表示队尾指针。
  3. runnext:表示指向下一个执行的 gorontine 指针。
  4. gFree:表示空闲的 goroutine。

P 提供了本地队列和一些 M,G 需要的执行环境以及缓存信息,在 M 和 G 之间担当着送料器的角色,至此,GMP 模型已经构建完成。如下:

GMP.jpg

如图所示,M 会主动从 P 中获取的 G 执行,源代码线程调用的 schedule() 函数下:

image-20220727182259147.png

image-20220727181509207.png

当没有获取到 gp(即 goroutine) 时,会调用 runqget() 获取 G,runqget() 入参 _g_.m.p.ptr()是指当前运行 G 绑定的 M 对应的 P 的指针,runqget 接收到 P 后,获取了 P 上的 runnext 返回,runnext 前面有介绍,表示指向下一个执行的 gorontine 指针。

这里 M 就能获取到 LRQ 上的 G 进行执行,但 runqget() 中出现了 next == 0 的情况,也就意味着 LRQ 上没有待执行的 G,此时通过源码可知将会调用 findrunnable() 去获取。

image-20220727182708609.png

findrunnable() 中先会进行一系列 check,尝试在 LRQ 上获取,确认 LRQ 上获取不到的 G 后,调用 globrunqget() 从 GRQ 上获取一批 G 到 LRQ 上,获取 G 的个数是 LRQ 长度的一半。

image-20220727182946340.png

当 GRQ 上也没有待执行的 G 可以获取时,空闲 M 对应的 P 将会从其他 P 上“偷取”一部分 G 来执行

image-20220727184223344.png

不过,进行“偷取 G”前,会进行一些当前整体状态的判断,如当其他 P 都处于空闲时,将不会进行“偷取 G”,或此时有很多 P 都在进行“偷取 G”,新进来的 P 也不会再进行“偷取”。

确认可以“偷取 G”后,先将当前 M 的 spinning 自旋状态置为 true,然后调用 runqsteal(),runqsteal() 内调用 runqgrab() “偷取”当前目标 P 上一半 G 来运行

image-20220727184947275.png

如果“偷取 G” 都没有成功的话,M 将进入休眠,等待被唤醒后再工作。

简述整个流程是:M 先去 LRQ 上获取 G 执行,LRQ 没有 G ,M 就从 GRQ 上获取 G 执行,GRQ 上也没有 G 的话,M 就“偷取”其他 M 上的 G 上执行,如果“偷取”都没有 G 的话,M 将进入休眠,等待被唤醒。

引入 P 模型除了是解决全局锁的效率问题,还有一个好处是当一个线程 M 阻塞是,可以将和它绑定的 P 上的 G 转移到其他线程,这样可以充分利用线程的优势。这种操作涉及到了协程调度模型,详细原理下面介绍。

协程调度模型如何解决协程并发

引入 P 模型解决了 1.0 版本多线程调度的全局锁效率问题,而串行执行无法并发,长任务阻塞问题就是由协程调度模型来解决。

首先长任务阻塞线程会造成 LRQ 任务饥饿的问题,这意味着如果队列中有一个长任务正在线程中执行,后面所有任务,无论长短,是否紧急都会阻塞在这里,这在实际一般的业务中是不可接受的。

为了解决这个问题,Golang 使用了一个调度模型的通用解决方案,就是任务轮换。简单来说就是执行长任务时,会达到某个触发条件,系统保存长任务的执行任务现场,将保存后的长任务放入队列中重新排队,线程将从队列中获取下一个任务执行。对应 GMP 的流程图如下:

GMP1.jpg

执行业务逻辑时保存现场的触发机制分为主动触发和被动触发。

主动触发任务切换

1. 程序中调用到了 runtime.gopark() 方法

image-20220727210425292.png

由注释源码可知 gopark() 主要是用于 goroutine 的切换,函数保存了当前 goroutine 执行的现场信息,然后调用了 mcall(park_m) 切换栈,park_m 中调用 schedule() 方法 重新获取新的任务执行。

image-20220727210904040.png

但 runtime.gopark() 方法是开发者无法主动直接调用的,只能通过外层方法间接调用。如调用 time.Sleep() 方法,底层就是调用了 runtime.gopark() 。

2. 系统调用完成时

当程序中使用 Syscall() 函数进行系统底层调用,流程中会先调用 entersyscall() 函数。

image-20220727212318606.png

此时进入系统调用,G 将会进入 _Gsyscall 状态,也就是会被暂时挂起,直到系统调用结束。M 进入系统调用,此时 P 也会放弃该 M。但是,此时 M 还指向 P,当 M 从系统调用返回后,会调用 exitsyscall() 方法还能找到恢复调度。

image-20220727213258150.png

image-20220727213316990.png

但如果 sysmon 线程检测到系统调用阻塞 M 时间过长时,会调用 retake,重新调度 P,让 P 绑定到其他 M 上执行。当最初的 M 被系统调用完后,会发现原 P 被占用,会直接寻找其他 P 进行调度。

被动触发任务切换(也称抢占式调度)

当开发的程序中永远不调用 gopark(),也不进行系统调用,这时就依靠抢占式调度来解决长任务线程阻塞问题。

1. 协作式抢占调度

在 Go 程序启动时,在执行 runtime_init() 前,会启动一个 sysmon 的监控线程,执行后台监控任务。简单来说 sysmon 线程主要进行 netpool(获取 fd 事件),retake(抢占),forcegc(强制 GC),scavenge heap(释放内存)等工作,其中与协程调度相关的是 retake 方法。

image-20220727215718817.png

retake() 中 sysmon 线程会扫描所有 P,并将 P 当前的调度次数和时间记录在 sysmontick 中。判断 P 的状态是否处于 _Prunning,处于 _Prunning 状态下的 P 执行时间是否超过 10ms(forcePreemptNS),若超过则执行 preemptone()。

image-20220727220242764.png

preemptone() 主要是将 gp 中的 preempt 抢占属性置为 true,stackguard0 置为 0xfffffabc。

在 goroutine 内部每次调用函数都会比较栈顶指针和 g.stackguard0,来判断是否发生栈溢出。此时通过上方 preemptone() 方法已经将 g.stackguard0 置为了一个很大的值。如果函数运行时 SP 小于 g.stackguard0,就会调用一个 morestack() 方法。

morestack() 是由汇编实现,实际也是调用了一个 go 函数 newstack()。newstack() 中主要是判断 g.preempt 值是否为 true,为 true 的情况下会将此时运行中的 G 保存放入 GRQ 中排队,等待执行。

2. 线程信号切换

如果遇见不调用 gopark(),不进行系统调用,甚至不进行函数调用的场景(无法触发 g.stackguard0 比较),Go 会在每次 GC 工作的时候,发送 SIGURG 信号给工作线程进行切换。这种场景非常少,这里不做详细介绍,大家只要了解基于信号切换时协程调度的兜底方案即可。

协程使用的最佳实践

  1. 协程适用于什么场景

    虽然 Go 已经实现了相当完善的协程调度机制,但同一时刻一个线程内依然只能执行一个协程,所以很明显协程 + 异步才能发挥出协程并发的最大效果。由此可见计算型的操作使用协程效率提升空间并不大,协程适用的场景应该是 IO 请求。

  2. 协程太多会有什么问题?有什么解决方案?

    协程太多的问题:

    1. 占用内存空间太大
    2. 文件打开数限制
    3. 调度开销过大,调度时间超过业务代码执行时间,得不偿失

    解决方案:

    1. 优化业务逻辑。在编写代码时思考业务是否需要这么多协程,有必要时借用工具来观察不同协程数下的业务执行效率,用数据说话。
    2. 利用有缓冲区的 channel 来做协程并发限流器。
    3. 构建协程池。这个方案不推荐,因为 GMP 架构已经相当于池化协程,再包装一层,增加了复杂度,提升效果也不明显。也不符合 Go 协程用完即毁的设计理念。

\

猜你喜欢

转载自juejin.im/post/7130045455852896287