Go-GMP-调度器深度解析(改进版本)

Go 的 GMP 调度器到底在干什么

读完这篇,你应该能回答三个问题:goroutine 创建后去了哪里,阻塞时发生了什么,以及为什么一个 goroutine 跑太久会被强制踢掉。基于 Go v1.19 runtime 源码。


目录


〇、前置知识:线程、协程与用户态

聊 GMP 之前,先把几个基础概念理清楚。

内核态 vs 用户态

操作系统把 CPU 的运行分成两个权限级别:

  • 内核态:操作系统内核运行的地方,能直接操作硬件、管理内存、调度线程。权限最高。
  • 用户态 :应用程序运行的地方,不能直接碰硬件,需要通过系统调用(syscall)才能请内核帮忙做事。

为什么要分?安全。如果每个程序都能直接操作硬件,一个 bug 就能把整个系统搞崩。

线程(Thread)

线程是操作系统内核能调度的最小单元。注意是"内核调度"------创建线程、销毁线程、切换线程,都需要内核参与。

这意味着什么?线程切换是有开销的。内核要做:

  1. 保存当前线程的寄存器、栈指针等上下文
  2. 切换到新线程的页表
  3. 恢复新线程的上下文
  4. 可能还要刷 TLB(快表)

一次线程切换大概在微秒级别。听起来不多,但如果每秒切几万次,积少成多就很可观了。

而且线程的"重量"不只是切换。每个线程默认栈空间 1-8MB,创建一个线程就要分配这么大一块内存。开一万个线程,光栈就吃掉几十 GB。

协程(Coroutine)

协程是用户程序自己搞出来的调度单元,运行在用户态,不需要内核参与。

核心思路:线程是内核调度的,协程是应用程序自己调度的。一个线程上可以跑多个协程,协程之间的切换由程序自己完成,不用陷入内核。

协程切换时要做什么?跟线程差不多------保存寄存器、栈指针,恢复另一个协程的上下文。但因为全在用户态完成,不用切换页表、不用刷 TLB,开销比线程切换小一个数量级,通常在纳秒级。

协程的栈也可以很小。线程默认 1-8MB,协程可以只分配几 KB,按需增长。

所以协程的优势是:轻量、切换快、创建成本低。代价是需要程序自己实现调度逻辑,复杂度从操作系统转移到了应用程序。

goroutine

goroutine 是 Go 对协程的实现,但它不是简单的协程。Go 在原生协程的基础上做了两件事:

1. 栈可以动态扩缩

普通协程的栈大小通常是固定的。goroutine 初始栈只有 2KB,运行时如果不够了会自动扩容(分段栈 → 连续栈),用完了再缩回去。这样既省内存,又不用担心栈溢出。

2. 整个运行时都为它服务

这是 Go 跟其他语言最大的区别。C++ 的协程库可以实现协程调度,但 std::mutex 阻塞的是线程------一个协程卡在锁上,同线程的其他协程全跟着卡。Go 不一样,mutex、channel、网络 IO 这些东西在 runtime 层面都做了适配,阻塞粒度是 goroutine 而不是线程。

这就引出了 GMP:Go 需要一套调度系统来管理这些 goroutine,让它们高效地跑在操作系统线程上。这套系统就是 GMP。


一、为什么要关心 GMP

go func(){}() 写起来很爽,一个 goroutine 就跑起来了。初始栈 2KB,开百万个也不心疼。

但你有没有想过:操作系统根本不认识 goroutine,它只认线程。那谁在背后做调度?一个 goroutine 阻塞了,为什么不会把其他 goroutine 也拖死?

答案是 GMP。Go runtime 里有一套调度系统,专门干这事。


二、三个角色:G、M、P

GMP 就是三个东西:

组件 是什么 干什么
G (Goroutine) goroutine 本身 跑用户代码,有自己的栈和状态
M (Machine) 操作系统线程 真正干活的,在 g0 和 g 之间来回切换
P (Processor) 调度器 手里攥着一个 g 队列,决定下一个跑谁

