Golang中的GMP模型原理及调度

被废弃的golang调度器

在2012年之前,用的是P不存在的GM调度器

GM调度器2

​ 线程M 想要执行、放回 协程G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。

​ 这样明显会发现一个问题,当M空闲都申请G时,形成锁竞争;甚至M0申请G、M1放回G都会形成竞争,效率低下。

老调度器有几个缺点:

  • 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争

  • M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。

  • 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。


GMP的设计思想

在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上

GMP模型

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

Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行

P和M的数量如何确定?

P 的数量由启动时环境变量 GOMAXPROCS 或者是由 runtime 的方法GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 GOMAXPROCS 个 goroutine 在同时运行。
M 的数量受go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略
程序员可以通过runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量。
并且一个 M 阻塞了,会创建新的 M。
另外,M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

G的调度过程

gofunc调度

Go 语言中 goroutine(简称 G)的调度过程如下:

创建阶段

  • 当使用go关键字创建一个goroutine时,新的G诞生。此时有两种情况:
    • Case1:若本地队列(与处理器 P 关联 )未满,G 加入到本地队列。
    • Case2:若本地队列已满,G 加入到全局队列。

调度执行阶段

  • 处理器(P)与机器线程(M)协作:
    • 每个 P 会绑定一个 MM 代表操作系统线程) 。MP 的本地队列获取 G 来执行。
    • M1 的本地队列空时,会从全局队列获取 G
    • 若全局队列也为空,M 会尝试从其他 P 的本地队列中窃取一半的 G(工作窃取算法 )。
  • 执行过程:
    • M 拿到 G 后开始执行 G 关联的函数(G.func() ) 。
    • 执行过程中,如果G.func()发生systemCall(系统调用)或阻塞:
    • 当前阻塞 GP 会被接管,M 会出列(或新建一个 M )。
    • 当阻塞解除,对应的 G 会重新回到队列等待调度执行。
    • 当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。
  • 调度返回与销毁:
    • G 执行完 G.func() 函数后,经过调度返回,最终被销毁。 而空闲的 M 会进入休眠的 M 队列等待再次被使用。

G的状态转换

GOroutinues状态

goroutines在其生命周期中会经历几种状态:

  • 可运行(Runnable):G已经准备好执行,但还没有被分配到M上。
  • 运行中(Running):G正在M上执行。
  • 休眠(Waiting):G在等待某些事件(如I/O操作、channel通信或定时器)。
  • 死亡(Dead):G的执行已经完成,或者被显式地终止。

在执行期间,goroutine 可能会因为多种原因进入阻塞(休眠)状态,像进行系统调用、等待 I/O 操作完成、获取锁或者等待通道操作等。一旦阻塞的原因消除,goroutine 就会被唤醒,重新回到就绪状态,等待再次被调度执行。

常见的阻塞有

  • I/O Select
  • Block on syscall
  • Channel
  • Mutex
  • Sleep
  • runtime.Gosched()

tips

非抢占式到抢占式的转变:从 Go 1.14 版本开始,goroutine 实现了基于信号的抢占式调度,避免了之前因某些代码结构(如死循环)导致的非抢占问题。

goroutine 结束后,它所占用的栈空间等资源会被自动释放,这一过程无需手动干预。

挂起机制(Hand off): 当 G 由于系统调用而阻塞时, M 会释放绑定的 P 供其他 M 使用

调度器的生命周期

调度器生命周期

为什么一定是M0和G0先被创建?而不是其他的M1、M2?

特殊的M0 和 G0

1. M0(Machine 0)

  • 作用:M0 是 Go 程序启动时创建的第一个 操作系统线程(OS Thread),负责执行 Go 的运行时初始化和调度任务。
  • 特点
    • 由 Go 运行时在 runtime·mstart 阶段创建,不依赖 Go 的调度器(因为调度器还没初始化)。
    • 运行在 主线程(main thread) 上,通常对应程序的初始线程(如 C 语言的 main 线程)。
    • 在初始化完成后,M0 会绑定一个 P(Processor),并开始调度 goroutine。
    • 如果程序没有启用 GOMAXPROCS > 1,M0 可能是唯一的 M(操作系统线程)。

2. G0(Goroutine 0)

  • 作用:G0 是每个 M 的 专属调度协程,负责管理该 M 上的 goroutine 调度(如创建、切换、销毁 goroutine)。
  • 特点
    • 每个 M(包括 M0)都有一个自己的 G0,不归 Go 调度器管理,而是由运行时直接控制。
    • G0 的栈是 固定大小(通常约 8KB),用于执行调度、垃圾回收(GC)、栈扩容等低层任务。
    • 当 M 需要切换 goroutine 时(如发生系统调用、channel 阻塞等),会先切回 G0,由 G0 决定下一个要运行的 G。

