Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > go gmp调度模型

Go语言中GMP调度模型详解

作者:~|Bernard|

文章解释了Go语言的GMP模型,介绍了G、M、P三者的定义、协作关系和调度策略,本文给大家介绍了Go语言中GMP调度模型,感兴趣的朋友一起看看吧

如果说 Goroutine 是 Go 并发的"工人",Channel 是"传送带",那 GMP 就是整个工厂的调度指挥中心

一、Go 语言中的 GMP 模型是什么?

1.1 三个字母的本质

GMP 是 Go Runtime 设计的三级调度抽象

字母全称本质生活比喻
GGoroutine用户任务,待执行的函数工人(带着图纸和工具,等待派活)
MMachineOS 线程(内核线程),真正跑代码的执行引擎机床/工位(有电、有马达,能干活)
PProcessor逻辑处理器,包含调度上下文和本地队列车间主任(手里有本地任务清单、工具箱、材料柜)

1.2 三者如何协作?(核心关系)

就是这个模型让Go能在少量线程上调度海量goroutine,是Go⾼并发的基础。

铁律:

1.3 为什么需要三层?不是两层就行了吗?

P 是性能关键

如果没有 P,直接让 G 和 M 绑定:

有了 P 之后:

二、什么是 Go Scheduler?

2.1 本质定义

Go Scheduler 是 Go Runtime 内部实现的一个用户态线程调度器(User-space Scheduler),运行在用户空间,与操作系统内核调度器(如 Linux CFS)是上下级关系

它不是操作系统的某个组件,而是编译进你程序里的 runtime 包代码(主要在 runtime/proc.goruntime/runtime2.go 中)。

2.2 它到底在管什么?

它只管理一件事:哪个 G(Goroutine)应该在哪个 M(OS 线程)上执行

操作系统内核调度器管理的是进程/线程(M),它根本不知道 G 的存在。Go Scheduler 夹在操作系统和你的业务代码之间,扮演"中间商"

2.3 为什么需要它?直接用操作系统线程不行吗?

核心原因:OS 线程太重,Goroutine 太轻。

对比项OS 线程(Linux pthread)Goroutine
栈大小默认 1~8 MB(固定或分页增长)默认 2 KB(可动态增长)
上下文切换1~2 微秒(进内核,保存/恢复大量寄存器,刷新 TLB)~200 纳秒(用户态,只改几个寄存器)
创建销毁开销大(需内核参与,分配大量资源)极小(用户态分配,约 ~2KB 内存)
单机并发量通常几千个就是极限轻松几十万甚至上百万

如果没有 Go Scheduler,一个 Goroutine 绑定一个 OS 线程:

Go Scheduler 通过 M:N 模型(少量 M 承载大量 G),把 Goroutine 的调度成本压到极低。

2.4 Scheduler 的核心入口函数

Go 程序里每个 M 都在执行一个永不退出的调度循环

// runtime/proc.go 中的核心函数
func schedule() {
    // 1. 找下一个可运行的 G
    gp := findRunnable()
    // 2. 执行它
    execute(gp)
    // 3. G 结束后,回到这里,继续循环
}

这个 schedule() 函数就是 Go Scheduler 的心脏。你的程序里每个 M(除了极少数特殊情况)都在死循环地调用它。

三、Go 调度策略是什么?

3.1 大前提:M:N 调度 + 两级队列

Go 的调度器采用的是 M:N 模型

3.2 抢占策略的演进

Go 的调度不是纯粹的协作式,也不是纯粹的操作系统抢占式,而是"以协作为主,强制抢为辅"的混合策略。=

① Go 1.14 之前:协作式抢占(Cooperative Preemption)

原理: 编译器在几乎所有函数调用的入口处(函数序言,Function Prologue),插入一段检查代码。这段代码会检查当前 G 是否被标记为需要让出 CPU。

