go func() 是 Go
最常用的关键字之一。你可以同时跑 100 万个
goroutine,每个只占 2KB 栈。大多数 Go 程序员知道”goroutine
很轻量”,但不知道为什么轻量——答案不是魔法,而是 Go
runtime 内部大约 15000 行 C 和 Go
混合代码实现的用户态调度器。
这个调度器的核心模型叫 GMP:G(goroutine)、M(machine,即 OS 线程)、P(processor,逻辑 CPU 资源)。一句话总结它们的关系:goroutine 不是线程,P 不是 CPU,M 才是真正消耗内核资源的东西。 G 是你写的代码,P 是调度资源的令牌,M 是执行代码的载体。三者的组合方式决定了 Go 并发模型的全部特性。
本文基于 Go 1.22
源码(src/runtime/),不是翻译官方文档,而是从源码里挖出每一个设计取舍,量化每一步开销,最后与
Linux CFS 调度器正面对比。
一、为什么需要自己的调度器
问题从 C10K 开始。
传统的 one-thread-per-connection
模型有一个简单的算术问题:Linux 默认线程栈 8MB,10000
个连接需要 80GB 内存——还没算线程创建的 clone()
系统调用开销和上下文切换的代价。
Linux 内核的 CFS 调度器是为通用场景设计的:它追求公平性——每个进程按权重分到 CPU 时间,用红黑树维护 vruntime(虚拟运行时间),调度一次需要从树中取最小 vruntime 的节点。这对几百个进程很好用,但对百万级并发任务来说,每次调度的 O(log n) 查找 + 内核态切换就成了瓶颈。
Go 的选择是在用户态实现 M:N 调度:M 个 goroutine 映射到 N 个 OS 线程上,绕开内核调度。代价是 Go runtime 自己承担调度逻辑的全部复杂性。
这个选择的效果可以量化:
| 维度 | OS 线程 (Linux CFS) | Goroutine (Go GMP) |
|---|---|---|
| 创建成本 | clone() 系统调用,
~10us |
用户态分配, ~0.3us |
| 初始栈 | 默认 8MB, 固定 | 2KB, 按需动态增长到 1GB |
| 上下文切换 | ~1-5us (陷入内核) | ~200ns (纯用户态) |
| 调度算法 | vruntime 红黑树, O(log n) | FIFO 环形队列, O(1) |
| 内存占用 / 万任务 | ~80GB | ~20MB |
33 倍的创建速度差异、5-25 倍的切换速度差异、4000 倍的内存差异——这就是 Go 能轻松跑百万 goroutine 的原因。不是 goroutine 有什么神奇属性,而是它绕开了内核调度的所有重量级操作。
二、GMP 三角关系
Go 调度器的三个核心结构体定义在
src/runtime/runtime2.go 中。
G – goroutine
type g struct {
stack stack // 栈内存 [lo, hi)
stackguard0 uintptr // 用于栈增长检查
m *m // 当前执行该 G 的 M(nil 表示不在运行)
sched gobuf // 调度上下文(SP, PC, 寄存器快照)
atomicstatus atomic.Uint32 // 状态机
goid uint64 // goroutine ID
// ...
}每个 goroutine 的核心就是一个栈和一组寄存器快照。切换
goroutine 时,runtime 保存当前 G 的
sched(SP、PC 等),加载目标 G 的
sched——整个过程在用户态完成,不需要系统调用。
goroutine 有 7 个状态,关键的转换路径:
_Gidle->_Grunnable:go func()创建新 G,编译器将其转换为runtime.newproc()_Grunnable->_Grunning:被schedule()选中,绑定到 M 上执行_Grunning->_Gwaiting:遇到 channel 操作、mutex、timer 等,主动让出_Grunning->_Gsyscall:进入系统调用_Gwaiting->_Grunnable:等待条件满足,被唤醒放回 runqueue_Grunning->_Gdead:func()执行完毕,G 回收到 freelist
M – machine (OS 线程)
type m struct {
g0 *g // 调度专用栈(不运行用户代码)
curg *g // 当前运行的用户 G
p puintptr // 绑定的 P(nil 表示未绑定)
nextp puintptr
spinning bool // 是否在 spinning 状态(找 G 中)
// ...
}M 是 OS 线程的 1:1 映射。m.g0 是一个特殊
goroutine,它不运行用户代码,专门执行调度逻辑(schedule()、GC
等)。当 M 需要调度下一个 G 时,它先切换到 g0
栈,执行调度决策,再切换到选中的 G 栈。
M 的数量不等于 GOMAXPROCS。阻塞 syscall 会导致 M 与 P 解绑,runtime 创建新 M 来服务该 P。因此 M 的数量可以远超 P(默认上限 10000)。
P – processor (逻辑处理器)
type p struct {
status uint32
m muintptr // 绑定的 M
runqhead uint32 // 本地 runqueue 头
runqtail uint32 // 本地 runqueue 尾
runq [256]guintptr // 本地 runqueue(256 槽位环形队列)
runnext guintptr // 下一个优先运行的 G
// ...
}P 是 Go 调度器最精妙的设计。它不是
CPU,而是调度资源的令牌。每个 P 持有一个
256 槽位的本地 runqueue,M 必须绑定一个 P 才能执行
goroutine。P 的数量由 GOMAXPROCS 决定(默认等于
CPU 核心数)。
为什么需要 P?如果只有 G 和 M,每个 M 直接从全局 runqueue 取 G,需要全局锁。P 的引入把全局竞争变成了每个 P 一个本地 runqueue——M 从自己绑定的 P 的本地 runqueue 取 G,无锁操作,O(1) 时间。
三、调度循环 – schedule() 的内部
调度器的核心函数是
runtime.schedule()(src/runtime/proc.go),它在
m.g0 栈上执行,负责选出下一个要运行的
G。核心逻辑可以简化为 6 步:
schedule() {
// 1. 每 61 次调度,从全局 runqueue 取一个 G(防止全局 G 饿死)
if schedtick%61 == 0 {
g = globrunqget(pp, 1)
}
// 2. 检查 P.runnext(上一轮刚放进去的高优先 G)
if g == nil { g = pp.runnext }
// 3. 从本地 runqueue 取(FIFO,O(1))
if g == nil { g = runqget(pp) }
// 4. 以上都空了,进入 findRunnable()——慢路径
if g == nil { g = findRunnable() } // 会阻塞直到找到 G
// 5. 找到了,执行 G
execute(g)
}
快路径(步骤 1-3)全部是无锁操作,在纳秒级完成。慢路径
findRunnable() 是调度器最复杂的部分:
findRunnable() {
1. 再查一次本地 runqueue(可能刚有 G 放进来)
2. 查全局 runqueue(加锁,偷一半 G 到本地)
3. 检查 netpoller(调用 netpoll(0) 非阻塞)
4. 从其他 P 偷 G(work stealing)
5. 再查一次全局 runqueue 和 netpoller(阻塞式)
6. 都没有 → M 进入 spinning → 最终 park(休眠)
}
Work Stealing
当一个 P 的本地 runqueue
空了,它不是傻等,而是去偷其他 P 的
G。算法在 runtime.runqsteal() 中:
- 随机选一个 victim P(避免总偷同一个,减少竞争)
- 偷走 victim 本地 runqueue 的一半 G
- 用 CAS 操作完成,lock-free
为什么偷一半?如果只偷一个,偷的 overhead 相对太高(随机选择 + CAS);偷一半可以在一次操作中均衡两个 P 的负载。这个策略直接来自学术论文(Blumofe & Leiserson, 1999),Go 的实现忠实遵循了理论最优解。
“每 61 次从全局取一个”这个魔法数字也值得说明:61 是质数,目的是打散全局 runqueue 的访问模式,避免多个 P 同步轮询全局队列造成锁竞争。
四、抢占 – 从协作到异步
Go 1.0 - 1.13:纯协作式抢占
早期 Go 调度器完全依赖 goroutine 主动让出
CPU。编译器在每个函数入口插入一段栈增长检查代码(morestack),如果
runtime 标记了当前 G
需要被抢占(g.stackguard0 = stackPreempt),栈检查会触发调度。
这意味着:如果一个 goroutine 不调用任何函数,它永远不会被抢占。
go func() {
for {
// 没有函数调用 = 没有抢占点
// 这个 goroutine 会永远霸占一个 P
}
}()这是 Go 社区长期抱怨的经典问题:一个 for {}
死循环可以卡死整个 P,导致其他 goroutine 饿死。Go team 在
issue #10958 中跟踪了多年。
Go 1.14+:异步抢占(基于信号)
Go 1.14
引入了基于信号的异步抢占机制(src/runtime/signal_unix.go):
- sysmon(系统监控线程)定期检查:是否有 goroutine 运行超过 10ms
- 如果是,向目标 M 发送
SIGURG信号 - M 的信号处理器
sighandler在当前 goroutine 栈上注入一个异步抢占点 - G 在下一条安全指令处(safe point)检查到抢占标记,主动让出
为什么选 SIGURG?两个原因: - 它是 POSIX 标准信号,可移植 - 几乎没有应用程序使用它(不像 SIGUSR1/SIGUSR2 经常被用户代码占用)
对比 Linux CFS 的抢占:内核通过硬件 timer
interrupt(HZ=1000,即每 1ms 一次)触发
scheduler_tick(),检查
need_resched 标志,强制调用
schedule()。这是硬件级别的抢占,精确但昂贵(每次都要陷入内核)。
Go 的 SIGURG 方案更轻量(信号处理在用户态),但粒度更粗:sysmon 的检查周期最短 20us,加上信号投递延迟,实际抢占延迟在微秒到毫秒级别。对于绝大多数 Go 应用来说够用了,但如果你需要亚微秒级的调度保证,Go 不是正确的选择。
五、Syscall 与 Netpoller – I/O 的真相
goroutine 遇到 I/O 时,调度器的行为取决于 I/O 类型。
阻塞 syscall(文件 I/O、CGO)
当 goroutine 进入阻塞 syscall(比如 read()
一个文件):
- G 调用
runtime.entersyscall() - P 与当前 M 解绑 – 这是关键:P 不能跟着 M 一起阻塞,否则 P 持有的 runqueue 里的其他 G 都会饿死
- 被释放的 P 立即寻找一个空闲 M(或创建新 M)继续调度其他 G
- Syscall 返回 ->
runtime.exitsyscall()-> G 尝试重新绑定 P - 如果原来的 P 还空闲,绑回去;否则放入全局 runqueue
这就解释了一个在 Go 集成
io_uring 时发现的问题:每次 CGO 调用
io_uring_wait_cqe() 都会触发 M 与 P
解绑,runtime 不断创建新 M,导致 M
膨胀。这不是 io_uring 的问题,是 Go 调度器处理阻塞
syscall 的固有机制。
网络 I/O(netpoller)
网络 I/O 走完全不同的路径。Go 的 net
包把所有 socket 设为非阻塞模式:
- goroutine 调用
net.Conn.Read() - 底层尝试
read()syscall - 如果返回
EAGAIN(没有数据)-> G 不阻塞 M - G 被挂到 netpoller 的
pollDesc结构上,状态变为_Gwaiting - M 继续调度其他 G(P 不解绑)
- runtime 在
findRunnable()慢路径中调用netpoll()(底层是epoll_wait()) epoll_wait返回就绪的 fd -> 对应的 G 被唤醒,放回 runqueue
这就是 goroutine “看起来同步,跑起来异步” 的秘密。程序员写的是阻塞风格的代码:
data, err := conn.Read(buf) // 看起来是同步等待但 runtime 在背后把 I/O 等待变成了 goroutine 切换 + epoll 轮询。M 从不因为网络 I/O 而阻塞,P 也不会解绑。这就是 Go 处理网络并发如此高效的根本原因。
对比传统方案:C/C++ 程序员需要手动写 epoll 事件循环、管理状态机、处理半包——Go 把这一切藏在了 runtime 里。你写同步代码,runtime 替你做异步。代价是你失去了对 I/O 调度细节的控制权。
六、对比 Linux CFS – 两种哲学
| 维度 | Linux CFS | Go GMP |
|---|---|---|
| 设计目标 | 通用公平性 | 高吞吐并发 |
| 调度算法 | vruntime 红黑树, O(log n) | FIFO + work stealing, O(1) |
| 公平性 | 严格按权重分配 CPU 时间 | 尽力而为(无优先级,先到先服务) |
| 优先级 | nice [-20, 19], 映射到权重 | 没有。所有 goroutine 平等 |
| 实时调度 | SCHED_FIFO / SCHED_RR | 不支持 |
| 抢占 | 硬件 timer interrupt, ~1ms 粒度 | SIGURG 信号, ~10ms 粒度 |
| I/O 处理 | 进程休眠, 内核唤醒 | netpoller (epoll) + G 切换 |
| 切换开销 | ~1-5us (陷入内核) | ~200ns (用户态) |
| 适用规模 | 数百~数千进程 | 数十万~百万 goroutine |
核心差异在设计哲学:CFS 追求公平,GMP 追求吞吐。
CFS 的 vruntime 机制保证每个进程都能按权重分到 CPU 时间。如果一个进程用了太多 CPU,它的 vruntime 增长更快,下次调度时会被排到红黑树的后面。这对桌面系统很重要(你不希望一个编译任务卡住你的浏览器),但对 Web 服务器来说是多余的开销——100 万个 HTTP handler goroutine 不需要公平调度,它们需要的是尽快被执行完。
Go 为此做了一个激进的取舍:所有 goroutine 平等,没有优先级。 没有 nice value,没有实时调度类,没有任何方式让一个 goroutine 比另一个”更重要”。如果你需要优先级,你得在应用层自己实现(比如用带优先级的 channel)。
这意味着 Go 的调度器比 CFS 简单得多——FIFO 队列 + work stealing vs 加权 vruntime 红黑树。简单意味着快:O(1) 出队 vs O(log n) 查找,用户态切换 vs 内核态切换。
但简单也意味着放弃了控制。在 CFS
中,你可以用 nice 命令调整进程优先级,用
cgroup 限制 CPU 使用。在 Go 中,你只有
GOMAXPROCS 这一个旋钮。runtime
替你做了几乎所有调度决策——大多数时候这是对的,但当调度行为不符合预期时(goroutine
泄漏、GC assist 抢占过多、tail latency
尖刺),你能做的事情非常有限。
结语
goroutine 的”轻量”不是天上掉下来的。它是 Go runtime 团队在三个层面做出取舍的结果:
- 用户态调度 –
快,但调试困难(
pprof、trace是你的朋友,不是strace) - 协作+异步抢占 – 简单,但粒度不如内核(不适合硬实时)
- 无优先级 – 公平,但无法做差异化调度(重要请求和普通请求一视同仁)
理解调度器不是为了手动调优(大多数应用不需要)。理解它是为了在
goroutine 行为异常时知道该往哪里看:goroutine 泄漏看
runtime.NumGoroutine(),调度延迟看
runtime/trace,M 膨胀看
runtime.NumCGOCall(),GC 抢占看
GODEBUG=gctrace=1。
Go 调度器的设计哲学是:“简单规则 + 极低开销 + runtime 替你做决策。” 对绝大多数 Go 应用来说,这是正确的选择。但如果你发现自己在跟调度器较劲——也许该退一步想想,是不是选错了语言。
如果你想了解 C10K/C10M 问题的完整背景以及为什么 M:N 调度成为主流方案,可以读从 C10K 到 C10M。
如果你想看 Linux CFS 调度器的内部实现(vruntime、权重计算、调度类层次),可以读Linux 进程调度。
如果你想了解 Go GC 如何与调度器协作(Mark Assist、写屏障、STW 协调),可以读 Go 垃圾回收深度解析。
如果你在 Go 中使用 io_uring 遇到了 M 膨胀问题,Go 如何集成 io_uring 详细分析了调度器冲突的根因。
如果你想看 goroutine 在实际工程中的使用模式(worker pool、Job queue),可以读Go 线程池模式。
如果你对不同语言的并发模型感兴趣(One Loop Per Thread vs Reactor+Worker Pool vs M:N),可以读并发模型架构。