深入理解 GMP 模型:Go 高并发的基石

"Go's concurrency is not magic. It's a complex dance of three entities: G, M, and P."

当你敲下 go func() 时,你可能觉得只是启动了一个简单的后台任务。但在这个简单的关键字背后,Go 运行时(Runtime)正在进行一场精密而复杂的调度。

为什么 Java 开启几千个线程就会导致系统卡顿,而 Go 开启几万个协程(Goroutine)却依然丝般顺滑?

为什么 Go 的协程切换成本仅为纳秒级?

这一切的秘密,都藏在 Go 独有的调度器模型------GMP 模型中。

1. 为什么要重新发明轮子?(线程 vs 协程)

在操作系统层面,线程(Thread)已经很强大了,为什么 Go 还要搞一个"协程"(Goroutine)?这本质上是 内核态用户态 的博弈。

|-----------|----------------------------------|---------------------------------------|
| 特性 | OS 线程 (Kernel Thread) | Goroutine (User Thread) |
| 创建/销毁 | 昂贵(需要系统调用) | 极廉价(运行时分配) |
| 内存占用 | 1MB - 8MB (固定栈) | 2KB (初始栈,可动态伸缩) |
| 切换成本 | 微秒级 (~1-2us) 涉及模式切换、寄存器保存较多 | 纳秒级 (~200ns) 仅保存 PC/SP/DX 等少量寄存器 |
| 调度者 | 操作系统内核 (OS Kernel) | Go 运行时 (Runtime) |

核心结论 :Go 调度器的本质,就是在用户态实现了一个"微型操作系统" ,它把成千上万个 Goroutine(G)复用到少量的内核线程(M)上执行。这就叫 M:N 调度

2. 从 GM 到 GMP

Go 1.0 时代并没有 P,只有 G 和 M。那个时候叫 GM 模型

在 GM 模型中,所有的 M(内核线程)都去竞争一个全局运行队列(Global Run Queue)。

这导致了严重的性能瓶颈(Dmitry Vyukov 在 2012 年提出的设计文档中痛陈):

  1. 全局锁竞争(Lock Contention):每个 M 想要拿任务,都得加一把全局大锁。

  2. 缓存局部性差:G 在不同的 M 之间跑来跑去,无法利用 CPU 本地缓存。

  3. 内存分配效率低:M 需要频繁访问全局的内存分配器。

为了解决这个问题,Go 1.1 引入了 P (Processor) ,将模型升级为 GMP

3. 源码级 GMP 详解

GMP 是三个核心结构体的缩写,它们在源码 src/runtime/runtime2.go 中定义。

3.1 G (Goroutine)

这是我们代码中 go func() 的实体。

复制代码
type g struct {
    stack       stack   // 协程的栈内存范围 [lo, hi)
    m           *m      // 当前绑定到哪个 M 上运行
    sched       gobuf   // 调度信息:保存 PC, SP 等寄存器,用于上下文切换
    atomicstatus uint32 // 状态:_Gidle, _Grunnable, _Grunning, _Gsyscall, _Gwaiting
    // ...
}

3.2 M (Machine)

对应一个真实的操作系统内核线程

复制代码
type m struct {
    g0      *g     // 【关键】特殊的调度协程,用于执行调度逻辑
    curg    *g     // 当前正在运行的 G
    p       puintptr // 当前绑定的 P
    nextp   puintptr // 下次唤醒时预绑定的 P
    // ...
}

3.3 P (Processor)

逻辑处理器,这是 GMP 的核心创新。它包含了运行 G 所需的资源。

复制代码
type p struct {
    m           muintptr // 回指,绑定到哪个 M
    runq        [256]guintptr // 【关键】本地运行队列,无锁访问
    runqhead    uint32
    runqtail    uint32
    mcache      *mcache // 【关键】本地内存缓存,分配小对象无需加锁
    // ...
}

3.4 M0 和 G0

除了普通的 G 和 M,Runtime 启动时会创建两个特殊角色:

  • M0 :启动程序时的主线程。它负责初始化操作,之后就和普通 M 一样了。

  • G0每个 M 都有一个 G0。G0 仅用于执行调度代码(schedule)、垃圾回收(GC)等系统任务。

    • 切换过程:当 M 执行用户 G 时,如果时间片到了需要切换,M 会先切换到 G0,由 G0 负责寻找下一个 G,然后再切换到新的 G。

    • :G0 使用的是系统栈(System Stack),空间较大。

4. 调度器的核心策略

Go 调度器如何保证 M 不会闲着,同时又不会饿死 G?

策略 A:工作窃取 (Work Stealing) ------ 负载均衡

当一个 M 绑定的 P 的本地队列空了,M 不会休息,它会变成"小偷"。

  1. 先去全局队列看看有没有 G(需要加锁,频率低)。

  2. 如果全局队列也空了,它会随机挑选另一个 P,从它的本地队列里偷走一半的 G(无锁/CAS)。

