共计 2760 个字符,预计需要花费 7 分钟才能阅读完成。
GPM 模型
[TOC]
参考: 深入 Golang 调度器之 GMP 模型
前言
在了解 Go 的 gorutine 时,我们还是得先复习下,并发和并行的区别:
- 并发:同一段 时间执行多个任务(你同时和两个女朋友聊天)。
- 并行:同一时刻 执行多个任务(你和你朋友都在和你女朋友聊天)。
在单核处理器上,通过多线程共享 CPU 时间片串行执行(并发非并行)。而并行则依赖于多核处理器等物理资源,让多个任务可以实现并行执行(并发且并行)。
一、GPM 的基本流程
1.1 GPM 的含义
- G,表示一个 goroutine,即我需要分担出去的任务;
- P,一个装满 G 的队列,用于维护一些任务;
- M,一个操作器,用于将一个 G 搬到线程上执行;
1.2 Go 调度器基本调度过程
- 创建一个 G 对象;
- 将 G 保存至 P 中;
- P 去唤醒(告诉)一个 M,然后继续执行它的执行序(分配下一个 G);
- M 寻找空闲的 P,读取该 P 要分配的 G;
- 接下来 M 执行一个调度循环,调用 G → 执行 → 清理线程 → 继续找新的 G 执行。
简单叙述各自的任务:
- G,携带任务;
- P,分配任务;
- M,寻找任务;
二、全面的流程
2.1 各自携带的信息
G
- 需执行函数的指令(指针)
- 线程上下文的信息(goroutine 切换时,用于保存 g 的上下文,例如,变量、相关信息等)
- 现场保护和现场恢复(用于全局队列执行时的保护)
- 所属的函数栈
- 当前执行的 m
- 被阻塞的时间
P,P/ M 需要进行绑定,构成一个执行单元。P 决定了同时可以并发任务的数量,可通过 GOMAXPROCS 限制同时执行用户级任务的操作系统线程。可以通过 runtime.GOMAXPROCS 进行指定。
- 状态(空闲、运行 …)
- 关联的 m
- 可运行的 goroutine 的队列
- 下一个 g
M,所有 M 是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。
- 所属的调度栈
- 当前运行的 g
- 关联的 p
- 状态
以上列举了三个结构各自的重要属性,现在我们来看下详细的运行流程。
2.2 准备知识
2.2.1 栈
普通栈:普通栈指的是需要调度的 goroutine 组成的函数栈,是可增长的栈,因为 goroutine 可以越开越多。
线程栈:线程栈是由需要将 goroutine 放置线程上的 m 们组成,实质上 m 也是由 goroutine 生成的,线程栈大小固定(设置了 m 的数量)。所有调度相关的代码,会先切换到该 goroutine 的栈中再执行。也就是说线程的栈也是用的 g 实现,而不是使用的 OS 的。
2.2.2 队列
全局队列:该队列存储的 G 将被所有的 M 全局共享,为保证数据竞争问题,需加锁处理。
本地队列:该队列存储数据资源相同的任务,每个本地队列都会绑定一个 M,指定其完成任务,没有数据竞争,无需加锁处理,处理速度远高于全局队列。
2.2.3 上下文切换
简单理解为当时的环境即可,环境可以包括当时程序状态以及变量状态。
对于代码中某个值说,上下文是指这个值所在的局部 (全局) 作用域对象。相对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存 (堆栈) 信息等。
2.2.4 线程清理
由于每个 P 都需要绑定一个 M 进行任务执行,所以当清理线程的时候,只需要将 P 释放(解除绑定)(M 就没有任务),即可。P 被释放主要由两种情况:
- 主动释放:最典型的例子是,当执行 G 任务时有系统调用,当发生系统调用时 M 会处于阻塞状态。调度器会设置一个超时时间,当超时时会将 P 释放。
- 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的 P / M 组合。当超过系统程序设置的超时时间,会自动将 P 资源抢走。去执行队列的其它 G 任务。
阻塞是正在运行的线程没有运行结束,暂时让出 CPU。
2.2.5 抢占式调度
在 runtime.main
中会创建一个额外 m 运行 sysmon
函数,抢占就是在 sysmon 中实现的。
sysmon 会进入一个无限循环, 第一轮回休眠 20us, 之后每次休眠时间倍增, 最终每一轮都会休眠 10ms. sysmon 中有 netpool(获取 fd 事件), retake(抢占), forcegc(按时间强制执行 gc), scavenge heap(释放自由列表中多余的项减少内存占用)等处理。
抢占条件:
- 如果 P 在系统调用中,且时长已经过一次 sysmon 后,则抢占;
调用 handoffp
解除 M 和 P 的关联。
- 如果 P 在运行,且时长经过一次 sysmon 后,并且时长超过设置的阻塞时长,则抢占;
设置标识,标识该函数可以被中止,当调用栈识别到这个标识时,就知道这是抢占触发的, 这时会再检查一遍是否要抢占。
2.3 详细流程
基本流程和上面一样。每创建出一个 g,优先创建一个 p 进行存储,当 p 达到限制后,则加入状态为 waiting 的队列中。
如果 g 执行时需要被阻塞,则会进行上下文切换,系统归还资源后,再返回继续执行。
当一个 G 长久阻塞在一个 M 上时,runtime 会新建一个 M,阻塞 G 所在的 P 会把其他的 G 挂载在新建的 M 上。当旧的 G 阻塞完成或者认为其已经死掉时 回收旧的 M(抢占式调度)。
P 会对自己管理的 goroutine 队列做一些调度(比如把占用 CPU 时间较长的 goroutine 暂停、运行后续的 goroutine 等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他 P 的队列里抢任务(所以需要单独存储下一个 g 的地址,而不是从队列里获取)。
三、总结
相比大多数并行设计模型,Go 比较优势的设计就是 P 上下文这个概念的出现,如果只有 G 和 M 的对应关系,那么当 G 阻塞在 IO 上的时候,M 是没有实际在工作的,这样造成了资源的浪费,没有了 P,那么所有 G 的列表都放在全局,这样导致临界区太大,对多核调度造成极大影响。
而 goroutine 在使用上面的特点,感觉既可以用来做密集的多核计算,又可以做高并发的 IO 应用,做 IO 应用的时候,写起来感觉和对程序员最友好的同步阻塞一样,而实际上由于 runtime 的调度,底层是以同步非阻塞的方式在运行(即 IO 多路复用)。
所以说保护现场的抢占式调度和 G 被阻塞后传递给其他 m 调用的核心思想,使得 goroutine 的产生。
单从线程调度讲,Go 语言相比起其他语言的优势在于 OS 线程是由 OS 内核来调度的,goroutine
则是由 Go 运行时(runtime)自己的调度器调度的,这个调度器使用一个称为 m:n 调度的技术(复用 / 调度 m 个 goroutine 到 n 个 OS 线程)。其一大特点是 goroutine 的调度是在用户态下完成的,不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池,不直接调用系统的 malloc 函数(除非内存池需要改变),成本比调度 OS 线程低很多。另一方面充分利用了多核的硬件资源,近似的把若干 goroutine 均分在物理线程上,再加上本身 goroutine 的超轻量,以上种种保证了 go 调度方面的性能。