【Go】GMP模型

点击阅读更多查看文章内容

线程

一个线程需要在内核态用户态之间进行切换,并且切换是受到操作系统控制的,可能这个现在需要等待多个时间片才能切换到内核态再调用操作系统底层的接口

那么我们是否可以用两个线程分别处理这两种状态呢?两个线程之间再做好绑定,当用户线程将任务提交给内核线程后,就可以不用堵塞了,可以去执行其他的任务了

image-20230223234355428

对于CPU来说(多核CPU),不需要关注线程切换的问题,只需要分配系统资源给内核线程进行调度即可,我们来给用户线程换个名字——协程(co-runtine)

如果是一比一的关系的话,上下文切换涉及用户态和内核态的切换,开销较大。

所以可以设计为N 比 1的形式,多个协程可以将任务一股脑的交给内核线程去完成,但是这样又有问题,如果其中一个问题在提交任务的过程中,堵塞住了,就会影响其他线程的工作,且无法利用多核CPU

image-20230223235007732

所以一般为M 比 N的关系

image-20230223235525408

协程相比于线程的优点

  1. 内存占用极低

    1. 初始栈仅 2KB,可以动态扩缩容
  2. 创建与切换成本低

    1. 协程由 Go 运行时调度,不依赖操作系统。
    2. 线程切换需要 CPU 上下文保存/恢复(涉及内核态切换)。
  3. 通信更安全高效,通过 Channel 传递数据避免共享内存导致的数据竞态问题


早期的GMP

为了解决传统内核级的线程的创建、切换、销毁开销较大的问题,Go 语言将线程分为了两种类型:内核级线程 M (Machine),轻量级的用户态的协程 Goroutine,至此,Go 语言调度器的三个核心概念出现了两个:

M: Machine的缩写,代表了内核线程 OS Thread,CPU调度的基本单元;

G: Goroutine的缩写,用户态、轻量级的协程,一个 G 代表了对一段需要被执行的 Go 语言程序的封装;每个 Goroutine 都有自己独立的栈存放自己程序的运行状态;分配的栈大小 2KB,可以按需扩缩容;

img

在早期,Go 将传统线程拆分为了 M 和 G 之后,为了充分利用轻量级的 G 的低内存占用、低切换开销的优点,会在当前一个M上绑定多个 G,某个正在运行中的 G 执行完成后,Go 调度器会将该 G 切换走,将其他可以运行的 G 放入 M 上执行,这时一个 Go 程序中只有一个 M 线程:

img

这个方案的优点是用户态的 G 可以快速切换,不会陷入内核态,缺点是每个 Go 程序都用不了硬件的多核加速能力,并且 G 阻塞会导致跟 G 绑定的 M 阻塞,其他 G 也用不了 M 去执行自己的程序了。

为了解决这些不足,Go 后来快速上线了多线程调度器:

img

多个 M 对应多个 G

每个Go程序,都有多个 M 线程对应多个 G 协程,该方案有以下缺点:

1)全局锁、中心化状态带来的锁竞争导致的性能下降;在 GM 模型中,所有的 G 协程都存储在一个全局队列中,所有的 M 线程需要从全局队列中获取 G 来执行。; 2)M 会频繁交接 G,导致额外开销、性能下降;每个 M 都得能执行任意的 runnable 状态的 G; 3)每个 M 都需要处理内存缓存,导致大量的内存占用并影响数据局部性; 4)系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外开销;


当前的GMP模型

为了解决多线程调度器的问题,Go 开发者 Dmitry Vyokov 在已有 G、M 的基础上,引入了 P 处理器,由此产生了当前 Go 中经典的 GMP 调度模型。

P:Processor的缩写,代表一个虚拟的处理器,它维护一个局部的可运行的 G 队列,可以通过 CAS 的方式无锁访问,工作线程 M 优先使用自己的局部运行队列中的 G,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了大量 G 的并发性。每个 G 要想真正运行起来,首先需要被分配一个 P。

  • P是被动的:P本身不会主动寻找M,它只是一个资源容器(维护Goroutine队列和运行上下文)。
  • M是执行者:M需要绑定P才能运行Go代码,但不会主动“找”P,而是由调度器分配。
  • 调度器是协调者:所有绑定/解绑逻辑由Go的运行时调度器统一管理,根据事件(如阻塞、唤醒、任务窃取)动态调整。

如图 1.5 所示,是当前 Go 采用的 GMP 调度模型。可运行的 G 是通过处理器 P 和线程 M 绑定起来的,M 的执行是由操作系统调度器将 M 分配到 CPU 上实现的Go 运行时调度器负责调度 G 到 M 上执行,主要在用户态运行,跟操作系统调度器在内核态运行相对应。

