Golang GMP 原理

这里填写标题

  • 1. Golang GMP 原理
    • 1.1. 协程和线程
      • 1.1.1. `1:1` 关系
      • 1.1.2. `N:1` 关系
      • 1.1.3. `M:N` 关系
    • 1.2. GMP 模型
      • 1.2.1. 模型说明
      • 1.2.2. 调度流程
    • 1.3. 调度场景
      • 1.3.1. G1 创建 G2
      • 1.3.2. G1 执行完毕
      • 1.3.3. G2 开辟过多的 G
      • 1.3.4. G2 本地满再创建 G7
      • 1.3.5. G2 本地未满再创建 G8
      • 1.3.6. G2 本地未满再创建 G8
      • 1.3.7. 被唤醒的 M2 从全局队列取批量 G
      • 1.3.8. M2 从 M1 中偷取 G
      • 1.3.9. 自旋线程的最大限制
      • 1.3.10. 自旋线程的最大限制
      • 1.3.11. G 发生系统调用/非阻塞

1. Golang GMP 原理

1.1. 协程和线程

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

1.1.1. 1:1 关系

  • 优点: 1 个协程绑定 1 个线程, 这种最容易实现, 协程的调度都由 CPU 完成了。
  • 缺点: 协程的创建、删除和切换的代价都由 CPU 完成, 有点略显昂贵了。

1.1.2. N:1 关系

  • 优点: N 个协程绑定 1 个线程, 协程在用户态线程即完成切换, 不会陷入到内核态, 这种切换非常的轻量快速。
  • 缺点: 1 个进程的所有协程都绑定在 1 个线程上, 某个程序用不了硬件的多核加速能力, 一旦某协程阻塞, 造成线程阻塞, 本进程的其他协程都无法执行了, 根本就没有并发的能力了。

1.1.3. M:N 关系

  • 优点: M 个协程绑定 1 个线程, 是N:11:1类型的结合, 克服了以上 2 种模型的缺点。
  • 缺点: 实现起来最为复杂。

1.2. GMP 模型

Go 为了提供更容易使用的并发方法, 使用了 goroutine 和 channel。goroutine 来自协程的概念, 让一组可复用的函数运行在一组线程之上, 即使有协程阻塞, 该线程的其他协程也可以被 runtime 调度, 转移到其他可运行的线程上。最关键的是, 程序员看不到这些底层的细节, 这就降低了编程的难度, 提供了更容易的并发。goroutine 非常轻量, 一个 goroutine 只占几 KB, 并且这几 KB 就足够 goroutine 运行完, 这就能在有限的内存空间内支持大量 goroutine, 支持了更多的并发。虽然一个 goroutine 的栈只占几 KB, 但实际是可伸缩的, 如果需要更多内容, runtime 会自动为 goroutine 分配。

1.2.1. 模型说明

G 来表示 Goroutine, M 来表示线程, P 来表示 Processor:

线程是运行 goroutine 的实体, 调度器的功能是把可运行的 goroutine 分配到工作线程上:
Goroutine 调度器和 OS 调度器是通过 M 结合起来的, 每个 M 都代表了 1 个内核线程, OS 调度器负责把内核线程分配到 CPU 的核上执行, 对上图的解读如下:

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

1.2.2. 调度流程

从上图我们可以分析出几个结论:

  • 我们通过 go func() 来创建一个 goroutine;
  • 有两个存储 G 的队列, 一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中, 如果 P 的本地队列已经满了就会保存在全局的队列中;
  • G 只能运行在 M 中, 一个 M 必须持有一个 P, M 与 P 是 1: 1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行, 如果 P 的本地队列为空, 就会想其他的 MP 组合偷取一个可执行的 G 来执行;
  • 一个 M 调度 G 执行的过程是一个循环机制;
  • 当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作, M 会阻塞, 如果当前有一些 G 在执行, runtime 会把这个线程 M 从 P 中摘除 (detach), 然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个 P;
  • 当 M 系统调用结束时候, 这个 G 会尝试获取一个空闲的 P 执行, 并放入到这个 P 的本地队列。如果获取不到 P, 那么这个线程 M 变成休眠状态, 加入到空闲线程中, 然后这个 G 会被放入全局队列中。

1.3. 调度场景

1.3.1. G1 创建 G2

  • 场景: P 拥有 G1, M1 获取 P 后开始运行 G1, G1 使用 go func() 创建了 G2, 为了局部性 G2 优先加入到 P1 的本地队列。

1.3.2. G1 执行完毕

  • 场景: G1 运行完成后, M 上运行的 goroutine 切换为 G0, G0 负责调度时协程的切换。从 P 的本地队列取 G2, 从 G0 切换到 G2, 并开始运行 G2, 实现了线程 M1 的复用。

