土法炼钢兴趣小组的算法知识备份

Go 调度器深度拆解:goroutine 到底对 CPU 做了什么

目录

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 个状态,关键的转换路径:

goroutine 状态机

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) 时间。

GMP 架构与 runqueue 流转

三、调度循环 – 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() 中:

  1. 随机选一个 victim P(避免总偷同一个,减少竞争)
  2. 偷走 victim 本地 runqueue 的一半 G
  3. 用 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):

  1. sysmon(系统监控线程)定期检查:是否有 goroutine 运行超过 10ms
  2. 如果是,向目标 M 发送 SIGURG 信号
  3. M 的信号处理器 sighandler 在当前 goroutine 栈上注入一个异步抢占点
  4. 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() 一个文件):

  1. G 调用 runtime.entersyscall()
  2. P 与当前 M 解绑 – 这是关键:P 不能跟着 M 一起阻塞,否则 P 持有的 runqueue 里的其他 G 都会饿死
  3. 被释放的 P 立即寻找一个空闲 M(或创建新 M)继续调度其他 G
  4. Syscall 返回 -> runtime.exitsyscall() -> G 尝试重新绑定 P
  5. 如果原来的 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 设为非阻塞模式

  1. goroutine 调用 net.Conn.Read()
  2. 底层尝试 read() syscall
  3. 如果返回 EAGAIN(没有数据)-> G 不阻塞 M
  4. G 被挂到 netpoller 的 pollDesc 结构上,状态变为 _Gwaiting
  5. M 继续调度其他 G(P 不解绑)
  6. runtime 在 findRunnable() 慢路径中调用 netpoll()(底层是 epoll_wait()
  7. 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 – 两种哲学

CFS vs GMP 调度哲学
维度 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 团队在三个层面做出取舍的结果:

理解调度器不是为了手动调优(大多数应用不需要)。理解它是为了在 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),可以读并发模型架构


By .