简单说:M 是干活的人,P 是工头,G 是活。M 必须从 P 手里领活干,P 手里的活就是一堆 G。

g 的状态

一个 g 从创建到销毁,会经历这些状态:

复制代码
_Gidle     (0)  刚分配,还没初始化
_Grunnable (1)  就绪,在队列里等调度
_Grunning  (2)  正在某个 M 上跑
_Gsyscall  (3)  正在执行系统调用
_Gwaiting  (4)  被阻塞了,等某个条件满足,通常是用户态阻塞,比如mutex和channel导致的阻塞
_Gdead     (6)  跑完了,等着被回收复用

注意 _Gdead 不是真死------g 的对象还在,只是用户代码跑完了。下次有新的 go func() 时,runtime 可能直接复用这个 g 对象,省掉分配和初始化的开销。

状态流转路径:

复制代码
newproc() → _Gidle → _Grunnable → execute() → _Grunning
                                                │
              ┌─────────────────────────────────┤
              │                                 │
         gopark()                          goexit1()
         (阻塞让渡)                         (正常结束)
              │                                 │
              ▼                                 ▼
        _Gwaiting                         _Gdead → 回收复用
              │
         goready()
              │
              ▼
        _Grunnable(回到就绪队列)

        _Grunning 也可以走 reentersyscall() → _Gsyscall
                                              │
                                         exitsyscall()
                                              │
                                              ▼
                                         _Grunnable

g 存在哪

每个 P 有一个本地队列 lrq,容量 256,用 CAS 无锁访问。另外还有一个全局队列 grq,访问要加锁。

复制代码
全局 schedt
┌──────────────────────────┐
│   grq(全局队列,加锁)    │
└──────────────────────────┘
      │       │       │
      ▼       ▼       ▼
    P₀       P₁       P₂
   [lrq]    [lrq]    [lrq]
   256槽    256槽    256槽
   CAS无锁  CAS无锁  CAS无锁

放 g 的逻辑很简单:先往自己 P 的 lrq 塞,满了才扔到全局 grq。取 g 的逻辑稍微复杂点,按这个顺序来:

  1. 先从自己 P 的 lrq 取(无锁)
  2. 从全局 grq 取(加锁)
  3. 检查有没有 IO 就绪的 g(netpoll)
  4. 从别的 P 的 lrq 偷(工作窃取)

有个细节值得记一下:每 61 次调度,强制先查一次 grq。61 是质数,故意跟 lrq 的 256 容量错开周期,防止全局队列饿死。

空闲时怎么办

没活干的时候,P 和 M 不会傻转 CPU。P 被扔进 schedt.pidle,M 被扔进 schedt.midle 然后暂停。等有新 g 被创建时,wakep() 把它们叫醒。


三、调度:g0 怎么找到下一个 g

每个 M 里都有一个特殊的 g0。它跟普通 g 不一样------普通 g 跑用户代码,g0 跑 runtime 的调度代码。g0 是 M 的伴生 goroutine,跟 M 一对一绑定,不由用户创建。

g 和 g0 是怎么切换的

M 的工作循环就是:g0 找 g → 切到 g 执行 → g 跑完了/让出了/被抢了 → 切回 g0 → 再找。

复制代码
g0(调度)──gogo()──> g(跑用户代码)
   ↑                       │
   └── mcall()/systemstack ┘

切到 g 叫 gogo ,从 g 切回来叫 mcallsystemstack。这三个是 runtime 里最底层的切换原语:

方法 方向 谁调用 干什么
gogo(buf) g0 → g g0 调 恢复 g 的寄存器和栈指针,跳过去执行
mcall(fn) g → g0 g 调 保存 g 的上下文,切到 g0 执行 fn
systemstack(fn) g → g0 → g g 调 临时切到 g0 跑 fn,跑完自动切回 g

