一个 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. 自测
- goroutine 的初始栈多大?上限由什么决定?
- P 的数量由什么决定?为什么 M 可以远多于 P?
runnext为什么优先于本地 FIFO 队列?这对执行顺序意味着什么?- 容器 CPU limit 为 0.5 核时,不设
GOMAXPROCS会发生什么? - 异步抢占解决了协作式抢占的哪个具体缺陷?
- 子 goroutine 里 panic,主 goroutine 的
recover能拦住吗?正确做法是什么?