2651 字
约 8 分钟
15
S09-X09 · goroutine 与 GMP 调度
2024-12-31
2026-11-09

一个 8 核容器上跑着 Go 服务,GOMAXPROCS 显示 64,GC 压力偏高,CPU 却被限流压在半核;另一个服务 goroutine 数从 200 涨到 30 万,内存缓慢上涨,重启就好。两件事都指向同一个地方:运行时调度器的工作方式,以及你对它的假设。

goroutine 不是"更轻的线程",它有独立的一套调度规则。按 Go 1.22 起的运行时行为拆。


1. goroutine 与线程的成本差异

维度 OS 线程 goroutine
初始栈 通常 1–8 MB 预留 2 KB,按需增长(上限默认 1 GB)
创建成本 系统调用,进入内核 用户态分配,微秒级量级
切换成本 内核态上下文切换 用户态保存少量寄存器
调度者 内核 Go 运行时
阻塞代价 整线程挂起 只有该 G 挂起,P 可被别的 M 接手
数量级 数千 十万级常见

"轻"的来源有三个:栈从 2 KB 起步、切换不陷内核、阻塞不拖累整个执行单元。但"轻"不等于"免费"——每个 G 有栈、有调度元数据,30 万个 G 的内存与扫描成本是实打实的。


2. GMP 三个角色

角色 是什么 数量 关键字段
G goroutine,一段待执行的函数 + 栈 动态,可达数十万 stack、status、goid
M 内核线程(machine) 按需创建,受 GOMAXPROCS 与阻塞情况影响 绑定的 P、当前运行的 G
P 执行上下文(processor) 固定为 GOMAXPROCS 本地运行队列(容量 256)、runnext

P 不是 CPU,它是"执行 Go 代码的许可"。一个 M 必须持有 P 才能跑 G,所以同时运行的 G 数量上限是 GOMAXPROCS。M 可以远多于 P:系统调用阻塞时会创建新 M 接管空闲 P。


3. 队列与 work stealing

来源 顺序 说明
P 的 runnext 最先 刚被唤醒/刚 go 出来的 G,优先复用同一 P 的缓存局部性
P 的本地队列 其次,FIFO 容量 256,避免全局锁
全局队列 再次 本地队列满时溢出,或 Gosched 归还
其他 P 的队列 本地与全局都空时 work stealing,偷一半
// 简化示意:调度循环的核心顺序
for {
    gp := runqget(pp)        // runnext → 本地队列
    if gp == nil { gp = globrunqget(pp, 1) }   // 全局队列
    if gp == nil { gp = runqsteal(pp) }         // 偷别的 P 的一半
    if gp == nil { /* 休眠或找 GC/系统任务 */ }
    execute(gp)
}

偷一半而不是偷一个,是为了让被偷方不会立刻再次饥饿;runnext 的优先级高于 FIFO 队列,是为了让"刚被唤醒的 G"尽快跑,减少唤醒延迟。这两条细节解释了为什么 goroutine 的执行顺序不保证 FIFO——任何依赖启动顺序的代码都是错的。


4. GOMAXPROCS 与容器

func main() {
    // 打印实际生效值,容器里第一件该看的事
    log.Printf("GOMAXPROCS=%d NumCPU=%d", runtime.GOMAXPROCS(0), runtime.NumCPU())
}
环境 默认 GOMAXPROCS 问题
物理机 逻辑核数 正常
容器(CPU limit 0.5) 早期版本仍取宿主核数 大量 P 抢极少配额 → 上下文切换与 GC 抖动
容器(GOMAXPROCS 显式设置) 设定值 推荐:与 limit 对齐
新版 Go 按 cgroup 限额推导 按你的版本核对是否已内置该行为

容器里的做法是显式设置:GOMAXPROCS 应等于 CPU limit 的整数部分(不足 1 核按 1 处理),而不是宿主核数。设大了不会更并行,只会让调度器多出无意义的竞争与 GC 辅助工作。


5. 抢占式调度

阶段 机制 痛点
协作式抢占(1.14 前) 函数调用时检查栈增长标记 无函数调用的死循环永不交出 P,其他 G 饿死
异步抢占(1.14+) 基于信号(SIGURG)中断,安全点插入抢占 少数汇编/C 调用边界仍需小心
func spin() {
    for {   // 1.14 之前:这个循环会卡住整个 P,调度器无法切换
        // 没有函数调用 → 没有栈检查点
    }
}

异步抢占的意义在于让"CPU 密集的死循环"不再能冻结调度器。但它不是实时系统:抢占是"尽快",不是"精确时刻",对延迟敏感的代码不能依赖抢占的时延保证。


6. 系统调用阻塞时发生什么

n, err := syscall.Read(fd, buf)   // 阻塞的系统调用
步骤 动作
1 运行中的 M 陷入系统调用,与 G 一起被标记为 syscall 状态
2 sysmon 周期性检查,发现该 M 阻塞时间过长(典型 10ms 量级)
3 把该 M 的 P 解绑,交给其他空闲 M
4 系统调用返回后,M 尝试拿回一个 P;拿不到就把 G 放全局队列,M 休眠