mcallsystemstack 的区别:

  • mcall(fn):切到 g0 执行 fn,不会自动切回来 。fn 里通常会调 schedule() 去找新的 g,原来的 g 可能被挂起了或者结束了。
  • systemstack(fn):切到 g0 执行 fn,fn 跑完自动切回原来的 g。适合做一些需要 g0 权限但不想放弃当前 g 的事情,比如创建新 goroutine。

一行 go func 背后发生了什么

go 复制代码
go func() { /* 你的代码 */ }()

展开来看:

  1. newproc() --- 创建 g 实例
  2. runqput() --- 塞进 lrq(满了就塞 grq)
  3. wakep() --- 叫醒空闲的 P/M
  4. schedule() --- g0 开始找活干
  5. findRunnable() --- 按 lrq → grq → netpoll → steal 的顺序翻
  6. execute() --- 把 M 和 g 绑上,状态 runnable → running
  7. gogo() --- 切换执行权,从 g0 跳到 g

其中 newproc 这一步有个细节:它是在当前 g 里调用的,但通过 systemstack 临时切到 g0 去执行。因为创建 g 需要操作 runtime 内部数据结构,这些操作需要在 g0 上完成。

go 复制代码
func newproc(fn *funcval) {
    gp := getg()
    pc := getcallerpc()
    systemstack(func() {
        // 这里已经是 g0 在执行了
        newg := newproc1(fn, gp, pc)
        _p_ := getg().m.p.ptr()
        runqput(_p_, newg, true)
        if mainStarted {
            wakep()
        }
    })
    // systemstack 结束,自动切回原来的 g
}

工作窃取

自己 P 的队列空了怎么办?去偷别人的。

go 复制代码
const stealTries = 4

for i := 0; i < stealTries; i++ {
    for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
        p2 := allp[enum.position()]
        if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
            return gp, inheritTime, false
        }
    }
}

起点随机,步长随机,避免一堆 P 同时去偷同一个目标。而且一次偷半队,不是偷一个------减少下次偷的频率。


四、让渡:g 主动让出 CPU

让渡就是 g 自己说"我不跑了",把执行权还给 g0。有三种情况。

跑完了

g 执行结束,自然退出:

复制代码
g → goexit1() → mcall(goexit0) → g0
g0:状态 running → dead,清空 g,扔进 gfree 复用队列,调用 schedule()

主动让

runtime.Gosched(),相当于说"我先歇会儿,让别人跑":

复制代码
g → Gosched() → mcall(gosched_m) → g0
g0:状态 running → runnable,扔进 grq,调用 schedule()

被阻塞了

mutex 加不上锁、channel 读不到数据,g 被卡住了:

复制代码
g → gopark() → mcall(park_m) → g0
g0:状态 running → waiting,解绑 M,调用 schedule()

条件满足 → goready() → ready() → 状态 waiting → runnable,扔进 lrq/grq

注意一个区别:阻塞让渡后,g 不会 进就绪队列。它挂在某个 mutex 或 channel 的等待队列里,等条件满足了再由别人通过 goready 捞回来。


五、抢占:sysmon 出手

让渡是 g 自己让,抢占是别人来抢。

Go 启动时会创建一个全局唯一的 sysmon 线程,它不跑业务代码,只做三件事:

  • 唤醒 IO 就绪的 g(netpoll)
  • 抢占跑太久的 g(retake)
  • 触发 GC

sysmon 大约每 10ms 轮询一次。

系统调用抢占

M 去做系统调用了,整个线程被内核占着,它手上 P 的队列里可能还有一堆 g 在等。

Go 的做法是:syscall 时把 P 和 M 解绑,P 记到 M 的 oldp 里。等 syscall 结束,先试试 oldp 还空着没,空着就直接复用;不行就找一个空闲 P;再不行就把 g 扔进 grq,M 暂停。

sysmon 发现某个 P 处于 syscall 状态,而且满足下面任一条件就动手:

  • P 的 lrq 里有 g 在等
  • syscall 已经超过 10ms

动手的方式是 handoffp:分配一个新 M 去接管这个 P。