1.3.3. G2 开辟过多的 G

  • 场景: 假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G, 前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列, p1 本地队列满了。

1.3.4. G2 本地满再创建 G7

  • 场景: G2 在创建 G7 的时候, 发现 P1 的本地队列已满, 需要执行负载均衡(把 P1 中本地队列中前一半的 G, 还有新创建 G 转移到全局队列)(实现中并不一定是新的 G, 如果 G 是 G2 之后就执行的, 会被保存在本地队列, 利用某个老的 G 替换新 G 加入全局队列)说明: 这些 G 被转移到全局队列时, 会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

1.3.5. G2 本地未满再创建 G8

  • 场景: G2 创建 G8 时, P1 的本地队列未满, 所以 G8 会被加入到 P1 的本地队列。
  • 说明: G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定, 而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。

1.3.6. G2 本地未满再创建 G8

  • 场景: 在创建 G 时, 运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。
  • 说明: 假定 G2 唤醒了 M2, M2 绑定了 P2, 并运行 G0, 但 P2 本地队列没有 G, M2 此时为自旋线程(没有 G 但为运行状态的线程, 不断寻找 G)。

1.3.7. 被唤醒的 M2 从全局队列取批量 G

  • 场景: M2 尝试从全局队列(简称"GQ") 取一批 G 放到 P2 的本地队列。至少从全局队列取 1 个 g, 但每次不要从全局队列移动太多的 g 到 p 本地队列, 给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。
  • 说明: 假定我们场景中一共有 4 个 P(GOMAXPROCS 设置为 4, 那么我们允许最多就能用 4 个 P 来供 M 使用)。所以 M2 只从能从全局队列取 1 个 G(即 G3)移动 P2 本地队列, 然后完成从 G0 到 G3 的切换, 运行 G3。

1.3.8. M2 从 M1 中偷取 G

  • 场景: 假设 G2 一直在 M1 上运行, 经过 2 轮后, M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行, 全局队列和 P2 的本地队列都空了, 如场景 8 图的左半部分。
  • 说明: 全局队列已经没有 G, 那 m 就要执行 work stealing(偷取): 从其他有 G 的 P 哪里偷取一半 G 过来, 放到自己的 P 本地队列。P2 从 P1 的本地队列尾部取一半的 G, 本例中一半则只有 1 个 G8, 放到 P2 的本地队列并执行。

1.3.9. 自旋线程的最大限制

  • 场景: G1 本地队列 G5、G6 已经被其他 M 偷走并运行完成, 当前 M1 和 M2 分别在运行 G2 和 G8, M3 和 M4 没有 goroutine 可以运行, M3 和 M4 处于自旋状态, 它们不断寻找 goroutine。
  • 说明: 为什么要让 m3 和 m4 自旋, 自旋本质是在运行, 线程在运行却没有执行 G, 就变成了浪费 CPU. 为什么不销毁现场, 来节约 CPU 资源。因为创建和销毁 CPU 也会浪费时间, 我们希望当有新 goroutine 创建时, 立刻能有 M 运行它, 如果销毁再新建就增加了时延, 降低了效率。当然也考虑了过多的自旋线程是浪费 CPU, 所以系统中最多有 GOMAXPROCS 个自旋的线程(当前例子中的 GOMAXPROCS=4, 所以一共 4 个 P), 多余的没事做线程会让他们休眠。

1.3.10. 自旋线程的最大限制

  • 场景: 假定当前除了 M3 和 M4 为自旋线程, 还有 M5 和 M6 为空闲的线程(没有得到 P 的绑定, 注意我们这里最多就只能够存在 4 个 P, 所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P), G8 创建了 G9, G8 进行了阻塞的系统调用, M2 和 P2 立即解绑, P2 会执行以下判断: 如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M, P2 都会立马唤醒 1 个 M 和它绑定, 否则 P2 则会加入到空闲 P 列表, 等待 M 来获取可用的 p。本场景中, P2 本地队列有 G9, 可以和其他空闲的线程 M5 绑定。

1.3.11. G 发生系统调用/非阻塞

  • 场景: G8 创建了 G9, 假如 G8 进行了非阻塞系统调用。
  • 说明: M2 和 P2 会解绑, 但 M2 会记住 P2, 然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时, 会尝试获取 P2, 如果无法获取, 则获取空闲的 P, 如果依然没有, G8 会被记为可运行状态, 并加入到全局队列, M2 因为没有 P 的绑定而变成休眠状态(长时间休眠等待 GC 回收销毁)。

你可能感兴趣的:(golang,golang,gmp)