跳到正文

07 Goroutine 与 GPM 调度 ​

一句话:goroutine 是 Go 运行时调度的用户态任务,用 2 KB 起步的可增长栈把「一连接一线程」的成本压下来,代价是共享堆、GC 扫描和调度器本身都要为 goroutine 数量买单。

适用 Go 1.22+(1.14 起基于信号的异步抢占;1.25 起 Linux 容器内 GOMAXPROCS 默认按 cgroup CPU 限额计算,以对应版本 Release Notes 为准)。并发原语见 06,本文只讲运行时怎么调度。

为什么需要 ​

一请求一线程时,线程栈通常按 MB 预留,创建、切换都要进内核;连接数一多,内存和上下文切换先到天花板。异步回调能省线程,但把控制流拆碎,错误处理和超时很难写对。

Go 的选择是 M:N 调度:大量 goroutine 复用少量 OS 线程,goroutine 阻塞时看起来仍是顺序代码,运行时负责把它挂起、换人执行。代价是调度、栈增长、GC 扫描都进入热路径,goroutine 数量不再是免费的。

核心机制 ​

G、P、M ​

组件含义关键数字
Ggoroutine,待执行任务初始栈 2 KB(_StackMin),64 位最大 1 GB
P逻辑处理器,持有本地运行队列数量 = GOMAXPROCS,默认 CPU 核数;本地队列 256 个 G
MOS 线程,真正执行 G默认上限 10000(debug.SetMaxThreads)

每个 P 绑定一个 M 执行本地队列;本地空了就从全局队列取,或从其他 P 偷一半(work stealing)。P 是调度并行度的单位,M 只是执行载体:阻塞系统调用会把 M 和 P 解绑,P 交给其他 M,所以 GOMAXPROCS 限制的是并行执行的 P,不是线程总数。

调度点、抢占与 sysmon ​

goroutine 在函数序言(栈检查)、channel 操作、select、锁、runtime.Gosched 和系统调用处让出。纯计算且没有函数调用的死循环,靠协作式调度永远让不出去。

Go 1.14 起加入异步抢占:sysmon 发现某个 G 连续运行超过约 10 ms(forcePreemptNS),向它发送信号(Unix 上是 SIGURG),运行时在安全点插入抢占。sysmon 是一个不绑定 P 的后台 M,周期性工作(间隔 20 µs–10 ms):

  • 抢占长时间运行的 G;
  • 从阻塞在系统调用里的 M 手中收回 P;
  • 每 2 分钟强制触发一次 GC(forcegcperiod);
  • 驱动网络轮询器(netpoller)和内存 scavenger。

网络 I/O 走 netpoller,等待的 goroutine 被挂起但不占 M;只有阻塞式系统调用或 cgo 才会真正占住线程。锁的阻塞最终落到运行时的信号量(sema),channel 则把 goroutine 挂到 sendq/recvq 后 park。

channel、select 与同步原语 ​

channel 的运行时结构是 hchan:环形缓冲区、sendq/recvq 两个等待队列和一把锁。

  • 无缓冲:发送和接收必须配对,先到的一方挂起。
  • 有缓冲:缓冲满时发送阻塞,空时接收阻塞。
  • 从已关闭的 channel 接收:立即返回零值和 ok=false;发送到已关闭的 channel 会 panic;关闭 nil channel 或重复关闭也会 panic。
  • nil channel 永远阻塞;select {} 永远阻塞;select 在多个就绪 case 中伪随机选择。

同步原语:sync.Mutex 有正常模式和饥饿模式。正常模式下抢锁失败会先自旋几次(多核、临界区短时避免睡眠/唤醒开销),再进 sema 队列;等待超过约 1 ms 进入饥饿模式,锁直接交给队首 waiter。RWMutex 写优先,递归读锁可能死锁;sync.Map 用 read/dirty 两份结构优化「读多写少、键基本不冲突」的场景,不适合频繁写。

内置 map 不是并发安全的:并发读写会触发运行时 fatal error(concurrent map writes),不能被 recover。它的底层是 bucket 数组,每个 bucket 放 8 组键值;装载因子约 6.5 时翻倍扩容,overflow bucket 过多时等量扩容,迁移是渐进式的,每次写顺带搬一部分。