运行超时抢占

某个 g 一口气跑了超过 10ms,sysmon 也会出手。这有两种方式。

协作式 :给 g 打个标记(stackguard0 = stackPreempt),等它下次栈扩张时自己发现,主动让出。

问题很明显:g 要是不触发栈扩张(比如死循环纯计算),就永远看不到这个标记。

非协作式(Go 1.14+) :直接发信号。sysmon 通过 pthread_kill 给目标 M 发 sigPreempt,信号处理函数篡改 g 的 PC 寄存器,把 asyncPreempt 函数插进去。g 恢复执行时,跑的已经是被偷梁换柱的代码了,最终走到 mcall(gopreempt_m) 把执行权交出来。

说白了就是:你不肯让,我就在你不知情的情况下把你的指令改了。


六、一些设计上的取舍

无锁优先。本地队列操作用 CAS,全局队列才加锁。热路径上能不锁就不锁,冷路径上才舍得锁。

全栈适配。Go 的并发优势不只是 goroutine 轻量。内存分配器给每个 P 准备了 mcache,mutex 和 channel 的阻塞粒度是 g 不是线程,网络 IO 用 netpoll 把 epoll 也控制在 g 粒度。整套运行时都是围着 GMP 设计的。

这跟 C++ 协程不一样。C++ 标准库的 std::mutex 阻塞的是线程,一个协程卡住了,同线程上的其他协程全跟着卡。要解决这个问题,得把所有并发原语都包一层协程感知的版本------工程量很大。

公平性。61 次调度强制查 grq 是防饥饿,随机化工作窃取是防热点,协作式+非协作式双保险是防抢占失效。

按需伸缩。P 和 M 空闲了就挂起来,有新活了再唤醒。不会空转 CPU。


附:G 的完整状态流转

复制代码
        newproc()
           │
           ▼
     _Gidle (0)  刚分配,还没初始化
           │
           ▼
   _Grunnable (1)  就绪,等调度
           │
      execute()
           │
           ▼
   _Grunning (2)  正在跑
           │
           ├──── gopark() ──────> _Gwaiting (4)  等条件
           │                           │
           │                      goready()
           │                           │
           │                           ▼
           │                    _Grunnable (1)  回到就绪
           │
           ├──── goexit1() ────> _Gdead (6)  跑完了,回收复用
           │
           └──── reentersyscall() ──> _Gsyscall (3)  系统调用
                                          │
                                     exitsyscall()
                                          │
                                          ▼
                                   _Grunnable (1)

写在最后

GMP 调度器的核心思路不复杂:用尽可能少的锁,把 g 均匀地分给 M 去跑。具体实现上有不少精巧的细节,但大方向就这一个。

理解 GMP 不是为了面试。写高并发代码的时候,知道 channel 阻塞时底层在做什么,知道为什么你的 goroutine 没有被及时抢占,排查问题时心里有底。

相关推荐
say_fall1 小时前
Linux进程核心概念:命令行参数与环境变量深度解析
linux·运维·服务器·ubuntu
轮子飞了1 小时前
基于 Spring AI + Milvus 的 RAG 混合检索实战
java
risc1234561 小时前
【Lucene】理解不是看见光,而是让眼睛适应黑暗
java·开发语言
Peace1 小时前
【Zabbix】
linux·运维·zabbix
小谢小哥1 小时前
62-Maven核心详解
java·后端·架构
方也_arkling1 小时前
【Java-Day16】API篇-Math类/System类/Object类/包装类
java·开发语言
x***r1511 小时前
burpsuite-1.4.07.jar 使用步骤详解(附Java环境配置与Burp Suite抓包教程)
java·开发语言·jar
Cosmoshhhyyy1 小时前
《Effective Java》解读第54条:返回零长度的数组或者集合,而不是null
java·开发语言·python
jsl_jsl_jsl1 小时前
☕ Java 高并发进阶(二):无锁并发与数据隔离——CAS、Unsafe 与 ThreadLocal 深度内核解密
java