源码层面的实现: 每个 G 结构体里有一个字段叫 stackguard0。当 sysmon 认为某个 G 运行太久了,就把它的 stackguard0 设置为一个特殊值 stackpreempt

当 G 执行到下一个函数调用时,会检查栈空间:

// 伪代码,示意函数序言的检查逻辑
if sp < stackguard0 {  // 正常是检查栈溢出
    if stackguard0 == stackpreempt {
        // 不是栈溢出,是被要求抢占了!
        goschedImpl()  // 主动让出 CPU
    }
}

致命缺陷: 如果某个 Goroutine 写了一个纯死循环,且循环体里没有任何函数调用

for {}

那么它永远不会走到函数序言的检查点preempt 标志永远得不到检查。这个 G 会永久霸占所在的 M,同一个 P 本地队列里的其他 G 全部饿死。

② Go 1.14 之后:基于信号的异步抢占(Asynchronous Preemption)

Go 1.14 引入了新机制,解决了"无函数调用的死循环"问题。

完整流程:

Step 1:Sysmon 检测

Step 2:发送信号

Step 3:M 的信号处理

Step 4:安全点抢占

关键结论:

四、发生调度的时机有哪些?

调度不是随时发生的,而是在特定时机触发。分为主动、被动、抢占三类:

4.1 主动调度(Goroutine 自己让出)

场景函数说明
显式让出runtime.Gosched()G 主动说"我先歇会儿"
parkgopark()Channel 阻塞、sleep、wait 等,G 挂起
结束G 的函数 returnG 执行完了,M 必须找新 G

4.2 被动调度(Goroutine 被迫让出)

场景说明
系统调用G 调用 read/write 等,M 进入内核态,G 和 M 解绑,P 找新 M
Channel 阻塞无缓冲 Channel 没配对,G 挂到队列,M 切换
锁阻塞sync.Mutex 拿不到,G 进等待队列
GC触发 GC 时,所有 G 必须暂停(STW),调度器介入
栈增长G 的栈不够用了,切换到 g0 分配新栈

4.3 抢占式调度(Go 1.14+ 的关键改进)

以前 Go 是协作式调度,如果 G 里写了个死循环且不调用任何函数,它会永远占着 CPU。

Go 1.14 引入了基于信号的抢占

五、M 寻找可运行 G 的过程是怎样的?

这是 Go Scheduler 最核心的算法,我必须严格按照 runtime/proc.gofindrunnable() 的源码逻辑,给你分步骤拆解

总览

当 M 上的当前 G 执行完毕(或阻塞)后,M 会调用 schedule(),进而调用 findrunnable() 来找下一个活干。

Step 1:检查 P 的 runnext(最快路径)

if gp := pp.runnext; gp != nil {
    pp.runnext = nil
    return gp
}

每个 P 有一个单缓冲槽位runnext。如果某个 G 被标记为"下一个优先执行"(比如刚被唤醒的高优先级 G),直接拿走。无锁,最快

Step 2:从 P 的本地队列取(runqget)

if gp, inheritTime := runqget(_p_); gp != nil {
    return gp, inheritTime
}

P 的 runq 是一个无锁环形数组队列(长度 256)。M 优先从这里取 G。这也是无锁操作,性能极高。

Step 3:从全局队列取(globrunqget)

if gp := globrunqget(_p_, max); gp != nil {
    return gp
}

本地队列空了。M 去全局队列 sched.runq 取一批 G。

Step 4:检查 Netpoller(网络轮询器)

if gp := netpoll(0); gp != nil {
    return gp
}

看看有没有因为网络 I/O 阻塞、但底层 fd 已经就绪的 G(比如 socket 收到数据了)。这是一个非阻塞调用。

Step 5:Work Stealing(工作窃取)

// 尝试 4 轮
for i := 0; i < 4; i++ {
    // 随机选择其他 P
    for _, p2 := range stealOrder {
        if gp := runqsteal(_p_, p2); gp != nil {
            return gp
        }
    }
}