策略 B:Handoff (系统调用接力) ------ 避免阻塞

当 G 执行了一个阻塞系统调用(Syscall,如读取文件)时:

  1. 分离:P 发现 M 要去内核里睡大觉了,P 会立即把 M 踹开(解绑)。

  2. 接力:P 寻找(或新建)一个新的 M 来绑定自己,继续执行队列里的其他 G。

  3. 归队:当旧的 M 从系统调用醒来时,它会尝试获取一个空闲的 P。如果拿不到,就把自己的 G 放入全局队列,自己去休眠。

策略 C:Spinning Threads (自旋线程) ------ 空间换时间

如果 M 没活干了,它不会立刻销毁或深度休眠(挂起线程成本高),而是会**自旋(Spinning)**一会儿。

  • 自旋:就是跑一个空循环,不断检查有没有新的 G 产生。

  • 目的 :虽然浪费了一点 CPU,但如果此时有新 G 创建,M 可以极速响应,避免了线程唤醒的延迟。

策略 D:Network Poller (网络轮询器) ------ IO 多路复用

Go 处理网络请求(如 HTTP)时,不会阻塞 M

  1. 当 G 发起网络请求(如 Read),G 会被移动到 Netpoller 中等待。

  2. M 继续执行 P 里的其他 G。

  3. 当网络数据到达,Netpoller 通知 Runtime,G 重新变成 Runnable,回到 P 的队列中。

这就是为什么 Go 写网络高并发服务效率极高,本质是 epoll/kqueue 的封装。

5. 调度循环:schedule()

M 的一生非常枯燥,它一直在运行一个名为 schedule() 的函数。伪代码如下:

复制代码
// src/runtime/proc.go

func schedule() {
    // 0. 必须在 g0 栈上运行
    _g_ := getg() 
    
    var gp *g
    
    // 1. 每 61 次调度,优先从全局队列拿一个 G (防止全局饥饿)
    if _g_.m.p.ptr().schedtick%61 == 0 {
        gp = globrunqget()
    }

    // 2. 尝试从 P 的本地队列拿 G
    if gp == nil {
        gp = runqget(_g_.m.p.ptr())
    }

    // 3. 如果本地也没了,开始"找/偷" (findrunnable)
    //    这里会检查全局队列、网络轮询器(Netpoll)、偷其他 P
    if gp == nil {
        gp = findrunnable() // 可能会阻塞在这里
    }

    // 4. 执行 G
    execute(gp) 
}

func execute(gp *g) {
    // 切换堆栈,从 g0 切到 gp
    // 跳转到 gp.sched.pc 指向的代码
    gogo(&gp.sched)
}

6. 抢占式调度:如何对付死循环?

如果有一个 G 写了个死循环 for {},它会不会一直霸占 CPU?

  • Go 1.14 之前 :是协作式的。如果 G 不发生函数调用(函数头有栈检查指令),它真的会霸占 CPU。

  • Go 1.14 之后 :引入了基于信号的异步抢占

    • Sysmon :Runtime 有一个守护线程 sysmon(不绑定 P),专门监控长时间运行(>10ms)的 G。

    • 信号 :一旦发现超时,sysmon 发送 SIGURG 信号给 M。

    • 中断:M 收到信号,中断当前 G 的执行,保存上下文,将其扔回全局队列,强行切换。

7. 总结

GMP 模型是 Go 语言最引以为傲的设计之一,也是其高并发能力的基石。

  • G:轻量级任务,状态保存者。

  • P:本地资源包,实现无锁队列,隔离了 M 和 G。

  • M:系统线程,执行者,被复用。

  • 调度哲学

    • 复用:Work Stealing, Handoff。

    • 低延迟:Spinning M, Netpoller。

    • 公平:全局队列轮询, 抢占式调度

相关推荐
A小码哥2 小时前
跟着AI学习谷歌最新的通用商业协议(UCP)实操步骤
人工智能·学习
科技林总2 小时前
【系统分析师】4.2 网络体系结构
学习
哪有时间简史2 小时前
Python程序设计基础
开发语言·python
zh_xuan2 小时前
kotlin对集合数据的操作
开发语言·kotlin
Hcoco_me2 小时前
大模型面试题76:强化学习中on-policy和off-policy的区别是什么?
人工智能·深度学习·算法·transformer·vllm
企业对冲系统官2 小时前
大宗商品风险对冲系统统计分析功能的技术实现
运维·python·算法·区块链·github·pygame
ValhallaCoder2 小时前
Day48-单调栈
数据结构·python·算法·单调栈
Yuzhiyuxia2 小时前
【设计模式】设计模式学习总结
学习·设计模式
a程序小傲2 小时前
京东Java面试被问:多活数据中心的流量调度和数据同步
java·开发语言·面试·职场和发展·golang·边缘计算