这就是"阻塞 IO 不会拖垮并发"的机制:阻塞的是 M,不是 P。反过来,如果代码用 GOMAXPROCS=1 加大量阻塞调用,P 的交接会变成主要开销。


7. runtime.Gosched 与 goroutine 泄漏

Gosched 主动让出当前 P,把 G 放回全局队列,等下轮调度。它只解决"我想让别人先跑",不解决"我该等多久"——真正的等待用 channel 或 context,不要用 Gosched 轮询。

泄漏的三种典型写法:

// 1. 永远阻塞在 channel:无人发送,也无人关闭
func leak1(ch chan int) { <-ch }

// 2. 没有退出条件的循环
go func() { for { doWork() } }()    // 没有 done 通道

// 3. 循环里的 time.After:每轮创建一个 Timer,到期前不释放
for {
    select {
    case v := <-in:  handle(v)
    case <-time.After(time.Second): continue   // 定时器堆积
    }
}
// 修法:用 ticker + context
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
    select {
    case v := <-in:  handle(v)
    case <-ticker.C: continue
    case <-ctx.Done(): return
    }
}

排查手段:

$ go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2   # 全栈,看谁在等
$ go tool pprof -top http://localhost:6060/debug/pprof/goroutine      # 按数量排序
log.Printf("goroutines=%d", runtime.NumGoroutine())   // 指标化,看增长斜率

runtime.NumGoroutine() 的价值不在绝对值,而在斜率:稳定波动正常,单调上升就是泄漏。


8. 并发安全:goroutine ≠ 并行

概念 含义 常见误解
并发(concurrency) 同时处理多件事的结构 以为并发就是快
并行(parallelism) 同时执行多件事 单核上 GOMAXPROCS=1 也能并发
数据竞争 两个 G 无同步地读写同一内存 "我没起 goroutine"——库内部可能起了
$ go run -race main.go        # 竞态检测,有竞争直接报错退出
$ go test -race ./...         # 单测里默认开,最划算的一道保险

-race 只在实际执行到冲突路径时报告,所以覆盖率决定检出率。它带来 5–20 倍量级的开销,只在测试与预发环境开。


9. WaitGroup 与 errgroup

var wg sync.WaitGroup
for _, id := range ids {
    id := id                 // Go 1.22 前必须;1.22 起每轮独立变量,可省
    wg.Add(1)
    go func() { defer wg.Done(); process(id) }()
}
wg.Wait()
工具 适合 注意
sync.WaitGroup 只需"全部完成" Add 必须在 go 之前;Done 用 defer
errgroup.Group 需要首个错误、需要取消 WithContext 才有取消传播
errgroup.SetLimit 需要并发上限 比手写信号量清晰

errgroup.WithContext 在第一个错误发生时取消 context,但它不会杀掉已在运行的 goroutine——被取消的 goroutine 必须自己检查 ctx.Done() 才会退出。


10. 排错表:常见坑

坑 表现 修法
循环变量捕获(1.22 前) 所有 goroutine 拿到最后一个值 1.22 起每轮独立变量;旧代码显式 id := id
无限起 goroutine 内存涨、GC 抖动、下游被打爆 用 errgroup.SetLimit 或信号量限并发
goroutine 里 panic 整个进程退出,无法被外部 recover 每个 goroutine 入口 defer recover()
把 GMP 当线程模型 用线程局部思维设计锁,出现假共享/过度加锁 用 channel 传所有权,用锁保护状态
容器里不设 GOMAXPROCS P 过多,切换与 GC 抖动 与 CPU limit 对齐
依赖 goroutine 启动顺序 偶发数据缺失 顺序靠 channel/WaitGroup 显式建立

go 出来的函数里 panic 会一路向上传播到运行时,recover 只对同一个 goroutine 内的 defer 有效,主 goroutine 的 recover 拦不住子 goroutine 的 panic。


11. 边界表

场景 该用的手段 理由
IO 密集高并发 大量 goroutine + channel 阻塞的是 M,P 可被接管
CPU 密集计算 限制 goroutine 数 ≈ GOMAXPROCS 更多 G 只增加调度开销
需要并发上限 errgroup.SetLimit / 信号量 无限制等于把下游打挂
需要取消 context 传播 只有 Gosched 不能让任务停下
长驻后台任务 明确的退出条件 + Stop 否则必然泄漏
排查泄漏 pprof/goroutine?debug=2 全栈直接指出阻塞点

一句话:goroutine 便宜,但"便宜"只描述创建成本,不描述它的生命周期责任。每个 go 都要能回答两个问题:它什么时候结束,谁负责取消它。


12. 自测

  1. goroutine 的初始栈多大?上限由什么决定?
  2. P 的数量由什么决定?为什么 M 可以远多于 P?
  3. runnext 为什么优先于本地 FIFO 队列?这对执行顺序意味着什么?
  4. 容器 CPU limit 为 0.5 核时,不设 GOMAXPROCS 会发生什么?
  5. 异步抢占解决了协作式抢占的哪个具体缺陷?
  6. 子 goroutine 里 panic,主 goroutine 的 recover 能拦住吗?正确做法是什么?
S09-X09 · goroutine 与 GMP 调度
https://www.clxhxhhr.top/posts/5988/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。