Context 用来在 goroutine 之间传递取消、超时和请求级数据:Done() 返回一个关闭即取消的 channel,Err() 说明原因。WithCancel/WithTimeout 的 cancel 必须调用,否则定时器和子 Context 会一直挂着。

实战场景 ​

一、容器里 GOMAXPROCS 按宿主机核数,P99 周期性抖到 800ms ​

现象:一个 Go 服务在 K8s 里 limit 2 核(宿主机 64 核),日常 QPS 正常,但每次流量爬坡就出现周期性延迟毛刺:P99 从 30ms 跳到 800ms,CPU 使用率始终贴着 2 核上限,GOMAXPROCS 打印出来是 64。GC 日志里没有长 STW。

排查:P 的数量决定并行度,而它默认取 CPU 核数。

定位层取证据判定
实际并行度runtime.GOMAXPROCS(0) 与容器 limit 对比远大于 limit 即错配
CFS 限流cgroup 的 cpu.stat 里 nr_throttled、throttled_time限流时间点与毛刺对齐即可确认
runnable 堆积runtime/metrics 的 sched.goroutines/runnable、pprof 的 scheduler latency 图调度等待时间长即 P 不够用
GC 并发标记占比并发标记默认最多占约 25% 的 GOMAXPROCSP 被高估时 GC 也会抢占配额
版本Go 版本是否具备容器感知的自动 GOMAXPROCS早期版本一律按逻辑核数

被限流时,64 个 P 都在往运行队列里塞 G,但内核每 100ms 周期只给 2 核的时间片,周期末尾全体被冻结,下一次唤醒就是一批高延迟。

对症做法:

go
// 1. 显式设置:把 P 的数量对齐容器配额
import "go.uber.org/automaxprocs/maxprocs"   // 或在启动时按 limit 自己算
func init() {
    if n, err := readCPUQuota(); err == nil {   // cgroup v2: cpu.max
        runtime.GOMAXPROCS(n)
    }
}
yaml
# 2. 或者干脆不给 CPU limit(只给 requests),
#    由 GOMAXPROCS 与节点池容量共同决定并行度;
#    代价是噪声邻居影响更明显,需要有 QoS 分层与限流兜底
resources:
  requests: { cpu: "2", memory: "1Gi" }
text
3. 与内存相关的配套:GOGC / GOMEMLIMIT 也要按容器配额设,
   否则 P 与 GC 阈值都按宿主机规模算,见 [垃圾回收](09-垃圾回收.md)。
4. 观测要包含“调度等待时间”,它能区分“代码慢”和“排不上队”,
   pprof 的 -schedtrace 与 runtime/metrics 都够用。

复盘要点:判据是“GOMAXPROCS == CPU limit”且“nr_throttled 增速为 0”。这条要放进部署模板校验,而不是靠开发者记得引库;Go 的版本差异要以官方发布说明为准(新版本已具备容器感知能力时优先升级而不是自己实现)。

二、cgo 与阻塞系统调用把 M 撑到上千,调度延迟失控 ​

现象:接了图像库(cgo)之后,服务在高峰期 threads 数从 40 涨到 2600,pprof 里 runtime·newosproc 占比明显,请求排队时间上升,且 RSS 随线程数一起涨(每线程默认栈 8MB)。

排查:区分“网络等待”和“线程被占住”。

场景是否占住 M机制
网络 I/O(标准 net 库)不占走 netpoller,G 挂起,M 继续跑别的 G
普通系统调用(读文件、部分 DNS)会占阻塞时把 M 与 P 解绑,P 交给别的 M(handoff)
cgo 调用占,且不能安全异步抢占需要 entersyscall,返回后才解除
runtime.LockOSThread占线程与 G 绑定,常见于图形/GL/驱动
channel/锁等待不占 MG 被 park 到 sendq/recvq 或 sema 队列

M 的默认上限是 10000(debug.SetMaxThreads),逼近上限时新 G 会直接 panic 或长时间排不上;每个 M 是一个 OS 线程,栈与调度切换成本都真实存在。

对症做法:

go
// 1. 给 cgo/阻塞调用设并发上限,而不是无限开 goroutine
var sem = make(chan struct{}, 8)           // 与图像库线程安全上限一致