Step 6:再次检查全局队列和 Netpoller

偷了一圈回来,可能全局队列有新任务了,或者网络包到了。再检查一次。

Step 7:GC 后台任务

if gp := gcBgMarkWorker(); gp != nil {
    return gp
}

实在没用户任务了,M 可以帮 GC 做后台标记工作(并发 GC 的一部分)。

Step 8:真的没有了,M 进入休眠(stopm)

stopm()

六、GMP 能不能去掉 P 层?去掉会怎么样?

6.1 结论:绝对不能去掉

如果你把 P 层抽掉,只剩下 G 和 M 两层,Go 的 Runtime 会在调度性能内存分配性能上同时崩塌。P 层存在的意义,远不止"一个中间层"这么简单。

6.2 原因一:本地无锁队列(Local Runq)

这是 P 层最核心的功能。

有 P 时的场景:

去掉 P 后的场景:

6.3 原因二:mcache(内存分配无锁)

这是很多人忽略的一点。P 层不仅管调度,还管内存分配

Go 的内存分配器采用 TCMalloc 模型,分为三级:

G ──► mcache(P 本地)──► mcentral(全局,按 span class 分)──► mheap(全局,向 OS 申请)

mcache 是什么?

去掉 P 后的场景:

6.4 原因三:调度上下文缓存

P 保存了一个 G 运行所需的完整上下文环境

这意味着: 当 G1 在 P 上阻塞后,P 可以立刻把 G1 的上下文冻结,然后执行 G2。G1 恢复时,P 上的缓存还在,无需重建。

去掉 P 后: 每次切换 G,都要重新初始化内存分配环境、重新分配 defer/sudog 缓存,开销巨大。

七、P 和 M 在什么时候会被创建?

7.1 P 的创建时机

P 是程序启动时一次性创建的。

func main() {
    runtime.GOMAXPROCS(4)  // 默认等于 CPU 核心数
}

具体流程:

P 的数量是固定的吗?

pidle 是什么?

7.2 M 的创建时机

M 是 OS 线程,创建成本较高,Go 会尽量复用。M 是动态创建的。

创建 M 的入口是 newm(),触发场景:

场景 1:程序启动时

创建第一个 M,即 m0(主线程)。

场景 2:有 G 要执行,但没有空闲 M

// 当需要执行 G 时,调用 startm()
func startm(_p_ *p, spinning bool) {
    // 1. 先尝试从 midle(M 空闲列表)拿一个休眠的 M
    mp := mget()
    if mp == nil {
        // 2. 没有空闲 M,新建一个!
        newm(fn, _p_, spinning)
        return
    }
    // ...绑定 P,唤醒 M
}

场景 3:G 进入系统调用,当前 M 阻塞

当 G 执行 read() / write() 等系统调用时:

场景 4:GC 并行标记需要

GC 的并发标记阶段可能需要额外的 M 并行工作。

midle 是什么?

八、m0 是什么?有什么用?

8.1 m0 的定义

m0 是 Go 程序启动时,由操作系统创建的第一个 OS 线程(主线程)。 它是 Go Runtime 的"盘古",一切从这里开始。

8.2 m0 的特殊身世

普通 M 是 Go Runtime 自己调用操作系统 API(如 clone / CreateThread)创建的,但 m0 不是——它是操作系统启动你的程序时,就已经存在的那条线程。

操作系统加载可执行文件
    │
    ▼
创建进程 + 主线程
    │
    ▼
进入 _rt0_amd64_linux(或对应平台的入口)
    │
    ▼
这条主线程,就是 m0

8.3 m0 的不可替代职责

阶段m0 做什么
Runtime 初始化执行 runtime.rt0_go,设置栈、初始化内存分配器、初始化调度器
创建 G0 和 main goroutine创建第一个 G(不是 g0,是执行 main.main 的那个 G)
进入调度循环m0 也调用 mstart()schedule(),和普通 M 一样干活
兜底如果所有其他 M 都阻塞在系统调用里,至少 m0 还在运行,保证程序不死