M0 和 G0 的关系

  1. 程序启动时
    • Go 运行时先创建 M0(主线程)。
    • 然后为 M0 创建 G0(调度协程)。
    • M0 和 G0 绑定后,Go 运行时开始初始化调度器(schedinit)。
    • 接着创建 main goroutine(用户的 main() 函数),并将其加入调度队列。
  2. 运行时调度
    • M0 通过 G0 调度 main goroutine 并执行用户代码。
    • 如果 main goroutine 结束,G0 会检查是否有其他 goroutine 可运行,如果没有,程序退出。
概念 作用 是否归调度器管理 栈大小
M0 第一个 OS 线程,负责初始化 ❌(直接由运行时管理) 系统线程栈(通常 2MB)
G0 每个 M 的专属调度协程 ❌(运行时直接控制) 固定(约 8KB)
main goroutine 执行用户的 main() 函数 ✅(由调度器管理) 动态增长(初始 2KB)

实际应用场景

  • 当你的 Go 程序启动时,runtime 会先运行在 M0 的 G0 上,初始化完成后才切换到 main goroutine。
  • 如果 M0 的 G0 崩溃(如栈溢出),整个程序会 panic,因为它是运行时的核心调度器。

GMP在源码中是怎样的

G M P 结构体定义于src/runtime/runtime2.go,本文版本为1.24.3

G的状态

