编程 高并发网关 CPU 尖峰排查:pprof 平缓但 perf 揪出 futex 写锁惊群

2026-09-01 21:31:45

高并发网关 CPU 尖峰排查:pprof 平缓但 perf 揪出 futex 写锁惊群

现象:P99 从 20ms 飙到 800ms,pprof 却一片祥和

一个高并发网关做压测,P99 延迟从稳定的 20ms 突然涨到 800ms+。监控先报警:Load Average 飙到 30,远超 8 核;top 里 Go 进程(Go 1.20.4,Linux 5.10,8 核)CPU 跑到 700%。

按常规套路抓 CPU profile:

go tool pprof -text http://localhost:6060/debug/pprof/profile?seconds=30

结果非常反常:30 秒的 profile 总耗时只有 3.42s 的 CPU 时间。如果进程真的吃了 7 个核,30 秒理应累计约 210s CPU 时间。更离谱的是热点函数排序:

runtime.epollwait    13.16%
runtime.futex         8.77%
runtime.findrunnable  7.31%

没有业务函数超过 5%。CPU 被谁吃了?pprof 说不知道。

pprof 不是万能的:SIGPROF 看不见内核态

Go 原生 CPU Profiler 用的是 setitimer 定时发 SIGPROF 信号,默认 100Hz 采样用户态调用栈。这套机制的前提是:CPU 时间花在用户态可执行代码上,信号能打断它、抓到栈。

但这里有三个盲区:

  1. 系统调用阻塞:goroutine 在 futexepoll 里挂起,不消耗用户态 CPU,信号来了也采不到业务栈。
  2. 不可中断睡眠:内核态 D 状态,信号根本递达不了。
  3. 极高频率的内核态上下文切换:大量线程在用户态和内核态之间反复横跳,信号采样点落在内核态的概率很低,采到的也都是 runtime.futex 这种运行时函数。

套用一句话:pprof 擅长看用户态纯计算,对内核态阻塞和抢占是近视眼。 这个案例里 CPU 尖峰恰恰是内核态 futex 唤醒风暴引发的,pprof 自然是什么都看不出来。

换 perf:火焰图直接把问题钉死

切到 Linux 内核级采样器 perf,抓 30 秒:

perf record -F 99 -p
-g -- sleep 30

生成火焰图:

perf script > out.perf
stackcollapse-perf.pl out.perf > out.folded
flamegraph.pl out.folded > cpu_flamegraph.svg

火焰图里出现一座占 60%+ 的“平顶山”,调用链非常清晰:

getFromCache -> sync.(*RWMutex).RLock -> runtime.gopark -> runtime.futex -> [kernel] sys_futex -> do_futex

业务代码在 getFromCache 里调用 RLock,然后全员挂在 futex 上。perf 能看到从用户态到内核态的完整路径,pprof 只看得到 runtime.futex 这一层。

根因:sync.RWMutex 写公平导致惊群

业务侧有个 globalCache,后台 goroutine 每 10 秒全量刷新一次,刷新时加写锁:

func refreshCache() {
mu.Lock()          // 写锁
cache = buildNewCache()
mu.Unlock()
}

func getFromCache(key string) (interface{}, bool) {
mu.RLock()         // 读锁
v, ok := cache[key]
mu.RUnlock()
return v, ok
}

Go 的 sync.RWMutexwriter-preferring 设计:一旦有写者排队等锁,后续所有 RLock 都不再放行,防止写者饿死。每 10 秒写锁到达后,窗口期内海量读请求全部被拦截:

  • 读 goroutine 尝试 RLock,发现写者在等,直接 gopark 把自己挂起;
  • 挂起的 M 进入内核 futex 等待队列;
  • 写锁释放时,futex 一次性唤醒成百上千个堆积的 goroutine —— 惊群效应

这些被唤醒的 goroutine 同时从休眠转为就绪态,疯狂抢占 CPU,上下文切换开销爆炸。Load Average 30 就是这么来的。

解法一:锁分片(Lock Sharding)

把一把全局大锁拆成 256 把分片锁,用哈希把读请求散列到不同分片:

const shardCount = 256

var shards [shardCount]*cacheShard

