cpuprof.go开关、缓冲区与记账

目录

  1. 全链路地图
  2. 常量块:缓冲区大小怎么定
  3. cpuProfile 结构体
  4. SetCPUProfileRate:开与关的完整时序
  5. add:信号处理器里的记账
  6. addNonGo:非 Go 线程的样本
  7. addExtra:补写与丢失上报
  8. 废弃的 CPUProfile 与两个 linkname 后门

一、全链路地图

CPU 剖析的本质,可以浓缩成一句话:一个定时器 + 一个信号处理器 + 一个环形缓冲 + 一个读出 goroutine。

text 复制代码
            【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:跑通最小闭环(对应第一节的地图)

后面所有实验共用一个模块和一段"脚手架",一次性准备:

bash 复制代码
mkdir cpuprof-lab && cd cpuprof-lab
go mod init cpuprof-lab      # 零外部依赖:标准库 + 自带 go tool 就够

cpu0/main.go(完整可复制;其中的 busy/sink 是全部实验的公共脚手架,后文实验只给新增部分,完整文件 = 本例的头部 + 新增函数与 main,import 按需增删):

go 复制代码
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(第五节)
}
bash 复制代码
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

go 复制代码
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):

go 复制代码
// 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()
}
bash 复制代码
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。两个必看的细节:

  1. 23/24 都是 main.deep------同一函数只建一条位置记录,靠重复出现表达递归层级:1×busy + 63×deep;
  2. 栈底(第 64 帧)是 main.deep 而不是 main.main------截断保留的是靠近中断点的 64 帧(leaf 侧),更外层的 main/runtime.main 整段丢弃。想想为什么合理:采样要回答"CPU 烧在哪",答案总在中断点附近,离得越远的栈帧信息价值越低。

统计每条样本行的帧数分布:

bash 复制代码
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

go 复制代码
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

go 复制代码
// 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):

go 复制代码
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):

go 复制代码
func main() {
	f, _ := os.Create("cpu.pprof")
	pprof.StartCPUProfile(f)
	busy(300)
	pprof.StopCPUProfile()
	f.Close()
}
bash 复制代码
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 复制代码
//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,以下为操作清单,未实测。)

bash 复制代码
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug ./cpu0     # 用实验 0 的程序
dlv 复制代码
(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 复制代码
//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

go 复制代码
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 一并放进本文件):

go 复制代码
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()
}
bash 复制代码
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

go 复制代码
// CPUProfile panics. ... Deprecated: ...
func CPUProfile() []byte { panic("CPUProfile no longer available") }
//  ↑ 旧 API(≤1.8):分块拉取 pprof 原始流。格式已变,直接 panic。
//    教训:外部依赖 runtime 内部格式终会翻车。

两个 //go:linkname 后门(212-258 行)是生态现实的活化石:

go 复制代码
//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):

go 复制代码
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):

go 复制代码
func main() {
	defer func() { fmt.Println("recover:", recover()) }()
	_ = runtime.CPUProfile() // Deprecated(≤1.8 时代的分块读取接口)
}
// 实测输出: recover: CPUProfile no longer available


下篇预告 :本文的(下)篇将进入采集端的「核心采样」------sigprof() 如何在信号里安全回溯栈、setcpuprofilerate() 如何安装定时器、以及 Windows 上「睡死的线程不计入」的特殊路径,最后用一张时序图和 8 道自测题收束全篇。关注本号,第一时间收到(下)篇。

相关推荐
特立独行的猫A2 小时前
13MB 单文件、一条命令变服务:把仓颉写的 cjh 内嵌进你的应用,当它的 AI 大脑
java·后端·python
IT_陈寒3 小时前
SpringBoot实战技巧总结
前端·人工智能·后端
TeamDev3 小时前
使用 JxBrowser 在桌面应用中集成 Spring
java·前端·后端·spring·混合应用·jxbrowser·浏览器控件
量化分析码农4 小时前
【Python量化多因子实战 05】因子也会轮动:因子动量与因子反转的择时应用
后端
陈随易4 小时前
Rolldown 把插件改动回传提速 26 倍
前端·后端·程序员
金銀銅鐵13 小时前
为什么枚举类中可以定义抽象方法
java·后端·ai编程
陈随易13 小时前
Vue 编译器换掉 source map,快了三四成
前端·后端·程序员
txxzjmzlh14 小时前
SpringBoot 统⼀功能处理
java·spring boot·后端
nice_lcj52016 小时前
Java Lambda 表达式入门:怎么写、怎么用、常用方法有哪些
java·开发语言·后端·面试