func process(img []byte) error {
    sem <- struct{}{}
    defer func() { <-sem }()
    return cgoResize(img)                   // 并发被限制在 8,M 不会失控
}

// 2. 文件 I/O 与 DNS 造成的阻塞:减少热路径上的同步文件读,
//    配置走启动期加载;DNS 用短连接 + 缓存或静态解析
// 3. 需要“异步”的场景优先用 goroutine + channel 表达,
//    不要为绕开阻塞而手工锁线程
text
4. 观测:runtime/metrics 的 /sched/threads、pprof 的 goroutine 与线程 profile;
   线程数与 RSS 一起涨时,先查有多少 G 卡在 cgo/系统调用。
5. debug.SetMaxThreads 只在确认是线程数失控时调整,
   提高上限只是把“崩溃”换成“更晚的崩溃”,不解决阻塞源。

复盘要点:判据是“线程数(threads)与 CPU limit 的比值有上限”(例如 ≤ 10 倍,经验值需校准)、“M 与 P 解绑次数”趋势。核心结论是:GOMAXPROCS 限制的是并行执行的 P,不是线程总数——把它当线程数配置是常见误解,真正需要限制的是并发调用外部库的数量。

三、无人接收的 channel:goroutine 泄漏与连接堆积 ​

现象:一个流式接口每次请求起一个 goroutine 往无缓冲 channel 发数据。客户端提前断开时不再有人读,发送方永久 park;一天后 runtime.NumGoroutine() 从 200 涨到 18000,同时文件描述符接近上限。

排查:channel 会把 G 挂到 sendq/recvq 后 park,这不是超时能自动恢复的状态。

泄漏形态判定证据说明
无缓冲发送无人接收goroutine 栈显示 chansend/gopark典型:客户端断开
接收方已退出,生产者仍写同上,且缓冲区已满有缓冲只是推迟发生
range ch 永不关闭goroutine 卡在 chan recv缺少 done 信号
select 无 default 也无退出分支卡在 select只有阻塞分支时等价于单分支
忘记 defer 关闭数量随请求线性增长生命周期没绑定

对症做法:

go
// 1. 所有可能阻塞的通信都要带退出分支
func produce(ctx context.Context, out chan<- Chunk, src Source) error {
    for {
        c, err := src.Next()
        if errors.Is(err, io.EOF) { return nil }
        if err != nil { return err }
        select {
        case out <- c:
        case <-ctx.Done():                 // 客户端断开或超时
            return ctx.Err()
        }
    }
}

// 2. 消费方退出要能通知生产方:http.Request.Context() 在连接断开时自动 cancel
go func() {
    defer close(out)
    if err := produce(req.Context(), out, src); err != nil {
        metrics.prodAbort(err.Error())
    }
}()

// 3. 泄漏可测:单测里比较前后 goroutine 数量,差值必须回到基线
func TestNoGoroutineLeak(t *testing.T) {
    before := runtime.NumGoroutine()
    runOnceWithCanceledContext(t)
    if after := runtime.NumGoroutine(); after > before+2 {
        t.Fatalf("goroutine leak: %d -> %d", before, after)
    }
}
text
4. 观测三件套:runtime.NumGoroutine()(或 /debug/pprof/goroutine?debug=1)、
   打开的文件描述符数、内存 RSS 同图对比,
   三者同向线性增长基本就是泄漏。
5. 缓冲不是解法:给 channel 加大缓冲只是把泄漏延后,
   没有退出分支的发送方一定会卡住。

复盘要点:判据是“goroutine 数在流量回落后回到基线”,这条要做成压测断言而不是看监控。配合“每个 goroutine 都有明确退出路径”的写法约束(要么 ctx.Done,要么 close 由生产者负责),泄漏就不会靠事故发现。select 的机制细节见本文的 channel 与同步原语一节。

最小上手 ​

go
package main

import (
	"fmt"
	"net/http"
	_ "net/http/pprof"
	"runtime"
	"time"
)

func leak() {
	ch := make(chan int)
	go func() { ch <- 1 }() // 没有接收者,永久阻塞
}

