Go 的调度器凭什么敢让十万个 goroutine 狂飙?GMP 三件套,一次讲透

2026-10-30 5 0

你写了一个 for 循环往里扔十万个 goroutine,Go 一点都不怵,内存也没见涨多少,程序跑得飞快。这时候隔壁那位用 Java 写同样逻辑的同事脸都绿了——他说他开 100 个线程就已经内存报警了。

但真正的问题是:Go 凭什么敢这么干?

不是靠魔法,靠的是 Go 语言自己实现的一套调度器。这套东西叫 GMP,三个字母,三种角色,把「海量并发」这件事变成了流水线上的搬运工游戏。

这篇咱们把它拆开看,顺便聊聊几个让线上服务半夜报警的坑。


一、先说结论:goroutine 便宜,但「便宜」是有代价的

先纠正一个流传很广的误解:goroutine 不是不要钱,它只是把成本从「栈内存」换成了「调度开销」。

每个 goroutine 初始栈只有 2KB,而且是可以动态增长的。线程栈呢?默认 1MB 起,而且往往一次性分配到位。你开 100 个线程,光栈就是 100MB;开 10 万个 goroutine,初始栈加起来也就 200MB 不到。

这就是 Go 敢让你「随便开」底气所在。但反过来说:

goroutine 便宜,不代表 goroutine 泄漏不致命。一个泄漏的 goroutine 会带着它的栈、它引用的所有对象一起赖着不走。你以为你只是「忘了关一个 channel」,实际上你在给内存放血。

后面我会专门讲这个。现在先回到调度器本身。


二、G、M、P:一场精妙的三角关系

GMP 里的三个字母,对应三种实体:

  • G(goroutine):你要跑的活儿。相当于车间里的任务单。
  • M(machine):真正干活的 OS 线程。相当于工人。
  • P(processor):逻辑处理器,是 Go 运行时用来「圈地」的许可证。有 P 你才能调度 G,没有 P,M 只能干瞪眼。

为什么要单独搞个 P?

因为 Go 1.1 之前是 G-M 两层结构,M 直接和 G 绑定,多个 M 抢同一个全局队列,加锁。上下文切换的损耗全花在锁上。后来官方加了 P,把「本地任务队列」这一层给独立出来——每个 P 有一个自己的私有队列(local run queue),调度时优先从本地队列取,避免争抢。

关键的绑定规则是:一个 M 在同一时刻最多绑定一个 P。而 P 的数量由 GOMAXPROCS 决定,默认等于逻辑 CPU 核心数。

所以你可以这么理解:P 是工位,M 是工人,G 是任务单。工位数(P)决定并行度,工人数(M)可以多于工位(比如有 M 卡在系统调用里),但只有占了工位的工人才有资格从任务池里领活。


三、任务怎么流转:本地队列优先,然后「偷」

Go 调度器的取任务顺序大致是这样的:

  1. 当前 P 的私有队列(local runq)
  2. 其他 P 的私有队列——「work stealing」,去偷一半
  3. 全局队列(global runq)
  4. 网络轮询器(netpoll)——等 I/O 就绪的 G

「偷一半」这个策略很有意思:偷的时候只拿别人队列的一半,剩下一半留在原地。因为偷多了会导致两个 P 互相偷来偷去,抖动起来 CPU 全在内核态空转,得不偿失。

而 runnext 槽位的存在更是经典设计:新创建的 goroutine 或者刚从系统调用返回的 goroutine,会被塞进当前 P 的 runnext 槽。这个槽位优先级最高,且被拿走时会「顶」掉原来槽位里的那个任务——所以叫 runnext 而不是 queue[0]。

// 看看当前 P 的私有队列长什么样(gdb 里可以看 runtime.allp)
func dumpLocalQueue() {
    n := runtime.NumGoroutine()
    fmt.Println("goroutine 数:", n)
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}

全局队列不是垃圾场,它只承担两个用途:一是 go 语句在某些情况下(新 G 没有可用的 P、或者需要立即唤醒 M)会直接把 G 塞进全局队列;二是 go 语言的 runqput 判断出本地队列已经很长(超过 256 个)时,为了防止某个 P 的本地队列无限膨胀,会把一部分任务连同新任务一起扔进全局队列。

这就是所谓的「均衡策略」:本地快、全局兜底、netpoll 收尾。三级流水线,谁也不闲着。


