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调度方面的性能。
有疑问加站长微信联系(非本文作者)