Go-调度模型-GPM

25次阅读

共计 2760 个字符,预计需要花费 7 分钟才能阅读完成。

GPM 模型

[TOC]

参考: 深入 Golang 调度器之 GMP 模型

前言

在了解 Go 的 gorutine 时,我们还是得先复习下,并发和并行的区别:

  • 并发:同一段 时间执行多个任务(你同时和两个女朋友聊天)。
  • 并行:同一时刻 执行多个任务(你和你朋友都在和你女朋友聊天)。

在单核处理器上,通过多线程共享 CPU 时间片串行执行(并发非并行)。而并行则依赖于多核处理器等物理资源,让多个任务可以实现并行执行(并发且并行)。

一、GPM 的基本流程

1.1 GPM 的含义

  • G,表示一个 goroutine,即我需要分担出去的任务;
  • P,一个装满 G 的队列,用于维护一些任务;
  • M,一个操作器,用于将一个 G 搬到线程上执行;

1.2 Go 调度器基本调度过程

  1. 创建一个 G 对象;
  2. 将 G 保存至 P 中;
  3. P 去唤醒(告诉)一个 M,然后继续执行它的执行序(分配下一个 G);
  4. M 寻找空闲的 P,读取该 P 要分配的 G;
  5. 接下来 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(释放自由列表中多余的项减少内存占用)等处理。

抢占条件

  1. 如果 P 在系统调用中,且时长已经过一次 sysmon 后,则抢占;

调用 handoffp 解除 M 和 P 的关联。

  1. 如果 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 调度方面的性能。

正文完
 0