四、抢占:Go 是怎么抢回 CPU 的

你写了个死循环:

for {
    // 空转
}

如果是 Go 1.13 之前,这玩意儿能把一个 P 占死,其他 goroutine 全饿着——Go 没有抢占机制,协作式调度,你不让出,别人就上不来。这在 1.13 之前是个著名的坑,社区里流传着各种「十行代码复现」的段子。

现在不一样了。Go 1.14 引入了异步抢占(async preemption),机制很朴素:运行时给每个 P 装一个定时器(sysmon 在检查),发现某个 M 跑的时间太长了,就在系统调用层面给它发信号,然后把它踢下线,让别的 M 接手它手里的 G。

同时保留了原有的协作式让出点:函数调用、channel 收发、锁竞争、内存分配触发 GC 都会检查 preempt 标志。

所以准确的说法是:Go 现在既有协作式抢占,也有异步抢占,异步抢占是兜底,防止有代码死不让步。

而 sysmon 是这一切的后台保镖。它是一个独立于所有 P 之外的 goroutine,绑定在系统线程上,做三件事:

  • 没有 P 空闲时,从全局队列偷任务
  • 抢占长时间运行的 G
  • 检查网络轮询器,把就绪的 G 唤醒

sysmon 的存在让 Go 的调度器在「P 全被占满」的情况下依然能自我救场。这也是为什么 runtime.LockOSThread 是个偏门的、慎用的 API——它会把 G 焊死在 M 上,而 M 是可以被 sysmon 抢走的。


五、坑一:GOMAXPROCS 和容器

这是近年最经典的生产事故之一。

Go 1.25 之前,runtime 通过 sched_getaffinity 来确定可用的 CPU 数量。但在容器环境里,如果 cgroup 的 CPU 限额(cpu.max)设置得比宿主机可见的核心数小,Go 看到的却是宿主机全部核心。

后果就是:一个限额只有 2 核的容器,Go 起了 64 个 P,调度器疯狂调度、疯狂 GC,而实际只有 2 核可用。结果是 CPU 被调度开销吃满,延迟飙升,你的 HPA 看着 CPU 指标一脸茫然。

Go 1.25 开始,runtime 会主动读取 cgroup 的 CPU 限额来确定默认 GOMAXPROCS,这个坑算是被官方堵上了。但在 1.25 之前的版本,你有两个选择:

// 方案一:显式设置,别让它瞎猜
runtime.GOMAXPROCS(2)

// 方案二:容器启动时直接给
// GOMAXPROCS=2 /app

// 方案三:用 automaxprocs 这类库自动读 cgroup
import _ "go.uber.org/automaxprocs"

另外注意:GOMAXPROCS 不是越大越好。它是并行度,不是并发度。你如果有 1000 个 goroutine 在等 I/O,那 P 的数量对它们没影响;如果你有 1000 个 goroutine 在死命算 CPU,那 P 越多,GC 的并发回收就越猛,内存占用会上去。一般让 P = 物理核心数就对了。


六、坑二:goroutine 泄漏

前面提过,这是 Go 服务最常见的慢性死亡方式。典型场景:

// 反面教材:没人接收,goroutine 永远卡在 ch <- v
func worker(ch chan int) {
    for v := range ch {
        process(v)
    }
}

func bad() {
    ch := make(chan int)
    go worker(ch)
    ch <- 1
    // 函数返回,ch 没人 close,worker 永远阻塞在这
}

更隐蔽的版本是用 channel 做信号量,只发不收:

func bad2(reqs chan *Request) {
    sem := make(chan struct{}, 10)
    for _, r := range reqs {
        sem <- struct{}{}        // 满了就永久阻塞
        go handle(r)
    }
}

排查泄漏其实不难,pprof 的 goroutine profile 加上一个「等了几秒再看一次」的差分就能定位:

import (
    "runtime/pprof"
    "time"
    "os"
)

func dumpGoroutines(tag string) {
    f, _ := os.Create("goroutine-" + tag + ".pprof")
    defer f.Close()
    pprof.Lookup("goroutine").WriteTo(f, 1)
    time.Sleep(10 * time.Second)
}

然后对比两次 dump,增长的 goroutine 栈顶就是泄漏点。context.Context 配合 cancel 是最常用的解药,几乎所有会阻塞的第三方库都支持 ctx.Done(),用起来能省掉 80% 的泄漏事故。


