目录
- 全链路地图
- 常量块:缓冲区大小怎么定
- cpuProfile 结构体
- SetCPUProfileRate:开与关的完整时序
- add:信号处理器里的记账
- addNonGo:非 Go 线程的样本
- addExtra:补写与丢失上报
- 废弃的 CPUProfile 与两个 linkname 后门
一、全链路地图
CPU 剖析的本质,可以浓缩成一句话:一个定时器 + 一个信号处理器 + 一个环形缓冲 + 一个读出 goroutine。
【Linux/macOS】 【Windows】
setitimer/timer_create 每秒 hz 次 专用线程 profileLoop 等 profiletimer
│ SIGPROF │ 挂起每个线程
▼ ▼
┌─────────────────────── sigprof(pc,sp,lr,gp,mp) ──────────────────────┐
│ 1) 检查 prof.hz、mp.profilehz 是否在采样 │
│ 2) 特判 cgo / libcall / vdso / 普通四种路径,选回溯起点 │
│ 3) tracebackPCs 走栈收集最多 64 帧 PC │
│ 4) 失败则记到 _System/_GC/_ExternalCode 伪函数 │
│ 5) cpuprof.add(tagPtr, stk) ← 本篇主角 │
│ 6) traceCPUSample(...) ← trace 联动(第 18 篇) │
└──────────────────────────────────────────────────────────────────────┘
│
▼
cpuProfile.log (*profBuf,无锁单写单读环形缓冲)
│ runtime_pprof_readProfile() ←linkname← runtime/pprof 包(第 08 篇)
▼
profileBuilder 编码成 pprof proto(第 10 篇)→ .pb.gz 文件
文件头注释(cpuprof.go:5-11)用四句话概括了它:
时钟 tick 的信号处理器把新栈加入最近 trace 的日志;一个用户 goroutine 读日志并格式化;读者跟不上就把溢出记成丢失计数;真正的缓冲在
profbuf.go。
图 3-1:从上篇总览看采集端。实线为 Linux/macOS 的信号中断路径,下方虚线为 Windows 用
profileLoop线程逐个点名的等价路径(详见 §十一)。
本篇,就是把这四句话展开成代码。
动手实验 0:跑通最小闭环(对应第一节的地图)
后面所有实验共用一个模块和一段"脚手架",一次性准备:
mkdir cpuprof-lab && cd cpuprof-lab
go mod init cpuprof-lab # 零外部依赖:标准库 + 自带 go tool 就够
cpu0/main.go(完整可复制;其中的 busy/sink 是全部实验的公共脚手架,后文实验只给新增部分,完整文件 = 本例的头部 + 新增函数与 main,import 按需增删):
package main
import (
"os"
"runtime/pprof"
"time"
)
var sink int // 防止忙循环被编译器优化掉
// busy 纯 CPU 忙循环 ms 毫秒:采样必然命中它
func busy(ms int) {
deadline := time.Now().Add(time.Duration(ms) * time.Millisecond)
x := 0
for time.Now().Before(deadline) {
for i := 0; i < 100000; i++ {
x += i
}
}
sink += x
}
func main() {
f, _ := os.Create("cpu.pprof")
if err := pprof.StartCPUProfile(f); err != nil { // → SetCPUProfileRate(100) → setcpuprofilerate(100)
panic(err)
}
defer pprof.StopCPUProfile() // → SetCPUProfileRate(0):停信号 → addExtra → log.close
busy(2000) // 期间每 10ms 一次 SIGPROF → sigprof → cpuprof.add(第五节)
}
go run ./cpu0
go tool pprof -top cpu.pprof
实测输出(go1.24.0 / Windows 11):
Duration: 2.14s, Total samples = 1.98s (92.52%)
flat flat% sum% cum cum%
1.95s 98.48% 98.48% 1.95s 98.48% main.busy
0.02s 1.01% 99.49% 0.02s 1.01% runtime.cgocall
0.01s 0.51% 100% 0.01s 0.51% runtime.stdcall1
...
对着第一节的地图逐环认领:StartCPUProfile → SetCPUProfileRate(100)(第四节);2 秒忙等 → 约 198 个样本(1.98s ÷ 10ms),每个都走 sigprof → cpuprof.add(第五节)→ profBuf.write(04 篇);读 goroutine profileWriter 编码落盘(第 10 篇)。那 ~1% 的 cgocall/registry.* 是 Windows 上首次 time.Now() 懒加载时区的真实开销——火焰图不会骗人,后面的实验还会反复遇到它。
图 3-2:
go tool pprof -top的占比最终会画成火焰图:最宽的底层方块就是占用 CPU 最多的函数(main.busy)。本实验里它占了 ~98%。
二、常量块:缓冲区大小怎么定
源码位置:cpuprof.go:21-35
const (
maxCPUProfStack = 64
// ↑ 每个样本最多保留 64 帧调用栈。够深吗?经验上火焰图需要的有效深度
// 通常 < 32;更深的栈在旧版是 100,1.2x 起收敛为 64,换取缓冲容量。
profBufWordCount = 1 << 17
// ↑ 数据环形缓冲容量:2^17 = 131072 个 uint64 字 = 1 MiB。
// 每个样本占 2(定长头)+ hdr(1, 即频率字)+ 栈长(≤64)字,
// 最好情况(64 帧全满)约 67 字/样本 → 容纳 ~1900 个样本。
// 100Hz、单线程满载时 = 19 "线程-秒"的余量(源码注释原话)。
profBufTagCount = 1 << 14
// ↑ 标签环形缓冲:2^14 = 16384 个 unsafe.Pointer,每样本 1 个标签位,
// = 160 线程-秒 @100Hz。标签即 pprof labels(第 01 篇 4.5 节),
// 从被中断 goroutine 的 g.labels 取出,随样本并行存档。
)
为什么数字必须是 2 的幂? profbuf 用"总读写次数 mod 2^30/2^32"再 mod 缓冲长度来定位下标,只有 2 的幂才能让两种回绕对齐(见 04 篇 newProfBuf 的注释)。
读不过来会怎样? 不是崩溃,是丢样本并计数——最终在 profile 里表现为一个 _LostExternalCode 伪函数栈(见第七节),所以"抓 30 秒 CPU profile 丢了多少样本"是可以直接看出来的。
动手实验 1:亲眼看到 64 帧截断(对应 cpuprof.go:21-22)
cpu1/main.go(在公共脚手架上新增;import 补 os、runtime/pprof):
// deep 递归 depth 层,只在最深处烧 CPU → 采样必然命中"深栈"
func deep(depth int) {
if depth == 0 {
busy(500)
return
}
deep(depth - 1)
sink += depth
}
func main() {
f, _ := os.Create("deep.pprof")
pprof.StartCPUProfile(f)
deep(100) // 真实栈深 ≈ 105 帧:busy + deep×100 + main + runtime.main...
pprof.StopCPUProfile()
f.Close()
}
go run ./cpu1
go tool pprof -raw deep.pprof | sed -n '/^Samples:/,$p'
实测输出(节选。-raw 的样本区每行一条:样本数 累计ns: 位置ID…;位置 ID 对应的函数名在随后的 Locations 段):
Samples:
samples/count cpu/nanoseconds
1 10000000: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
45 450000000: 22 23 24 24 24 ...(共 64 个 ID)... 24
1 10000000: 1 2 3 4 25 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
1 10000000: 26 27 28 29 17 18 19 20 21
1 10000000: 30 23 24 ...(共 64 个 ID)... 24
2 20000000: 31 23 24 ...(共 64 个 ID)... 24
Locations 段里的对应关系(节选;行号取决于你的文件布局):
22: main.busy cpu1/main.go:15 ← 内层循环
23: main.deep cpu1/main.go:25 ← 调用 busy 的那一层
24: main.deep cpu1/main.go:28 ← 递归调用点,重复出现 62 次
逐帧数第二行:恰好 64 个位置 = maxCPUProfStack。两个必看的细节:
- 23/24 都是
main.deep——同一函数只建一条位置记录,靠重复出现表达递归层级:1×busy + 63×deep; - 栈底(第 64 帧)是
main.deep而不是main.main——截断保留的是靠近中断点的 64 帧(leaf 侧),更外层的 main/runtime.main 整段丢弃。想想为什么合理:采样要回答"CPU 烧在哪",答案总在中断点附近,离得越远的栈帧信息价值越低。
统计每条样本行的帧数分布:
go tool pprof -raw deep.pprof | sed -n '/^Samples:/,$p' \
| awk -F': ' '/^ *[0-9]+ +[0-9]+: /{print split($2,a," ")}' | sort -n | uniq -c
# 实测:1 条 9 帧、2 条 21 帧、3 条 64 帧(awk 正则排除掉了 Locations 段的行,别数错)
顺带一个意外收获:那 3 条浅栈(21/21/9 帧)的根帧都是 profileWriter → newProfileBuilder → peBuildID → time.Time.String → time 包首次初始化时区 → 一路 cgocall 进注册表——读 goroutine 自己也在被采样,而且它恰好在剖析开着的时候才第一次跑起来。采样对进程内所有 Go 线程一视同仁,没有任何豁免名单。
三、cpuProfile 结构体
源码位置:cpuprof.go:37-59
type cpuProfile struct {
lock mutex
on bool // profiling is on
log *profBuf // profile events written here
// ↑ 全部样本的落点。lock 只保护 on/log 两个字段的切换;
// 样本写入路径(信号处理器)不碰这把锁,靠 prof.signalLock 自旋。
// extra holds extra stacks accumulated in addNonGo ...
extra [1000]uintptr
numExtra int
lostExtra uint64 // count of frames lost because extra is full
lostAtomic uint64 // ... updated racily
}
// ↑ extra:非 Go 线程(如 cgo 创建的 pthread)收到 SIGPROF 时,
// 无法安全调用 log.write(没有 g、栈极小),先把栈塞进这个
// 定长数组"暂存区",等下一个 Go 线程的信号到来时一并补写。
// 1000 字按"每样本 2+2 字"估算可暂存 ~300 样本/秒 @100Hz。
var cpuprof cpuProfile // 全局单例:整个进程同时只有一份 CPU 剖析。
设计要点:单例 + 两把锁各管一段。
cpuprof.lock管"开/关"这种慢路径;prof.signalLock(在proc.go的 prof 结构里)管信号处理器与开关之间的互斥。信号处理器里绝不能拿普通 mutex(可能中断了持锁者 → 死锁),只能自旋 CAS。
四、SetCPUProfileRate:开与关的完整时序
源码位置:cpuprof.go:61-97
// SetCPUProfileRate sets the CPU profiling rate to hz samples per second.
func SetCPUProfileRate(hz int) {
if hz < 0 { hz = 0 } // ← 负数当 0:关闭
if hz > 1000000 { hz = 1000000 } // ← 上限 1MHz,防呆
lock(&cpuprof.lock) // ← 慢路径锁:保护 on/log 的切换
if hz > 0 {
if cpuprof.on || cpuprof.log != nil {
print("runtime: cannot set cpu profile rate until previous profile has finished.\n")
unlock(&cpuprof.lock)
return
}
// ↑ 关键防重入:上一轮 profile 的数据可能还没被读干(log 非 nil)。
// 这就是"runtime/pprof.StartCPUProfile 期间不能再 Start"的根源。
cpuprof.on = true
cpuprof.log = newProfBuf(1, profBufWordCount, profBufTagCount)
// ↑ hdrsize=1:每条记录带 1 个字的头部(存 hz,供读取端换算周期)
hdr := [1]uint64{uint64(hz)}
cpuprof.log.write(nil, nanotime(), hdr[:], nil)
// ↑ 缓冲里的第一条记录不是样本,而是"元记录":头部 = hz,
// 栈为空。proto 编码端靠它知道每样本代表 1/hz 秒。
setcpuprofilerate(int32(hz)) // ← 真正装定时器(第十节)
} else if cpuprof.on { // ← hz==0:关闭分支
setcpuprofilerate(0) // ① 先停定时器,不再有新信号
cpuprof.on = false
cpuprof.addExtra() // ② 把 extra 暂存的非 Go 栈冲进 log
cpuprof.log.close() // ③ 标记 EOF:读干后 read 返回 eof
}
unlock(&cpuprof.lock)
}
注意关闭顺序 ①→②→③ 是精心安排的:先断源,再冲暂存,最后 EOF。 颠倒任何两步,要么丢 extra 里的尾巴,要么 read 永远等不到 eof(系列第 01 篇"坑 #1"的源码级解释)。
动手实验 2:防重入——直接调 runtime.SetCPUProfileRate(cpuprof.go:78-83)
SetCPUProfileRate 本身是导出函数,绕开 runtime/pprof 直接调,正好把源码行为一个个戳出来。cpu2/main.go(import 补 fmt、runtime、time):
func main() {
fmt.Println("[1] 第一次开启 hz=100 —— 成功,无任何输出")
runtime.SetCPUProfileRate(100)
busy(300) // 让样本真实流入环形缓冲(没人读,也不影响运行)
fmt.Println("[2] 未关闭就再次开启 —— 期望 stderr 打印 runtime 错误")
runtime.SetCPUProfileRate(100)
fmt.Println("[3] 先关闭再开启 —— 仍然失败!log 未被读干(见第八节 readProfile)")
runtime.SetCPUProfileRate(0) // on=false,但 cpuprof.log 仍非 nil
runtime.SetCPUProfileRate(100) // cpuprof.log != nil → 依旧被拒
fmt.Println("[4] 结束。本轮剖析数据没有读者,只能放弃(别在生产代码这么用)")
}
实测输出(两行 runtime 报错走 stderr,与 stdout 的行序可能交错):
[1] 第一次开启 hz=100 —— 成功,无任何输出
[2] 未关闭就再次开启 —— 期望 stderr 打印 runtime 错误
runtime: cannot set cpu profile rate until previous profile has finished.
[3] 先关闭再开启 —— 仍然失败!log 未被读干(见第八节 readProfile)
runtime: cannot set cpu profile rate until previous profile has finished.
[4] 结束。本轮剖析数据没有读者,只能放弃(别在生产代码这么用)
三次调用对应源码三条路径:[1] 通过 79 行防重入检查 → 建 newProfBuf、写 hz 元记录、装定时器;[2] cpuprof.on == true → 80 行 print 后直接 return;[3] on 已置 false,但 cpuprof.log != nil——把它置 nil 的唯一地方是 runtime_pprof_readProfile 读干之后(第八节),而本程序没有任何读者。注意 [3] 里那 300ms 攒下的约 30 个样本从此再没人能取走:环形缓冲随进程一起销毁。这就是"runtime 层防重入是最后防线"的直观含义。
动手实验 3:看见缓冲区里的第一条"元记录"(cpuprof.go:87-88)
cpu3/main.go(import 补 os、runtime/pprof):
func main() {
f, _ := os.Create("cpu.pprof")
pprof.StartCPUProfile(f)
busy(300)
pprof.StopCPUProfile()
f.Close()
}
go run ./cpu3
go tool pprof -raw cpu.pprof | head -2
实测输出:
PeriodType: cpu nanoseconds
Period: 10000000
Period = 10,000,000 ns = 1e9/100。换算发生在 runtime/pprof/proto.go:284-289:校验 data[0] != 3 || data[2] == 0 之后取 b.period = 1e9 / int64(data[2])——data[2] 正是第四节那条"元记录"的第 3 个字(hz)。所以 proto 里的 Period 就是 hz 的影子,-top 里的 "1.98s" 全是读取端拿 样本数 × Period 累加出来的。hz 被 runtime/pprof 写死为 100(pprof.go:843 的注释:操作系统发不动 500Hz 以上的信号),想改频率只能像实验 2 那样直调 runtime——但官方没有给直调者的读取通道,这正是第八节 pyroscope 们"钻 linkname 后门"的现实动机。
五、add:信号处理器里的记账
源码位置:cpuprof.go:99-126
//go:nowritebarrierrec
// ↑ 编译器指令:本函数及其调用不得触发写屏障。原因:GC 标记阶段可能
// 正持有写屏障的锁/状态,而信号可以在任意点打断它。
func (p *cpuProfile) add(tagPtr *unsafe.Pointer, stk []uintptr) {
for !prof.signalLock.CompareAndSwap(0, 1) {
osyield()
}
// ↑ 与 SetCPUProfileRate/setcpuprofilerate 互斥的自旋锁。
// 信号环境不能 sleep,只能让出 CPU 死等(osyield)。
// TODO 注释(源码 109 行)仍在讨论 osyield 在此是否安全。
if prof.hz.Load() != 0 { // implies cpuprof.log != nil
// ↑ 双保险:拿到 signalLock 后再确认频率仍非 0,
// 防止"刚要写,别的线程恰好把剖析关了"的窗口。
if p.numExtra > 0 || p.lostExtra > 0 || p.lostAtomic > 0 {
p.addExtra()
}
// ↑ 先把非 Go 线程攒下的暂存栈补写进 log(第七节)。
hdr := [1]uint64{1}
// ↑ 头部 = 1:这条记录是"1 个样本"(丢失记录的头部则是丢失数,
// 两者在 profBuf 里同构,读取端无需区分)。
cpuprof.log.write(tagPtr, nanotime(), hdr[:], stk)
// ↑ 无锁写入环形缓冲(04 篇精读 write)。
// tagPtr 指向被中断 g 的 labels 字段地址——传地址而不是值,
// 因为这里不能有写屏障;log.write 内部用裸指针存储绕开屏障,
// 上面 118-121 行的长注释解释了为什么这样对 GC 是安全的:
// gp 是唯一可能改 gp.labels 的 goroutine,而它正被我们中断着。
}
prof.signalLock.Store(0) // ← 释放自旋锁
}
一个样本的完整成本:一次 CAS 自旋 + 一次原子读 + 一次无锁批量写 ≈ 几百纳秒。这就是"CPU 剖析开销 ~1%"的微观基础。
动手实验 4(调试器):断点走进 add / sigprof / setcpuprofilerate
本篇主角都在 runtime 包里,业务代码"调不到",但 delve 能直接断进去:dlv debug 默认以 -gcflags='all=-N -l' 重编(关优化、关内联),runtime 函数调试信息完整。(本机未装 delve,以下为操作清单,未实测。)
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug ./cpu0 # 用实验 0 的程序
(dlv) break runtime.SetCPUProfileRate # 第四节:开/关入口
(dlv) break runtime.setcpuprofilerate # 第十节:装/停定时器
(dlv) break runtime.sigprof # 第九节:信号处理器主体
(dlv) break runtime.(*cpuProfile).add # 第五节:样本记账
(dlv) continue
命中后的看点(与源码逐一对上):
SetCPUProfileRate:print hz→ 100;单步走过第四节 79 行的防重入检查、86 行的newProfBuf(1, 1<<17, 1<<14);setcpuprofilerate:print gp.m.locks在gp.m.locks++前后从 0 变 1——禁抢占(第十节);sigprof:bt看不到常规调用者(信号借的是当前线程的栈,栈上是被打断的那个人);走过第九节第 5 段的tracebackPCs后print n= 实际收到的帧数;add(每 10ms 命中一次,看两三次后clear掉这个断点):print stk直接打印被中断瞬间的调用栈 PC 数组,print *p是 cpuprof 单例(on/log/extra/numExtra 全在内),print prof.hz= 100——第五节的每个字段都能摸到实物。
六、addNonGo:非 Go 线程的样本
源码位置:cpuprof.go:128-160
//go:nosplit
//go:nowritebarrierrec
func (p *cpuProfile) addNonGo(stk []uintptr) {
// (与 add 相同的 signalLock 自旋,注释解释:通常同时只有一个
// SIGPROF 在处理;Linux 用 timer_create 发进程定向信号后,
// 可能打破该假设,靠这把锁兜底。)
for !prof.signalLock.CompareAndSwap(0, 1) { osyield() }
if cpuprof.numExtra+1+len(stk) < len(cpuprof.extra) {
// ↑ 容量检查:1 个长度字 + 栈长 必须放得下
i := cpuprof.numExtra
cpuprof.extra[i] = uintptr(1 + len(stk)) // ← 先写长度字(定长框架)
copy(cpuprof.extra[i+1:], stk) // ← 再拷栈
cpuprof.numExtra += 1 + len(stk) // ← 推进写指针
} else {
cpuprof.lostExtra++ // ← 暂存区满:只计数
}
prof.signalLock.Store(0)
}
谁会调它?sigprofNonGo(signal_unix.go:515)——SIGPROF 落在了不是 Go 运行时创建的线程(典型:cgo 里 pthread_create 的线程)。这种线程没有 g,连 log.write 的参数都凑不齐,所以只存 PC 不做完整回溯(源码注释:"we don't get a full traceback from those threads",每栈按 2 字计)。
七、addExtra:补写与丢失上报
源码位置:cpuprof.go:162-197
func (p *cpuProfile) addExtra() {
hdr := [1]uint64{1}
for i := 0; i < p.numExtra; {
// ↑ 遍历 extra 里的定长框架:length + stack
p.log.write(nil, 0, hdr[:], p.extra[i+1:i+int(p.extra[i])])
// ↑ 时间戳传 0:这些样本的真实时间早已过去,没有意义
i += int(p.extra[i]) // ← 跳到下一条
}
p.numExtra = 0
if p.lostExtra > 0 {
// ↑ 暂存区溢出过的:合成一条"丢失记录"
hdr := [1]uint64{p.lostExtra} // ← 头部 = 丢了几个
lostStk := [2]uintptr{
abi.FuncPCABIInternal(_LostExternalCode) + sys.PCQuantum,
abi.FuncPCABIInternal(_ExternalCode) + sys.PCQuantum,
}
// ↑ 栈是两个"伪函数"PC:最终在火焰图里显示为
// runtime._LostExternalCode —— 你能在真实 profile 里看到它!
p.log.write(nil, 0, hdr[:], lostStk[:])
p.lostExtra = 0
}
if p.lostAtomic > 0 {
// ↑ mips/arm 专属:SIGPROF 恰好打断 64 位原子操作的模拟实现时,
// 为避免死锁直接丢样本并计数(见第九节 sigprof 的特判)
hdr := [1]uint64{p.lostAtomic}
lostStk := [2]uintptr{
abi.FuncPCABIInternal(_LostSIGPROFDuringAtomic64) + sys.PCQuantum,
abi.FuncPCABIInternal(_System) + sys.PCQuantum,
}
p.log.write(nil, 0, hdr[:], lostStk[:])
p.lostAtomic = 0
}
}
+ sys.PCQuantum 是把伪函数 PC 修正成"落在函数体内"的可符号化地址(PCQuantum 为指令对齐粒度)。
动手实验 5(可选,Linux/WSL + gcc):让样本落进 addNonGo/extra 链路
第六、七节的 addNonGo → extra → addExtra 链路,需要一个"非 Go 创建的线程在烧 CPU"。cgo 一行 C 就能造出来(cpu9/main.go,须在 Linux/WSL/macOS 且 CGO_ENABLED=1;记得把公共脚手架 busy/sink 一并放进本文件):
package main
/*
#include <pthread.h>
static void* spin(void* unused) { for(;;){} return 0; } // C 线程纯 CPU 死循环
static void start_spin_thread(void) {
pthread_t t;
pthread_create(&t, 0, spin, 0);
}
*/
import "C"
import (
"os"
"runtime/pprof"
)
func main() {
C.start_spin_thread() // pthread_create 的线程不在 Go 运行时的 M 列表里
f, _ := os.Create("cgo.pprof")
pprof.StartCPUProfile(f)
busy(3000) // 进程定向的 SIGPROF 落在 C 线程 → sigprofNonGo → addNonGo(第六节)
pprof.StopCPUProfile()
f.Close()
}
go run ./cpu9
go tool pprof -top cgo.pprof | grep -iE "external|lost"
# 期望出现:runtime._ExternalCode(占比可观,与 main.busy 分庭抗礼)
看点:C 代码没有 Go 的 unwind 表,回溯失败归入伪函数 _ExternalCode(第六节注释"每栈按 2 字计"的实证);extra 暂存区若攒满 1000 字,_LostExternalCode 也会现身(第七节)。Windows 上此实验必然失败——profileLoop 只巡检 Go 的 M 链表,C 线程根本不在采样范围内(第十一节);要验证只能去 Linux/WSL。这个平台差异本身就是考点。
八、废弃的 CPUProfile 与两个 linkname 后门
源码位置:cpuprof.go:199-258
// CPUProfile panics. ... Deprecated: ...
func CPUProfile() []byte { panic("CPUProfile no longer available") }
// ↑ 旧 API(≤1.8):分块拉取 pprof 原始流。格式已变,直接 panic。
// 教训:外部依赖 runtime 内部格式终会翻车。
两个 //go:linkname 后门(212-258 行)是生态现实的活化石:
//go:linkname pprof_cyclesPerSecond runtime/pprof.runtime_cyclesPerSecond
func pprof_cyclesPerSecond() int64 { return ticksPerSecond() }
// ↑ 供 runtime/pprof 换算 CPU 周期频率。注释点名 pyroscope-go
// 等包在用,动签名会破坏它们(go.dev/issue/67401)。
//go:linkname runtime_pprof_readProfile runtime/pprof.readProfile
func runtime_pprof_readProfile() ([]uint64, []unsafe.Pointer, bool) {
lock(&cpuprof.lock)
log := cpuprof.log // ← 快照 log 指针(关闭后仍可读完)
unlock(&cpuprof.lock)
readMode := profBufBlocking
if GOOS == "darwin" || GOOS == "ios" {
readMode = profBufNonBlocking // #61768:Darwin 的 note 在信号
} // 处理器里不 async-signal-safe
data, tags, eof := log.read(readMode) // ← 阻塞读下一批(04 篇精读)
if len(data) == 0 && eof {
lock(&cpuprof.lock)
cpuprof.log = nil // ← 读干且 EOF:允许开启下一轮剖析
unlock(&cpuprof.lock)
}
return data, tags, eof
}
为什么 log 读干才置 nil 很重要:它解释了第四节"不能连续两次 Start"——只有上一轮数据被
runtime/pprof的读 goroutine 完全消费,cpuprof.log才回到 nil,下一次SetCPUProfileRate(hz>0)才会成功。而runtime/pprof.StopCPUProfile会join 读 goroutine,保证 Stop 返回时已读干——这就是系列第 01 篇"defer Stop 不丢数据"承诺的最后一环。
动手实验 6:读干才许开下一轮 + 废弃 API 的下场
对照组 cpu2b/main.go(import 补 fmt、os、runtime/pprof、time):
func main() {
f1, _ := os.Create("round1.pprof")
fmt.Println("第1轮 Start:", pprof.StartCPUProfile(f1)) // <nil>
f2, _ := os.Create("round2.pprof")
fmt.Println("重复 Start:", pprof.StartCPUProfile(f2)) // 包层防重入:返回 error,不打 stderr
f2.Close()
busy(300)
pprof.StopCPUProfile() // 内部:SetCPUProfileRate(0) + <-cpu.done(join 读 goroutine)
f1.Close()
fmt.Println("第1轮 Stop 完成(log 已被读干置 nil)")
f3, _ := os.Create("round3.pprof")
fmt.Println("再次 Start:", pprof.StartCPUProfile(f3)) // <nil> —— 因为 log 已为 nil
busy(300)
pprof.StopCPUProfile()
f3.Close()
fmt.Println("两轮全部成功")
}
实测输出:
第1轮 Start: <nil>
重复 Start: cpu profiling already in use
第1轮 Stop 完成(log 已被读干置 nil)
再次 Start: <nil>
两轮全部成功
与实验 2 对照:同样的"开→关→开",直调 runtime 失败、走 pprof 成功,而成功的每一步都有源码背书——StopCPUProfile 先 SetCPUProfileRate(0) 再 <-cpu.done(pprof.go:900-901)等待读 goroutine;profileWriter 循环 readProfile 直到 eof(pprof.go:867-887,每轮先 sleep 100ms);runtime_pprof_readProfile 在读干时把 cpuprof.log 置 nil(cpuprof.go:252-256);下一轮第四节 79 行的检查这才放行。两层防重入分工明确:包层 cpu.profiling 管 writer 的并发使用,runtime 层管缓冲区的生命周期。
顺手验证本节开头的废弃 API(cpu6/main.go,import 补 fmt、runtime):
func main() {
defer func() { fmt.Println("recover:", recover()) }()
_ = runtime.CPUProfile() // Deprecated(≤1.8 时代的分块读取接口)
}
// 实测输出: recover: CPUProfile no longer available
下篇预告:本文的(下)篇将进入采集端的「核心采样」——
sigprof()如何在信号里安全回溯栈、setcpuprofilerate()如何安装定时器、以及 Windows 上「睡死的线程不计入」的特殊路径,最后用一张时序图和 8 道自测题收束全篇。关注本号,第一时间收到(下)篇。