在代码中体现为(https://github.com/golang/go/blob/master/src/runtime/runtime2.go)

const ( //源代码17行
    // G 状态
    //
    // 除了表示 goroutine 的一般状态外,G 状态还像一个锁,用于保护 goroutine 的栈(从而影响其执行用户代码的能力)。
    // TODO(austin): _Gscan 标志位可以设计得更轻量级。
    // 例如,我们可以选择不运行在运行队列中找到的 _Gscanrunnable 状态的 goroutine,而不是通过比较并交换(CAS)循环直到它们变为 _Grunnable 状态。
    // 像 _Gscanwaiting -> _Gscanrunnable 这样的状态转换实际上是可以的,因为它们不影响栈的所有权。

    // _Gidle 表示这个 goroutine 刚被分配,还未初始化。
    _Gidle = iota // 0

    // _Grunnable 表示这个 goroutine 在运行队列中。它当前没有执行用户代码,栈未被占用。
    _Grunnable // 1

    // _Grunning 表示这个 goroutine 可以执行用户代码。栈由这个 goroutine 占用,它不在运行队列中,并且已分配了一个 M 和一个 P(g.m 和 g.m.p 有效)。
    _Grunning // 2

    // _Gsyscall 表示这个 goroutine 正在执行系统调用。它没有执行用户代码,栈由这个 goroutine 占用,它不在运行队列中,并且已分配了一个 M。
    _Gsyscall // 3

    // _Gwaiting 表示这个 goroutine 在运行时被阻塞。它没有执行用户代码,不在运行队列中,但应该在某个地方记录(例如,通道等待队列),以便在必要时可以将其置为就绪状态。
    // 栈通常不被占用,但在适当的通道锁下,通道操作可能会读写栈的部分内容。
    // 否则,在 goroutine 进入 _Gwaiting 状态后访问栈是不安全的(例如,栈可能会被移动)。
    _Gwaiting // 4

    // _Gmoribund_unused 目前未使用,但在 gdb 脚本中被硬编码。
    _Gmoribund_unused // 5

    // _Gdead 表示这个 goroutine 当前未被使用。它可能刚退出,在空闲列表中,或者刚被初始化。
    // 它没有执行用户代码,可能分配了栈,也可能没有。G 及其栈(如果有)由退出该 G 或从空闲列表中获取该 G 的 M 拥有。
    _Gdead // 6

    // _Genqueue_unused 目前未使用。
    _Genqueue_unused // 7

    // _Gcopystack 表示这个 goroutine 的栈正在被移动。它没有执行用户代码,也不在运行队列中。栈由将其置为 _Gcopystack 状态的 goroutine 占用。
    _Gcopystack // 8
    ......

看似状态很多,其实主要还是图上几个

  • 等待中:_ Gwaiting、_Gsyscall 和 _Gpreempted,这几个状态表示G没有在执行;
  • 可运行:_Grunnable,表示G已经准备就绪,可以在线程运行;
  • 运行中:_Grunning,表示G正在运行;
字段 编号 描述
_Gidle 0 表示此 Goroutine 刚刚分配并且尚未初始化。
_Grunnable 1 表示此 Goroutine 在运行队列中。它当前不执行用户代码。堆栈不属于它。
_Grunning 2 表示此 Goroutine 可能执行用户代码。堆栈由此 Goroutine 拥有。它不在运行队列中。它被分配给一个 M 和一个 P。
_Gsyscall 3 表示此 Goroutine 正在执行系统调用。它不执行用户代码。堆栈由此 Goroutine 拥有。它不在运行队列中。它被分配给一个 M。
_Gwaiting 4 表示此 Goroutine 在运行时被阻塞。它不执行用户代码。它不在运行队列中,但应该在某个地方记录(例如,一个通道等待队列),以便在必要时可以准备就绪。堆栈不属于它,除非在适当的通道锁下,通道操作可能读取或写入堆栈的某些部分。否则,在 Goroutine 进入 _Gwaiting 后访问堆栈是不安全的(例如,它可能会被移动)。
_Gmoribund_unused 5 目前未使用,但在 gdb 脚本中硬编码。
_Gdead 6 表示此 Goroutine 当前未使用。它可能刚刚退出,位于空闲列表上,或者刚刚初始化。它不执行用户代码。它可能具有堆栈,也可能没有。G 和其堆栈(如果有)由正在退出 G 的 M 或从空闲列表中获取 G 的 M 拥有。
_Genqueue_unused 7 目前未使用。
_Gcopystack 8 表示此 Goroutine 的堆栈正在移动。它不执行用户代码,也不在运行队列中。堆栈由将其放入 _Gcopystack 的 Goroutine 拥有。
_Gscan 0x1000 与除 _Grunning 之外的其他状态组合表示 GC 正在扫描堆栈。Goroutine 不执行用户代码,堆栈由设置 _Gscan 位的 Goroutine 拥有。

G结构体中重要字段

源码305行左右

字段 说明
goid Goroutine ID, 唯一标识符
status Goroutine 状态, 如运行和阻塞
stack Goroutine 栈空间
gopc Goroutine PC 寄存器
m Goroutine 所在的 M
locked Goroutine 是否被锁定
sched Goroutine 调度器
atomicstatus Goroutine 原子状态

P的状态

const (
    // P 状态
    _Pidle = iota // 0
    _Prunning // 1
    _Psyscall // 2
    _Pgcstop // 3
    _Pdead // 4
)
状态 描述 关键特性
_Pidle P 未被用于运行用户代码或调度器,通常在空闲列表中,可能正在状态转换中。 - 由空闲列表或状态转换中的对象拥有 - 运行队列为空
_Prunning P 由一个 M 拥有,用于运行用户代码或调度器,仅拥有此 P 的 M 可更改其状态。 - 可转换为_Pidle(无工作时)、_Psyscall(系统调用时)或_Pgcstop(GC 时) - M 可直接转移 P 的所有权
_Psyscall P 未运行用户代码,与系统调用中的 M 有亲和性,但不被 M 拥有,可能被其他 M 抢占。 - 类似_Pidle 但转换轻量级并保持 M 亲和性 - 离开此状态需使用 CAS 操作,存在 ABA 风险
_Pgcstop P 为 STW(Stop The World)停止,由停止世界的 M 拥有,保留运行队列。 - 从_Prunning 转换到此状态会使 M 释放 P 并休眠 - startTheWorld 会重启有非空队列的 P
_Pdead P 不再使用(GOMAXPROCS 减少),资源被剥离但保留部分内容(如跟踪缓冲区),可被重用。 - GOMAXPROCS 增加时可重用

P结构体重要字段

字段 类型 描述
id int32 P 的唯一标识符
status uint32 P 的状态
link puintptr 下一个 P,在 P 链表中
m muintptr 拥有这个 PM
mcache *mcache 用于内存分配的 P 本地缓存
runqhead uint32 P 本地 runnable 状态的 G 队列头部,无锁访问
runqtail uint32 P 本地 runnable 状态的 G 队列尾部,无锁访问
runq [256]guintptr P 本地 runnable 状态的 G 队列,最多 256 个元素
runnext guintptr 一个比 runq 优先级更高的 runnable G
gFree struct 状态为 dead 的 G 链表,在获取 G 时会从这里面获取。
gcBgMarkWorker guintptr (原子操作) 用于 GC 后台标记的 worker
gcw gcWork 用于 GC 的工作结构体。

参考

刘丹冰Aceld https://topgoer.cn/docs/golang/chapter09-11

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