func getFromCache(key string) (interface{}, bool) {
idx := fnv.New32a()
idx.Write([]byte(key))
shardIdx := idx.Sum32() % shardCount
shard := shards[shardIdx]
shard.RLock()
v, ok := shard.m[key]
shard.RUnlock()
return v, ok
}

竞争碰撞概率从 1 降到 1/256,单点 futex 风暴消失。

适用场景

  • 写频率不是极低,但读仍有较多并发;
  • 缓存 map 单分片不太大,哈希分布较均匀;
  • 不想引入额外依赖,改造成本要小。

什么时候别用

  • 哈希函数选得不好、热点 key 集中,实际只命中少数分片,退化成局部大锁;
  • key 空间极小(比如只有几个固定 key),分片等于白分;
  • map 很大,分片后每片数据不均衡,某一片负载高。

解法二:写时复制 + atomic.Value

对于读多写极少(每 10 秒一次刷新)的场景,更彻底的方案是去掉锁:

var cache atomic.Value // 内部持有一个 map[string]interface{}

func refreshCache() {
newMap := buildNewCache()
cache.Store(newMap) // 整体替换指针
}

func getFromCache(key string) (interface{}, bool) {
m := cache.Load().(map[string]interface{})
v, ok := m[key]
return v, ok
}

读端彻底无锁,零阻塞;写端只是替换指针,代价是一次完整的新 map 分配和拷贝(COW)。改造后 sys_futex 高塔从火焰图上消失,Load Average 从 30 回到 2,p99 稳定在 15ms。

适用场景

  • 读多写极少(配置、路由表、黑白名单);
  • map 大小在可接受范围内,几分钟复制一次无所谓;
  • 读路径对延迟极其敏感,无法容忍任何锁等待。

什么时候别用

  • 写频率高:每次写都全量复制,CPU 和内存分配开销变成新瓶颈;
  • map 太大(百万级 key):复制一次要几十 ms,写延迟没法看;
  • 需要实时读到自己刚写入的数据:COW 的写是异步发布,语义上不是强一致。

怎么选:分片还是 COW?

简单判断:

  • 写频率 > 每秒几次、map 又大 → 分片,COW 复制成本扛不住;
  • 写频率极低(秒级甚至分钟级一次)、map 中小、读延迟敏感 → COW + atomic.Value,锁都省了;
  • 中间态 → 优先分片,改动小,风险低。

附录:完整排查命令

# 1. 抓 CPU profile(pprof 视角)
go tool pprof -text http://localhost:6060/debug/pprof/profile?seconds=30

# 2. 抓内核采样(perf 视角)
perf record -F 99 -p
-g -- sleep 30

# 3. 生成火焰图
perf script > out.perf
stackcollapse-perf.pl out.perf > out.folded
flamegraph.pl out.folded > cpu_flamegraph.svg

什么时候 pprof 会「骗」你

  • 进程 CPU 高但 pprof 总 CPU 时间远小于实际:大概率时间花在内核态(futex、syscall、vhost 等),pprof 的信号采样打不到内核栈;
  • pprof 热点全是 runtime.futex / epollwait / syscall:说明锁竞争或系统调用泛滥,这时候要切 perf 看内核栈,别在 pprof 里继续浪费时间。

顺带提一句:非 Go 堆内存泄漏

如果内存缓慢上涨但 pprof heap inuse_space 很小,大概率不是 Go 堆的问题,而是:

  • CGO 调用里用 C/C++ 分配的内存;
  • 直接 mmap 显式映射的内存;
  • glibc / jemalloc 分配器碎片。

pprof 对这部分无能为力。可用的手段:

# 看进程内存段分布
cat /proc/
/smaps

# 追踪缺页异常,配合 eBPF 看谁在分配
perf record -e page-faults -p
-g -- sleep 30

或者直接用 bcc / eBPF 的 memleak 工具抓未释放的分配栈。想持续监控所有 Pod 的 CPU/内存/锁竞争,可以挂 Pyroscope 或 Parca 这类 eBPF 持续 profiling 工具,能回溯任意时刻的火焰图,比事后抓包从容得多。

复制全文 生成海报 Go 性能调优 pprof perf 锁竞争 RWMutex

推荐文章

程序员茄子在线接单