func main() {
	go http.ListenAndServe("127.0.0.1:6060", nil)
	for i := 0; i < 100; i++ {
		leak()
	}
	time.Sleep(200 * time.Millisecond)
	fmt.Println("goroutines:", runtime.NumGoroutine())
	select {}
}
bash
mkdir -p /tmp/gdemo && cd /tmp/gdemo
go mod init gdemo
# 保存上面的 main.go 后
go run .
# 输出约 102:main + HTTP server + 100 个泄漏的 goroutine

# 另开终端:看完整栈,不用等 profile 采样
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' | head -40
go tool pprof -http=:8081 http://127.0.0.1:6060/debug/pprof/goroutine

profile 里会看到大量停在 chan send 的栈;这就是「发送方没有接收者」的典型泄漏。生产环境不要把 pprof 暴露在公网端口。

生产动作 ​

  • 控制数量:用固定大小 worker pool + 有界 channel,或 errgroup.SetLimit(n)、semaphore.Weighted。不要用无界 goroutine 池把背压消掉。
  • 参数与版本:
    • GOMAXPROCS 默认 CPU 核数;Go 1.25+ Linux 容器按 cgroup 配额,老版本需显式设置。
    • 线程上限默认 10000(debug.SetMaxThreads 可改);触顶会报 program exceeds 10000-thread limit,根因通常是阻塞系统调用或 cgo,调大只是拖延。
    • GODEBUG=schedtrace=1000 每秒打印调度器状态(runqueue、idleprocs、threads);scheddetail=1 展开每个 P。
    • GOTRACEBACK=all 让 panic 时打印所有 goroutine 栈;system 还会带上运行时栈。
  • 泄漏排查路径:
    • 现象:runtime.NumGoroutine() 单调上涨 → pprof goroutine?debug=2 看栈 → 判断卡在 chan send/recv、select、sync.Mutex、IO wait 还是 semaphore。
    • 常见根因:channel 没有对端、context 没取消、HTTP resp.Body 没关、time.Ticker 没 Stop、WaitGroup 的 Add/Done 不配对、向无人接收的 channel 发送。
    • 测试里用 go.uber.org/goleak 在 TestMain 退出时兜底。
  • 死锁:所有 goroutine 都睡着且没有定时器、netpoll 可唤醒时,运行时会报 all goroutines are asleep - deadlock!。有后台网络或定时器时不一定报,仍要靠 profile 看阻塞栈。
  • 关闭纪律:只由发送方关闭 channel;接收方不要关;共享取消用 context,不要靠关闭一个公共 channel 通知所有人。

边界 ​

  • goroutine 便宜但不免费:2 KB 初始栈 + G 结构 + 调度和 GC 扫描成本;百万级可行,但内存和 GC 会先成为瓶颈。
  • GOMAXPROCS 不等于线程上限:阻塞系统调用和 cgo 会额外创建 M,线程数可能远超 P 数。
  • channel 不是队列替代品:无界缓冲会把背压藏起来,延迟和内存一起涨。
  • sync.Map 不是通用并发 map:读多写少、键集合稳定时才有优势;频繁写不如 map + RWMutex。
  • 抢占不是实时:异步抢占有约 10 ms 量级延迟,硬实时场景不能依赖它。
  • 泄漏的 goroutine 不会被 GC:它的栈是 GC 根,阻塞着的 goroutine 会一直占内存。
  • NumGoroutine 是瞬时值:可能包含运行时和框架创建的 goroutine,看趋势而不是绝对值。

讲清楚 ​

goroutine 的核心是 M:N 调度:G 是任务,P 是并行度和本地队列,M 是真正执行的线程。P 的数量由 GOMAXPROCS 决定,阻塞系统调用会把 P 让给别的 M,所以少量线程能跑大量 goroutine。Go 1.14 之后靠 sysmon 发信号做异步抢占,纯计算死循环也能在约 10 ms 内被换下;sysmon 还负责收回阻塞线程的 P、每两分钟强制 GC 和驱动 netpoll。

channel 是带缓冲和等待队列的同步结构,关闭语义和 nil channel 的阻塞行为是最容易踩的坑;内置 map 不是并发安全的,sync.Map 只适合读多写少。goroutine 泄漏的本质是它永远阻塞在某个等待队列上,而栈又是 GC 根,所以不会被回收——排查靠 pprof goroutine?debug=2 看栈,预防靠 context、有界队列和明确的关闭纪律。

把一门技术讲透到既懂也会用