"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 年提出的设计文档中痛陈):
-
全局锁竞争(Lock Contention):每个 M 想要拿任务,都得加一把全局大锁。
-
缓存局部性差:G 在不同的 M 之间跑来跑去,无法利用 CPU 本地缓存。
-
内存分配效率低: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 不会休息,它会变成"小偷"。
-
先去全局队列看看有没有 G(需要加锁,频率低)。
-
如果全局队列也空了,它会随机挑选另一个 P,从它的本地队列里偷走一半的 G(无锁/CAS)。
策略 B:Handoff (系统调用接力) ------ 避免阻塞
当 G 执行了一个阻塞系统调用(Syscall,如读取文件)时:
-
分离:P 发现 M 要去内核里睡大觉了,P 会立即把 M 踹开(解绑)。
-
接力:P 寻找(或新建)一个新的 M 来绑定自己,继续执行队列里的其他 G。
-
归队:当旧的 M 从系统调用醒来时,它会尝试获取一个空闲的 P。如果拿不到,就把自己的 G 放入全局队列,自己去休眠。
策略 C:Spinning Threads (自旋线程) ------ 空间换时间
如果 M 没活干了,它不会立刻销毁或深度休眠(挂起线程成本高),而是会**自旋(Spinning)**一会儿。
-
自旋:就是跑一个空循环,不断检查有没有新的 G 产生。
-
目的 :虽然浪费了一点 CPU,但如果此时有新 G 创建,M 可以极速响应,避免了线程唤醒的延迟。
策略 D:Network Poller (网络轮询器) ------ IO 多路复用
Go 处理网络请求(如 HTTP)时,不会阻塞 M。
-
当 G 发起网络请求(如 Read),G 会被移动到 Netpoller 中等待。
-
M 继续执行 P 里的其他 G。
-
当网络数据到达,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。
-
公平:全局队列轮询, 抢占式调度
-