七、坑三:I/O 阻塞时,线程去哪了

一个容易误解的点:goroutine 发起阻塞 I/O 时,占着它的 M 会怎样?

答案是:M 会让出 P,然后去干别的。Go 运行时底层用的是 netpoll(epoll/kqueue 的封装),I/O 没就绪时,M 会把 G 挂起,释放 P 给别的 G 用;I/O 就绪后,netpoller 线程把 G 放回就绪队列,交给某个空闲 M。

这就是 Go 用少量线程扛住海量网络请求的秘诀:

阻塞的从来不是线程,是 goroutine。线程是稀缺的、贵的(栈大、切换重),goroutine 是廉价的、可堆的。所以设计上一律让 goroutine 去阻塞,线程永远保持可运行。

但有两个例外要小心:

  1. cgo 调用:M 进入 cgo 后不可被抢占,而且会占着 P(除非用了 runtime.LockOSThread 的特殊模式)。cgo 调用多了,调度器会被拖垮。这也是为什么用 SQLite、或者某些图像库时性能会莫名其妙地塌。
  2. 文件 I/O:Go 的文件读写虽然会进入 netpoll 风格的等待,但很多平台实现上并不是真正的异步通知,阻塞时会真正挂起线程。所以「大量并发写文件」的场景,Go 并不比 Java 有优势。

八、调度器的观测手段

调度器不是黑盒。几个必备的调试开关:

# 每 1000ms 打印一次调度器状态
GODEBUG=schedtrace=1000 ./app

# 更详细:看每个 P 的队列长度、抢占次数
GODEBUG=scheddetail=1,schedtrace=1000 ./app

# 关闭异步抢占,强制协作式(排查诡异死锁时有用)
GODEBUG=asyncpreemptoff=1 ./app

schedtrace 的输出大概长这样:

SCHED 0: 0ms SCHED 0: 0ms
  GOMAXPROCS=8 GOMAXPROCS=8
  P idle: 0/0/0/1/0/0/0/0  M idle: 7  Ms: 8
  S idle: 0  S total: 0
  G waiting: 0  G running: 3  P idle: 5
  CHAN 0 (0)  CHAN 1 (0)  CHAN 2 (0)
  MC: 0/0/0/0  MC wait: 0
  P 0: (g0) c0 m0 i0
  P 1: (g1) c1 m1 i0
  ...

重点看三个数:G waiting(等待被调度的数量)、G running(真正在跑的)、M idle(闲着的 OS 线程)。如果 G waiting 长期很高而 CPU 很闲,说明调度出问题了——通常是某个 G 卡在无法被抢占的地方,比如 cgo 或者死循环里的汇编。

再配合 pprof 的 goroutine profile、block profile、mutex profile 三个视角,基本能把调度问题定位干净。


九、写在最后

GMP 的设计其实回答了一个很朴素的问题:怎么在语言层面把并发成本降到几乎为零,同时还能真正用满多核。

它的答案是:把「轻量任务」和「昂贵资源」(OS 线程)解耦,用 P 做局部队列减少争抢,用 work stealing 保证负载均衡,用 netpoll 把阻塞 I/O 挪出线程,用 sysmon + 异步抢占兜底防止单点饿死。

但再好的调度器也救不了坏代码。回头看那些最容易出事的场景,其实都跟调度器本身无关,而是:

  • goroutine 起了没有收(泄漏)
  • context 没有透传(取消不掉)
  • 无脑加锁或者无脑开 goroutine
  • 在容器里没管 GOMAXPROCS

工具链给你的是能力,不是免死金牌。用好 go tool pprof、go tool trace、GODEBUG=schedtrace,这才是真正能把并发写稳的路径。

毕竟,goroutine 只是让你轻松地同时干很多事,但每一件事干得好不好,还得看你自己。

—— 完 ——

相关文章

你删掉了 100 万行,磁盘却没释放:数据库里的「尸体」不会自己消失
一个表情符号能把你的数据库搞瘫?MySQL 字符集 utf8mb4 的六宗罪
你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写
你刚写进去的数据怎么就读不出来了:主从延迟的七种死法
限流不是「限速」:令牌桶、漏桶、滑动窗口,以及你为什么限了个寂寞
别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账

发布评论