Go 协程调度器:G-M-P 模型与调度策略
前两篇分别讲了 Channel 的底层(hchan 数据结构)和内存模型(happens-before 规则)。但还有一个更基础的问题没回答:goroutine 为什么这么轻量?调度器是怎么管理数万个 goroutine 的? 这篇深入 Go 调度器的 G-M-P 模型,结合 slg-go 项目的真实并发场景分析。
背景:slg-go 里的 Goroutine 开销
在 slg-go 项目中,单个 Logic Node 进程的 goroutine 分布大致如下:
1 | // cmd/gate/main.go — Gateway 进程启动时的 goroutine |
来源:cmd/gate/main.go, cmd/chat/ws.go, internal/battle/scheduler/scheduler.go
估算一下总 goroutine 数量:
| 来源 | 数量 | 说明 |
|---|---|---|
| HTTP 请求处理 | ~1000 | 取决于并发连接数 |
| WebSocket 连接 | ~20,000 | 每连接 2G = 读+写 |
| 战斗 Worker | 4 | 固定 worker pool |
| 后台任务 | ~10 | 心跳、metrics、etcd 保活等 |
| 合计 | ~21,000 | 单进程 |
21,000 个 goroutine,总内存约 40-50MB(每个 G 约 2KB 初始栈)。如果用 Java Thread 呢?默认栈 1MB/线程 → 21GB 内存。这就是为什么 Go 能做到百万在线而 Java 不行。
Q1:G-M-P 三者到底是什么?
Go 调度器的核心数据结构:
flowchart LR
subgraph G["G (Goroutine)"]
G1["G1<br/>用户代码"]
G2["G2<br/>用户代码"]
G3["G3<br/>用户代码"]
GN["..."]
end
subgraph M["M (Machine / OS Thread)"]
M1["M1:OS线程<br/>绑定P1"]
M2["M2:OS线程<br/>绑定P2"]
MN["..."]
end
subgraph P["P (Processor)"]
P1["P1<br/>本地运行队列<br/>[G5,G6,G7]"]
P2["P2<br/>本地运行队列<br/>[G8,G9]"]
PN["..."]
end
G1 --> P1
G2 --> P1
G3 --> P2
P1 --> M1
P2 --> M2
G — Goroutine(协程)
1 | type g struct { |
关键特性:
- 初始栈只有 2KB(vs Java Thread 的 1MB),按需增长(最大可达 1GB)
- 创建成本极低(只需分配
g结构体 + 2KB 栈),所以可以随便go func() - 用户态切换:不经过内核,切换开销约 几十纳秒(vs 线程切换的微秒级)
M — Machine(操作系统线程)
1 | type m struct { |
关键特性:
- M 是真正的 OS 线程(由 OS 调度)
- 默认数量 =
GOMAXPROCS(通常等于 CPU 核心数),但阻塞时会创建额外 M - 每个 M 有一个特殊的
g0——调度专用的 goroutine,拥有更大的固定栈(64KB)
P — Processor(逻辑处理器)
1 | type p struct { |
关键特性:
- 数量固定 =
GOMAXPROCS(默认 CPU 核心数) - P 才是真正持有运行队列的实体
- P 的本地队列大小只有 256,满了就放全局队列
三者的关系
1 | P 是核心:M 必须绑定 P 才能执行 G。 |
Q2:Goroutine 的状态转换
理解状态转换是理解调度行为的关键:
stateDiagram-v2
[*] --> _Gidle : 创建
_Gidle --> _Grunnable : go func()
_Grunnable --> _Grunning : P.runq 取出
_Grunning --> _Grunnable : 时间片用完 / 抢占
_Grunning --> _Gwaiting : channel收发/锁/sleep
_Gwaiting --> _Grunnable : 条件满足/Goready
_Grunning --> _Gdead : 函数返回
_Grunning --> _Gcopystack : 栈增长
_Gcopystack --> _Grunning : 栈扩容完成
note right of _Gwaiting : "Gopark: 挂起当前G"
note left of _Grunnable : "可运行队列等待"
从 slg-go 代码看状态转换
场景一:Worker 正常取任务
1 | // scheduler.go:337-356 |
当 s.requests 为空且没有其他 case 就绪时:
- 当前 G 从
_Grunning→_Gwaiting(调用Gopark) - G 被放入
requestschannel 的recvq - M 解绑 P 去找其他 G 运行
- 新任务到达时,G 被 Goready →
_Grunnable - 某个 P 取到它 →
_Grunning
整个过程对开发者完全透明。
场景二:WebSocket 双 goroutine 协作
1 | // ws.go:29-146 — 完整的状态流转 |
来源:cmd/chat/ws.go:29-146
这里两个 goroutine 通过 ctx 和 channel 协作生命周期:
- 主 G 负责读,阻塞在网络 I/O 上
- G-write 负责写,阻塞在
select上 - 任一方出错都通过
cancel()通知另一方退出
Q3:调度策略——Work Stealing 与 Hand Off
Go 调度器有两个核心调度策略:
Work Stealing(工作窃取)
当 P 的本地队列空了:
1 | P1 的 runq: [] ← 空了! |
为什么从尾部偷? 因为尾部的 G 更可能是最近才创建的,缓存热度更低。
Hand Off(移交)
当 M 因系统调用阻塞(如网络 I/O)时:
1 | M1 绑定 P1, 执行 G1 |
这就是为什么 slg-go 的 WebSocket handler 中,即使主 goroutine 阻塞在 websocket.JSON.Receive() 上,同一个 P 上的其他 goroutine 也不会受影响——P 会被移交给别的 M。
Q4:抢占式调度——防止长运行 G 饿死其他 G
Go 1.14 之后引入了基于信号的异步抢占。在此之前只有协作式抢占(函数调用时的检查点)。
抢占机制
1 | sysmon 线程(特殊 M,不绑定 P)每 10µs 检查一次: |
这对战斗引擎有什么影响?
1 | // engine.go:127-189 — Execute 方法 |
来源:engine.go:127-189
如果一场战斗涉及大量兵种和 200 轮计算,单次 Execute() 可能耗时数十毫秒。此时抢占机制会确保这个 G 不会独占 M 太久——虽然对于纯计算密集型场景,更好的做法是把大计算拆分成多个步骤,主动让出:
1 | // 更好的做法:每轮检查 context,主动让步 |
TODO: engine.go 的
Execute()方法目前没有中间检查点,超长的战斗可能导致单个 worker 被长时间占用。考虑加入runtime.Gosched()或轮间 context 检查。
Q5:Goroutine 栈的增长机制
这是 Go 能支持大量 goroutine 的关键技术之一:
1 | 初始状态(2KB): |
增长规则:
- 初始 2KB
- 不足时翻倍(连续空间,copy-on-write)
- 最大 1GB(默认,可通过
debug.SetMaxStack修改) - 收缩:GC 时如果栈只用了一半以下,会缩回一半
这意味着你不需要像写 C 那样预先估算栈大小——Go 会自动管理。这也是为什么 go func() 可以放心大胆地用。
Q6:System Monitor(sysmon)—— 特殊的后台线程
Go 运行时有一个特殊的 M 叫 sysmon,它不绑定任何 P,直接运行在 OS 层面:
1 | // runtime/proc.go — sysmon 主循环(伪代码) |
sysmon 对 slg-go 这类服务器程序特别重要:
- 它保证了即使你的代码忘记让出 CPU,调度器仍然能正常工作
- 它处理了网络 I/O 完成后的 G 唤醒(epoll/kqueue 事件转交给 P)
- 它触发了定时 GC
Q7:从架构角度看调度设计
slg-go 的三层架构中,每一层的调度模式都不一样:
| 层级 | 并发模型 | 调度特点 |
|---|---|---|
| Gate(网关) | 1连接≈2G(读写分离) | 大量短生命周期 G,依赖网络 I/O 阻塞自动 hand off |
| Logic(逻辑) | 分片 + RWMutex | G 主要做内存操作和 DB 查询,CPU 密集型少 |
| Battle(战斗) | Fixed Worker Pool(N=4) | G 是常驻 worker,循环消费 channel 任务 |
flowchart TB
subgraph Gate ["Gate 层 — 高并发 I/O"]
G1["HTTP Handler G×N<br/>短生命周期"]
G2["WS Read G×N<br/>阻塞在 recv"]
G3["WS Write G×N<br/>阻塞在 select"]
end
subgraph Logic ["Logic 层 — 分片计算"]
L1["Shard 0<br/>RWMutex 保护"]
L2["Shard 1<br/>RWMutex 保护"]
L3["...")
end
subgraph Battle ["Battle 层 — 固定池"]
B1["Worker 0<br/>for-select 循环"]
B2["Worker 1<br/>for-select 循环"]
B3["Worker 2<br/>for-select 循环"]
B4["Worker 3<br/>for-select 循环"]
end
Gate -->|"chan *Request"| Battle
Battle -->|"resultChan"| Logic
Logic -->|"DB/Cache"| Gate
调度效率的关键洞察:
- Gate 层的 G 大部分时间处于
_Gwaiting(等网络 I/O),占用 M 极少 - Logic 层竞争集中在 RWMutex 上,持锁时间应尽量短
- Battle 层的 Worker G 几乎一直在跑(
_Grunning),是 CPU 密集型的,所以要控制 worker 数 ≤GOMAXPROCS
总结
Go 调度器的设计哲学可以用一句话概括:
用少量的 OS 线程(M=GOMAXPROCS)高效地运行大量的用户态协程(G=数万~数十万),通过 P 做中介实现 work stealing 和负载均衡。
理解 G-M-P 模型之后,你在写并发代码时会有几个直觉性的判断:
- goroutine 随便开——2KB 栈 + 用户态切换,成本低到几乎免费
- 不要共享内存,用 channel 通信——避免锁竞争导致的上下文切换
- CPU 密集型任务控制并发度——worker pool 大小 ≈ CPU 核心数
- I/O 密集型任务放开胆子开 goroutine——大部分时间都在
_Gwaiting,不抢 CPU - 长运行计算加 Gosched 或拆成小步骤——别饿死同 P 上的邻居
六篇下来,Go 并发的三个维度——通信原语(Channel)、内存安全(Happens-Before)、调度机制(G-M-P)——算是讲完了。接下来你想往哪个方向深入?是 Go 反射与泛型的实战用法,还是 Go 在分布式系统中的工程实践?