img

需要说明的是,Go 调度器也叫 Go 运行时调度器,或 Goroutine 调度器,指的是由运行时在用户态提供的多个函数组成的一种机制,目的是为了高效地调度 G 到 M上去执行。可以跟操作系统的调度器 OS Scheduler 对比来看,后者负责将 M 调度到 CPU 上运行。从操作系统层面来看,运行在用户态的 Go 程序只是一个请求和运行多个线程 M 的普通进程,操作系统不会直接跟上层的 G 打交道。

至于为什么不直接将本地队列放在 M 上、而是要放在 P 上呢? 这是因为当一个线程 M 阻塞(可能执行系统调用或 IO请求)的时候,可以将和它绑定的 P 上的 G 转移到其他线程 M 去执行,如果直接把可运行 G 组成的本地队列绑定到 M,则万一当前 M 阻塞,它拥有的 G 就不能给到其他 M 去执行了。

基于 GMP 模型的 Go 调度器的核心思想是:

  1. 尽可能复用线程 M:避免频繁的线程创建和销毁;

  2. 利用多核并行能力:限制同时运行(不包含阻塞)的 M 线程数为 N,N 等于 CPU 的核心数目,这里通过设置 P 处理器的个数为 GOMAXPROCS 来保证,GOMAXPROCS 一般为 CPU 核数,因为 M 和 P 是一一绑定的,没有找到 P 的 M 会放入空闲 M 列表,没有找到 M 的 P 也会放入空闲 P 列表;

  3. Work Stealing 任务窃取机制:M 优先执行其所绑定的 P 的本地队列的 G,如果本地队列为空,M会从全局队列获取 G 运行,如果全局队列为空,M 可以从其他 M 绑定的 P 的运行队列偷取 G 执行,这种 GMP 调度模型也叫任务窃取调度模型,这里,任务就是指 G;(M主动窃取)

  4. Hand Off 交接机制:M 阻塞,调度器会将M与P分离,并将P分配给其他空闲的M或者创建新的M来绑定P

  5. 基于协作的抢占机制:每个真正运行的G,如果不被打断,将会一直运行下去,为了保证公平,防止新创建的 G 一直获取不到 M 执行造成饥饿问题,Go 程序会保证每个 G 运行10ms 就要让出 M,交给其他 G 去执行;

  6. 基于信号的真抢占机制:尽管基于协作的抢占机制能够缓解长时间 GC 导致整个程序无法工作和大多数 Goroutine 饥饿问题,但是还是有部分情况下,Go调度器有无法被抢占的情况,例如,for 循环或者垃圾回收长时间占用线程,为了解决这些问题, Go1.14 引入了基于信号的抢占式调度机制,能够解决 GC 垃圾回收和栈扫描时存在的问题。

goroutine阻塞

在Go的GMP调度模型中,不同类型的阻塞行为对M(内核线程)的影响是不同的:

  1. 系统调用阻塞(真正导致M阻塞)

当Goroutine执行系统调用(如文件I/O、网络I/O等)时:

  • M会与P分离:系统调用会导致底层OS线程(M)阻塞
  • 调度器分配P给新的M:调度器会尝试获取空闲M或创建新M来绑定这个P继续执行
  • 系统调用返回后:
    • G尝试获取原来的P继续执行
    • 如果原来的P已被占用,G会被放入全局队列等待调度
  1. Go运行时阻塞(不会导致M阻塞)

对于Go运行时管理的阻塞操作:

  • channel操作:当G因channel操作阻塞时
  • time.Sleep:当G执行睡眠时
  • sync包锁:当G等待互斥锁时

这些情况下:

  • M不会阻塞:只是当前G被移出运行队列,移动到特定的等待队列(如channel的等待队列)当阻塞条件满足(如channel有数据、锁可用等),G会被重新放回原来P的本地运行队列
  • P可以立即运行其他G:M会继续执行P本地队列中的其他Goroutines
  • G被唤醒后:会被重新放入P的本地运行队列

全局队列

全局G队列是所有P共享的一个队列,用于存放待执行的Goroutines(G)。

特点:

  • 全局共享:所有P都可以访问
  • 锁竞争:访问需要获取全局锁,性能较低
  • 后备队列:当P的本地队列满时,新创建的G会被放入全局队列
  • 负载均衡:当P的本地队列为空时,会从全局队列获取G
作者

ShiHaonan

发布于

2025-07-01

更新于

2025-08-28

许可协议

评论