8.4 m0 与普通 M 的区别

特性m0普通 M
创建者操作系统Go Runtime (newm)
g0 栈使用操作系统分配的初始栈使用 Runtime 在堆上分配的栈
生命周期与进程同生共死可以休眠复用,理论上可销毁(实际很少)
数量全局只有 1 个可以有多个

九、g0 是一个怎样的协程?有什么用?

9.1 g0 的定义

每个 M 都有一个专属的 g0,它是一个"系统 Goroutine",不执行用户代码,只执行 Runtime 代码。

9.2 g0 与普通 G 的本质区别

特性普通 Gg0
2KB 起步,动态增长(通常几 KB 到几 MB)较大(通常 8KB+),固定大小,系统栈
执行内容用户写的函数Runtime 内部函数(schedule、GC、syscall 封装)
调度行为被调度(被动等待 M 执行)主动调度别人(执行 schedule() 决定下一个跑谁)
数量用户创建,无数多个每个 M 严格只有 1 个

9.3 g0 的三大核心作用

作用 1:调度中转站(最重要的作用)

当 M 上的普通 G1 需要让出 CPU 时,必须切换到 g0,由 g0 执行调度逻辑。

G1 正在执行用户代码
    │
    ▼
G1 阻塞(Channel / Sleep / 系统调用)
    │
    ▼
保存 G1 的上下文(SP, PC, BP)到 G1.gobuf
    │
    ▼
【切换到 g0 栈】← 关键!
    │
    ▼
g0 执行 schedule() → findrunnable() → 找到 G2
    │
    ▼
恢复 G2 的上下文
    │
    ▼
【从 g0 切换回 G2】
    │
    ▼
G2 开始执行

为什么必须切到 g0? 因为 schedule() 本身是 Runtime 函数,它如果跑在普通 G 的栈上,可能会污染用户栈(比如占用太多栈空间、修改栈布局)。g0 有独立的、足够大的系统栈,可以安全地执行复杂的调度逻辑。

作用 2:系统调用的代理

普通 G 执行 read() / write() 时:

作用 3:栈增长管理

当普通 G 的栈不够用时,会触发 morestack。这个函数运行在 g0 栈上,负责分配更大的栈空间,拷贝旧栈数据,然后切回用户 G。

十、Go 栈和用户栈是如何进行切换的?

10.1 两种栈的明确区分

归属用途大小
用户栈(G 栈)每个普通 G 独有执行用户函数、局部变量、defer、panic recover2KB 起步,可动态增长
系统栈(g0 栈)每个 M 的 g0 独有执行 Runtime 代码、调度、系统调用较大,固定

10.2 切换的核心数据结构:gobuf

Go 在 G 结构体里保存了切换所需的全部寄存器状态:

type gobuf struct {
    sp   uintptr  // Stack Pointer:栈顶指针
    pc   uintptr  // Program Counter:下一条要执行的指令地址
    g    uintptr  // 指向当前 G 的指针(恢复时知道切到哪个 G)
    bp   uintptr  // Frame Pointer:帧指针/基址指针
    ret  uintptr  // 返回值(某些架构用)
    lr   uintptr  // Link Register:返回地址(ARM 等架构用)
}

10.3 完整切换过程(以 G1 阻塞、切换到 G2 为例)

阶段 A:保存 G1 的现场(G1 → g0)

发生时机: G1 执行到 Channel 发送/接收、Sleep、锁阻塞等。

; 伪汇编
; 1. 把 G1 的寄存器保存到 G1.sched(gobuf)
MOVQ SP, G1.sched.sp      ; 保存栈指针
MOVQ PC, G1.sched.pc      ; 保存程序计数器(当前指令地址)
MOVQ BP, G1.sched.bp      ; 保存帧指针
MOVQ $G1, G1.sched.g      ; 保存 G 指针
​
; 2. 修改 G1 状态
MOVQ $_Gwaiting, G1.status  ; G1 变成等待状态
​
; 3. 切换到 g0 栈
MOVQ g0.sched.sp, SP        ; SP 切到 g0 的栈顶
MOVQ g0, g                  ; 当前 G 寄存器指向 g0
​
; 4. 调用 runtime.schedule()
CALL runtime.schedule(SB)

阶段 B:g0 执行调度

func schedule() {
    gp := findRunnable()  // 找到 G2
    execute(gp)           // 准备执行 G2
}

阶段 C:恢复 G2 的现场(g0 → G2)

; 伪汇编
; 1. 修改 G2 状态
MOVQ $_Grunning, G2.status
​
; 2. 从 G2.sched 恢复寄存器
MOVQ G2.sched.sp, SP      ; 恢复栈指针
MOVQ G2.sched.pc, PC      ; 恢复程序计数器(下一条指令)
MOVQ G2.sched.bp, BP      ; 恢复帧指针
MOVQ G2.sched.g, g        ; 当前 G 寄存器指向 G2
​
; 3. 跳转到 G2 的 PC 继续执行
JMP G2.sched.pc

关键点: 这不是操作系统级别的线程切换(不需要进内核),只是修改了几个 CPU 寄存器(SP、PC、BP、g),所以速度极快(约 200 纳秒)。

10.4 系统调用时的特殊切换

当 G1 执行 read() 时,切换更复杂:

G1 用户栈
    │
    ▼
调用 syscall.Read
    │
    ▼
【切换到 g0 栈】
    │
    ▼
g0 调用 entersyscall()
    ├── 保存 G1 上下文
    ├── G1.status = _Gsyscall
    ├── P 和 M 解绑(P 可以去找别的 M)
    └── 释放 P 的锁
    │
    ▼
g0 执行真正的 syscall(进入内核态)
    │
    ▼
内核完成 read,返回用户态
    │
    ▼
g0 调用 exitsyscall()
    ├── 尝试重新绑定原来的 P
    ├── 如果 P 被抢走了 → G1 进全局队列
    └── 如果绑定成功 → 恢复 G1 上下文
    │
    ▼
【切回 G1 用户栈】
    │
    ▼
G1 继续执行

十一:GOMAXPROCS=4,100 万个 Goroutine,P 只有 4 个,为什么不会成为瓶颈?

答案分三层:

① P 是"逻辑处理器",不是"执行单元" 真正消耗 CPU 的是 M(OS 线程)。P 只是一个调度上下文,本身不消耗 CPU 时间。4 个 P 意味着最多 4 个 M 同时执行用户代码,这与你的 4 核 CPU 完全匹配。

② 100 万个 G 大部分时间在睡觉 在真实的高并发程序中(如 Web 服务器),绝大多数 G 处于:

这些 G 不需要占用 P。只有就绪状态(_Grunnable)的 G 才需要排队等 P。通常就绪的 G 数量远小于 100 万。

③ G 的本地队列分散了压力

④ G 本身极轻量

十二:纯死循环for {},Go 1.14 前后分别发生什么?

Go 1.14 之前:

Go 1.14 之后:

十三:栈切换时除了 SP/PC,还需要保存什么?为什么 BP 很重要?

gobuf 中保存的关键信息:

为什么 BP 极其重要?

BP 指向当前栈帧的底部。通过 BP 链表,可以回溯整个调用链:

当前函数的 BP ──► 调用者的 BP ──► 调用者的调用者的 BP ──► ...

如果没有 BP:

Go 在 AMD64 架构上默认启用帧指针(-fno-omit-frame-pointer),就是为了保证调试和 GC 的正确性。

到此这篇关于Go语言中GMP调度模型详解的文章就介绍到这了,更多相关go gmp调度模型内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文