Go CPU 剖析的"地基":信号、CAS、写屏障、无锁环形缓冲


目录

  • B.0 这篇补什么:一张"卡点自查表"
  • B.1 信号:操作系统拍你肩膀的艺术
  • B.2 CAS 与自旋锁:一句"我来"引发的学问
  • B.3 三色标记与写屏障:GC 为什么怕你写指针
  • B.4 单写单读环形缓冲:不需要锁的账本
  • B.5 四块地基拼回 cpuprof.go:总映射表
  • B.6 自测题

B.0 这篇补什么:一张"卡点自查表"

读 03 篇时的常见卡点,与对应地基:

03 篇卡点 缺的地基 去哪补
"信号处理器"到底是什么?它什么时候被谁调用? 信号与异步打断 B.1
signalLock 为什么 CompareAndSwap 一下就拿走,拿不到直接放弃? CAS 与自旋锁 B.2
//go:nowritebarrierrec 为什么存在?写屏障是个啥? 三色标记与写屏障 B.3
prof.signalLock 拿不到为什么是"丢一个样本"而不是"等一等"? B.1 + B.2 B.1/B.2
profbuf.go 凭什么无锁?count & (n-1) 是什么操作? 单写单读环形缓冲 B.4
为什么 add() 里全是裸数组写,一点堆都不碰? B.1 + B.3 B.1/B.3

一个总的观察 :cpuprof.go 的每一处"反直觉"设计,几乎都是在向"我在信号处理器里跑"这个事实妥协。把 B.1 的三条铁律记住,后面三块地基全是铁律的推论。


B.1 信号:操作系统拍你肩膀的艺术

从零开始:你的代码不是唯一在跑的东西

先建立一个世界观:CPU 每秒执行几十亿条指令,你的 Go 程序只是其中一股指令流。真正掌控 CPU 的是操作系统内核------它给你的程序分时间片、处理键盘中断、应付网卡。内核可以在任意两条机器指令之间夺走 CPU,转去干别的,再回来让你的程序若无其事地继续。

信号(signal) 就是内核"叫停你的程序"的机制之一:内核在任意两条指令之间 插入一次跳转,强行执行一个你(或 runtime)事先注册的函数------信号处理器(signal handler)------执行完再回到被打断的位置,就像什么都没发生过。

生活类比:你在流水线拧螺丝(主程序),车间主任(内核)随时拍你肩膀(信号)。你必须在当前这颗螺丝拧完的瞬间停下来,听主任交代事情(信号处理器),然后回去接着拧------而且拧到一半的那颗不能报废。

图 B-1:信号就是操作系统(车间主任)在任意两条指令之间"拍你肩膀"。被打断的代码可能正干到一半,所以信号处理器必须"快进快出"。

Go runtime 对 SIGPROF 的用法:让内核每 10ms 拍一次肩 (定时器到点 → 内核发 SIGPROF),处理器里看一眼"我此刻在执行什么函数、调用栈长什么样",记一笔账就走。这就是 CPU 剖析采样的全部秘密------不是剖析器在循环里不停查,而是内核在随机时刻不停地拍。

铁律:信号处理器里不能做什么

打断点不可预测,这带来三条铁律。cpuprof.go 所有反直觉设计都是这三条的推论:

  1. 不能睡觉/阻塞。你睡觉的那一刻,当前整条线程就停了------而被打断的代码可能正干到一半,持有别的 goroutine 都在等的资源。一等可能就是永远。
  2. 不能用"会等待的锁"。想想:如果被打断的代码正拿着锁 L,你的处理器也要拿 L------死锁,而且和自己的死锁(同一根线程)。唯一安全姿势是"试一下,拿不到立刻走"(B.2 专门讲)。
  3. 尽量别碰堆(GC 的地盘)。GC 可能正在标记到一半(一半对象黑、一半白),处理器里分配对象/改指针 = 往一锅正在熬的汤里加料(B.3 专门讲)。

所以"记一笔就走"是唯一正确姿势:数据结构提前分配好 (裸数组),写入无锁 (CAS / 单写单读),函数不分配、不阻塞、不碰指针语义 。对照 cpuprof.go:add() 只往预分配的缓冲里写定长记录,一个 malloc 都没有。

Windows 例外

Windows 没有 POSIX 信号这套机制。runtime 用一个专职线程 profileLoop 每 10ms 主动"点名":挂起每个线程 → 读它的寄存器/栈 → 走同一条 sigprof → add 路径(见 03 篇 §十一)。效果等价:还是"在任意时刻抓拍正跑的代码",只是"拍肩"换成了"点名"。本系列实验全在 Windows 上跑,但结论两边通用。

配套实验 :03 篇的"动手实验 2"(连开两轮 runtime.SetCPUProfileRate,观察第二轮失败)演示的是同一族问题的另一面------"已开启"标志的防重入;signalLock 的"试一下就走"语义见 B.2。本篇不再重复。


B.2 CAS 与自旋锁:一句"我来"引发的学问

从零开始:多核就是多人同时摸一块黑板

单核时代,同一时刻只有一个人执行,天下太平。多核 = 多个工人同时读写同一块黑板 。麻烦来了:n++ 在机器层面不是一步,是三步:

text 复制代码
读 n 到寄存器 → 寄存器加 1 → 写回 n

工人甲刚"读走 n=5",工人乙也"读走 n=5",各自加 1 各自写回 6------两次自增,黑板上只涨了 1。这叫丢失更新(lost update),是并发 bug 的祖师爷。

图 B-2 :n++ 在机器层面是"读→加→写"三步,多核下会丢更新。CAS 把"看一眼+换"变成不可分割的原子操作------谁 CAS 成功谁拿锁。

CAS:黑板前的一次原子抢答

硬件给了个救星------CAS(Compare-And-Swap,比较并交换),一条 CPU 原子指令,语义是:

"我觉得黑板当前值是 A;如果是,把它换成 B,并告诉我'成功';如果不是,啥也不动,告诉我'失败'。"

整个"看一眼+换"的过程不可分割 ,没有第二个工人能插进来。Go 里就是 sync/atomic 的 CompareAndSwap。

拿锁的完美实现:locked 从 false 改成 true 用一次 CAS------谁 CAS 成功谁拿锁。拿不到怎么办?两个流派:

  • 睡觉流派(mutex) :把自己挂到等待队列,让出 CPU,等解锁时被叫醒。不烧 CPU,但入睡/唤醒有开销(陷入内核、上下文切换,微秒级)。
  • 自旋流派(spinlock) :不睡!立刻再试一次 CAS,失败再试,循环往复("自旋"= 原地打转)。零切换开销,但烧着 CPU 等待。

经验法则:临界区只有几十纳秒、等一下就好 → 自旋划算;可能等很久 → 必须睡,不然烧掉一个核。Go 的 sync.Mutex 是混合流派:先自旋一小会儿,没戏再睡。

铁律视角:信号处理器里只能"试一次,拿不到就放弃"

把 B.1 铁律 2 套进来:信号处理器里拿锁------

  • 睡觉等待?违反铁律 1,直接出局。
  • 自旋等待?持锁的可能恰好是被打断的代码自己 (比如抄账员抄到一半被拍肩)------它要等你让出 CPU 才能接着跑并放锁,而你又在等它放锁。永久卡死。

所以只剩一种姿势:CAS 一次,拿不到立刻放弃 。这正是 cpuprof.add 对 signalLock 的做法(cpuprof.go:106 一带):

go 复制代码
if !prof.signalLock.CompareAndSwap(false, true) {
    return // 拿不到?说明上一笔还在被处理,本样本直接放弃,等下一个 10ms
}

丢一笔账 vs 死锁一笔账,never a question。10ms 后还有下一班车。

映射:cpuprof.go 里的对应物

本篇概念 cpuprof.go / runtime 对应物
CAS 抢锁 prof.signalLock atomic.Boolean + CompareAndSwap
"试一下就走" add() 拿不到 signalLock 直接 return(丢样本)
自旋 + 让出 spinlock.Lock() 里的 runtime.Gosched() ≈ 源码各处的 osyield()
睡觉等待 sync.Mutex(抄账员等数据用的可不是信号侧,是普通世界)

动手实验 B-1:自旋锁的正确性,与它的代价

模块准备(做过附录 A 的话 apx-lab 已存在,直接加子目录即可):

bash 复制代码
mkdir apx-lab && cd apx-lab   # 已存在就 cd 进去
go mod init apx-lab           # 已有 go.mod 就跳过

spin/main.go(完整可复制):

go 复制代码
package main

import (
	"fmt"
	"os"
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

// spinlock:CAS 自旋锁------拿不到就"原地再试",绝不睡觉
type spinlock struct{ locked atomic.Bool }

func (l *spinlock) Lock() {
	for !l.locked.CompareAndSwap(false, true) { // ← 一次 CAS 抢答
		runtime.Gosched() // 让出 CPU 再试(≈ 源码里的 osyield)
	}
}
func (l *spinlock) Unlock() { l.locked.Store(false) }

var sink int
var spins atomic.Int64 // 等待者一共自旋了多少轮(自旋的"代价"直观化)

// part1:自旋锁的正确性------互斥是精确的
func demo() {
	var lk spinlock
	var n int
	var wg sync.WaitGroup
	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				lk.Lock()
				n++ // 非原子自增,全靠锁保护
				lk.Unlock()
			}
		}()
	}
	wg.Wait()
	fmt.Println("part1 正确性:8 goroutine × 1000 次自增 =", n, "(期望 8000)")
}

// part2:持锁者被"冻结"后,等待者的两种姿态
//   mutex:等待者睡觉(不耗 CPU),两个无辜任务各占满一个核
//   spin :等待者原地打转(耗 CPU),3 个 goroutine 抢 2 个核 → 无辜任务减产
func frozen(mode string) {
	runtime.GOMAXPROCS(2) // 两个 P:无辜任务理论上各占一个全速跑

	var mu sync.Mutex
	var sp spinlock
	acquired := make(chan struct{})

	go func() { // 持锁者:拿到锁后永久冻结(≈ 信号打断了持锁者,它醒来前谁也动不了锁)
		if mode == "mutex" {
			mu.Lock()
		} else {
			sp.Lock()
		}
		close(acquired)
		select {}
	}()
	<-acquired

	go func() { // 等待者:等一把永远不会放的锁
		if mode == "mutex" {
			mu.Lock() // 睡眠等待:不耗 CPU
		} else {
			// 纯自旋:不 Gosched、不睡觉,占着核空转烧 CPU 等到世界末日
			for !sp.locked.CompareAndSwap(false, true) {
				spins.Add(1)
			}
		}
	}()

	deadline := time.Now().Add(2 * time.Second)
	var c, d atomic.Int64        // 两个无辜任务:它们的减产量就是等待者烧掉的 CPU
	var sinkC, sinkD int         // 各写各的,无竞争
	go func() {                  // C
		for time.Now().Before(deadline) {
			for i := 0; i < 1000; i++ {
				sinkC += i
			}
			c.Add(1)
		}
	}()
	for time.Now().Before(deadline) { // D(主 goroutine)
		for i := 0; i < 1000; i++ {
			sinkD += i * 2
		}
		d.Add(1)
	}
	sink += sinkC + sinkD
	fmt.Printf("part2 模式=%-5s 两个无辜任务 2 秒合计跑了 %d 轮(等待者自旋 %d 万轮)\n",
		mode, c.Load()+d.Load(), spins.Load()/10000)
}

func main() {
	switch os.Args[1] {
	case "demo":
		demo()
	case "mutex", "spin":
		frozen(os.Args[1])
	}
}
bash 复制代码
go run ./spin demo
go run ./spin mutex
go run ./spin spin

实测输出(go1.24.0 / Windows 11):

输出 复制代码
part1 正确性:8 goroutine × 1000 次自增 = 8000 (期望 8000)
part2 模式=mutex 两个无辜任务 2 秒合计跑了 7598068 轮(等待者自旋 0 万轮)
part2 模式=spin  两个无辜任务 2 秒合计跑了 4006631 轮(等待者自旋 19754 万轮)

解读:

  • part1:CAS 抢答 + 自旋等待,8000 次自增一个不丢------自旋锁作为锁,正确性没问题。
  • part2 :持锁者被冻结(这是对"信号打断了持锁者"的模拟)。mutex 模式下等待者睡觉,无辜任务满速 759 万轮;spin 模式下等待者纯自旋,3 个 goroutine 抢 2 个核,无辜任务掉到 400 万轮 ------等待者空转了近 20 亿轮 CAS,烧掉的 CPU 一半本来是无辜任务的。这就是信号处理器里"绝不自旋、绝不睡觉"的物证:等待者要是 sigprof 本身,烧的是你正在剖析的程序,还可能永远等不到。

(细节:spin 模式若改成"每轮自旋都 Gosched 让出",两个无辜任务几乎不减产------让出式自旋太"礼貌",占不住核。真实信号处理器里没有 Gosched 这种好事,所以姿势只剩"CAS 一次,失败即走"。)


B.3 三色标记与写屏障:GC 为什么怕你写指针

从零开始:GC 怎么判断"谁是垃圾"

Go 的 GC 思路:从"根"(全局变量、各 goroutine 栈上的指针等一定活着的东西)出发,顺着指针把摸得到的对象 全部标记为活的;一轮摸完,没被摸到的就是垃圾,回收。

标记过程用三色抽象管理进度,这是并发标记的标准心智模型:

  • 白:还没被发现。标记结束时还是白 → 判定为垃圾。
  • 灰:发现了,但它的孩子(它指向的对象)还没查完。放在"待查队列"里。
  • 黑 :我和我的孩子都查完了。黑对象不会再被看第二眼------这是效率的来源,也是事故的来源。

算法就是一句话:从队列里逐个取出灰对象,把它的白孩子染灰入队,自己染黑;队列空了,标记结束。

图 B-3:白=还没被发现,灰=已发现但孩子没查完(在待查队列),黑=自己和所有孩子都查完了。黑对象不再被看第二眼,既是效率来源也是漏标事故的来源。

事故:标记和你同时跑

麻烦在于 Go 的 GC 是并发 的------标记的同时你的程序还在跑(否则每次 GC 全世界暂停,程序没法用)。你的程序每次执行 a.x = c 这样的指针写,都在修改 GC 正在遍历的地图。漏标事故的经典配方只需要两步:

  1. 黑 A 拿到白 C 的指针(A 已查完,GC 不会再回头看 A,所以发现不了 C);
  2. 灰 B 到白 C 的旧路被掐断(B 还没查,但等查它时 C 已经不在它的孩子里了)。

两步都发生 → C 从此无人知晓 → 标记结束仍为白 → 被当垃圾回收 。可 A 里明明还存着 C 的指针------悬空指针,随后就是内存错乱。注意单独任何一步都无害(只有 1,C 还是能从 B 摸到;只有 2,C 本来就该没人引用),必须两步同时发生------这正是写屏障要盯的死窗口。

生活类比:图书管理员逐排登记藏书(标记),每登记完一排就挂"此排已完"的牌子(染黑)。你(读者,mutator)趁他背过身,把一本书从还没登记的书架 (灰 B)塞进已挂牌的书架(黑 A),还把它原来的位置清空。管理员永远登记不到这本书 → 当废品处理。可你手里还捏着它的借书卡(黑 A 里的指针)。

写屏障:每次指针写都过一道岗哨

写屏障(write barrier) = 编译器在每次指针写操作 前后插进的一小段代码,像一个岗哨:凡是"黑/灰对象拿到白对象指针"这种危险写法,岗哨立刻把那个白对象染灰入队 (术语叫 shade)------保证它不会被漏掉,代价是那一轮 GC 多查几个对象。Go 1.8+ 用的是混合写屏障,细节不展开;你只需要记住:指针写不再是免费的,背后有个岗哨。

铁律视角:信号处理器为什么连写屏障都不许有

回到 B.1 铁律 3,现在可以说精确了:add() 运行在信号处理器里,而信号可能打断岗哨自己 、打断 GC 内部记账的中间状态。写屏障代码假设自己在"正常世界"运行(可能要入队、拿 GC 内部的锁),在信号里重入它 = 在岗哨执勤时把岗哨调走。所以 Go 干脆给了 //go:nowritebarrierrec:编译期检查------这个函数以及它调用的所有函数,调用链上禁止出现任何写屏障,出现即编译报错 。add() 从根上被摁住:零分配、零指针写屏障,只写提前分好的裸数组。

动手实验 B-2:亲手制造一次漏标,再看岗哨救场

mark/main.go(完整可复制;纯单线程确定性模拟,没有真 GC 参与,专门把事故窗口演给你看):

go 复制代码
package main

import "fmt"

// 三色:0=白(未发现) 1=灰(已发现,孩子没查完) 2=黑(查完)
type obj struct {
	name     string
	color    int
	children []*obj
}

var cname = map[int]string{0: "白", 1: "灰", 2: "黑"}

func show(title string, objs []*obj) {
	fmt.Print(title)
	for _, o := range objs {
		fmt.Printf("  %s(%s)->[", o.name, cname[o.color])
		for i, ch := range o.children {
			if i > 0 {
				fmt.Print(" ")
			}
			fmt.Print(ch.name)
		}
		fmt.Print("]")
	}
	fmt.Println()
}

// popScan:从灰队列取一个对象,把它的白孩子染灰入队,自己染黑
func popScan(grey *[]*obj) *obj {
	o := (*grey)[0]
	*grey = (*grey)[1:]
	for _, ch := range o.children {
		if ch.color == 0 {
			ch.color = 1
			*grey = append(*grey, ch)
		}
	}
	o.color = 2
	return o
}

func run(withBarrier bool) {
	// 对象图:root → {A, B};B → C。C 只有 B 这一条路能被摸到。
	root := &obj{name: "root"}
	a := &obj{name: "A"}
	b := &obj{name: "B"}
	c := &obj{name: "C"}
	root.children = []*obj{a, b}
	b.children = []*obj{c}

	fmt.Printf("\n===== %s =====\n", map[bool]string{false: "无屏障:漏标事故现场", true: "有屏障:岗哨救场"}[withBarrier])
	grey := []*obj{root}
	root.color = 1
	show("初始          ", []*obj{root, a, b, c})

	popScan(&grey) // 扫 root → root 黑,A/B 灰
	show("扫完 root     ", []*obj{root, a, b, c})

	popScan(&grey) // 扫 A → A 黑(A 此刻没有孩子)
	show("扫完 A        ", []*obj{root, a, b, c})

	// ---- mutator 窗口:标记进行中,程序做了两次指针写 ----
	a.children = append(a.children, c) // 黑 A → 白 C!
	b.children = nil                   // 灰 B → 白 C 的旧路被掐断
	if withBarrier {                   // 写屏障:A.field=C 触发 shade(C)
		if c.color == 0 {
			c.color = 1
			grey = append(grey, c)
		}
	}
	show("mutator 写指针后", []*obj{root, a, b, c})

	popScan(&grey) // 扫 B:孩子已被掐空,B 黑
	for len(grey) > 0 { // 有屏障时队列里还躺着 C;无屏障时队列已空
		popScan(&grey)
	}
	show("标记结束      ", []*obj{root, a, b, c})

	fmt.Println("判定垃圾(未染黑即回收):")
	for _, o := range []*obj{root, a, b, c} {
		if o.color != 2 {
			fmt.Printf("  回收 %s", o.name)
			if !withBarrier {
				fmt.Printf("  ← 但 A.children 里还躺着 C 的指针:悬空!")
			}
			fmt.Println()
		}
	}
}

func main() {
	run(false) // 先看事故
	run(true)  // 再看岗哨怎么救
}
bash 复制代码
go run ./mark

实测输出(go1.24.0 / Windows 11):

输出 复制代码
===== 无屏障:漏标事故现场 =====
初始            root(灰)->[A B]  A(白)->[]  B(白)->[C]  C(白)->[]
扫完 root       root(黑)->[A B]  A(灰)->[]  B(灰)->[C]  C(白)->[]
扫完 A          root(黑)->[A B]  A(黑)->[]  B(灰)->[C]  C(白)->[]
mutator 写指针后  root(黑)->[A B]  A(黑)->[C]  B(灰)->[]  C(白)->[]
标记结束        root(黑)->[A B]  A(黑)->[C]  B(黑)->[]  C(白)->[]
判定垃圾(未染黑即回收):
  回收 C  ← 但 A.children 里还躺着 C 的指针:悬空!

===== 有屏障:岗哨救场 =====
初始            root(灰)->[A B]  A(白)->[]  B(白)->[C]  C(白)->[]
扫完 root       root(黑)->[A B]  A(灰)->[]  B(灰)->[C]  C(白)->[]
扫完 A          root(黑)->[A B]  A(黑)->[]  B(灰)->[C]  C(白)->[]
mutator 写指针后  root(黑)->[A B]  A(黑)->[C]  B(灰)->[]  C(灰)->[]
标记结束        root(黑)->[A B]  A(黑)->[C]  B(黑)->[]  C(黑)->[]
判定垃圾(未染黑即回收):

解读:

  • 第一轮(无屏障) :黑 A 拿到白 C、灰 B 掐断旧路------两步同发,队列空了 C 还是白 → C 被回收,而 A 里还留着它的指针。这就是写屏障要防的内存事故的完整因果链。
  • 第二轮(有屏障) :同一串扫描、同一串指针写,唯一区别是写 A 的字段时岗哨把 C 染了灰(输出第 4 行 C(灰))→ C 入队 → 标记结束全黑,C 存活。
  • 映射回源码 ://go:nowritebarrierrec 不是"add 里的写不安全所以要拦",而是"add 连岗哨都不能惊动"------它跑在信号里,岗哨的账本(GC 内部队列)可能正处于任何中间状态。

B.4 单写单读环形缓冲:不需要锁的账本

从零开始:两个速度不同的人怎么共用一本账

cpuprof 的世界里有两个角色,速度天生不匹配:

  • 记账员 (信号处理器 add):每 10ms 记一笔,必须立刻记完走人(铁律),绝不能等任何人;
  • 抄账员 (profileWriter goroutine):每 100ms 醒一次,把攒的账批量抄走、编码、写盘。

中间必须有本子兜着差距。朴素方案是加锁:记账前拿锁、抄账前拿锁。但铁律 2 说记账员不能等锁。于是有了并发编程里一个精巧的解法:让"锁"消失,代价是各自的计数器只能自己写。

环形缓冲 + 各写各的计数器

结构:一个固定长度的数组(想象首尾相接成环),外加两个计数器:

text 复制代码
buf  [N]uint64   ← 环形数组,N 是 2 的幂
wcnt             ← 写者已写总笔数(只有写者能改!)
rcnt             ← 读者已读总笔数(只有读者能改!)

规则:

  • 写者 :w - r == N 说明写者绕一圈追上了读者 → 满 ,这笔丢掉并给溢出计数 +1(绝不等待!);否则写进 buf[w & (N-1)],然后 w++。
  • 读者 :r == w 说明空,没账可抄;否则读 buf[r & (N-1)],然后 r++。

为什么不需要锁?每个共享变量恰好只有一个写入者 (wcnt 只有写者碰,rcnt 只有读者碰,数组每个格子从写到读之间只有一对一交接),读写计数器用原子指令(RISC/CISC 各有便宜的原子读),就再没有"两个工人摸同一块黑板"的局面。这叫 SPSC (Single-Producer Single-Consumer)无锁队列------无锁不是魔法,是通过"单一写者"约定把冲突从根上消灭。

图 B-4 :环形数组 + 两个只属于自己的计数器(写者 wcnt、读者 rcnt)。写者绕一圈追上读者说明"满",直接丢笔;下标用 & (N-1) 取代除法取模。

两个精妙细节:

  • 容量取 2 的幂 :下标本来要算 w % N(除法,几十个时钟周期),改成 w & (N-1)(与运算,1 个时钟周期)。代价只是 N 必须是 2 的幂------cpuprof.go 里 profBufWordCount = 1 << 17 就是这么来的。
  • 满时丢笔而非等待 :这是铁律 1 的直接推论。丢的笔会累计到溢出计数器,最后作为"丢失样本"上报------宁可账本缺一笔,不能流水线卡一秒。映射:非 Go 线程样本跟不上时的 _LostExternalCode 机制(03 篇 §七)。

映射:cpuprof 的真身

本篇玩具 runtime 真身(profbuf.go)
buf [64]uint64 profBuf.data(1<<17 词,还分 data/tags 两层)
wcnt/rcnt 写读两侧各自维护的 count(配合翻面 publish 机制)
overflow 计数 profBuf.overflow,读出时变成 _LostExternalCode 记录
丢笔不等待 信号侧写缓冲的全部哲学

真身比玩具精巧(双缓冲翻面、tag/普通记录两种槽位、profBufDead 状态机),04 篇逐行拆;本篇建立直觉即可:一本两个人各写各计数器的环形账本,满了就撕,绝不锁。

动手实验 B-3:快慢两个抄账员,亲眼看丢笔

ring/main.go(完整可复制):

go 复制代码
package main

import (
	"fmt"
	"os"
	"sync/atomic"
	"time"
)

// 迷你 profBuf:固定容量环形账本,一个写者 + 一个读者,全程无锁
const N = 64 // 2 的幂!(真身是 1<<17)

type ring struct {
	buf      [N]uint64
	wcnt     atomic.Uint64 // 写者推进的总笔数(只由写者改)
	rcnt     atomic.Uint64 // 读者推进的总笔数(只由读者改)
	overflow atomic.Uint64 // 没抄走就被迫撕掉的笔数
}

func (r *ring) write(v uint64) bool {
	w, rd := r.wcnt.Load(), r.rcnt.Load()
	if w-rd >= N { // 写者绕一圈追上读者:账本满
		r.overflow.Add(1) // 只能丢这笔并计数(→ _LostExternalCode 的机制本质)
		return false
	}
	r.buf[w&(N-1)] = v // &(N-1) 取下标:容量必须 2 的幂的原因
	r.wcnt.Store(w + 1)
	return true
}

func (r *ring) read() (uint64, bool) {
	rd, w := r.rcnt.Load(), r.wcnt.Load()
	if rd == w {
		return 0, false // 账本空
	}
	v := r.buf[rd&(N-1)]
	r.rcnt.Store(rd + 1)
	return v, true
}

func main() {
	readerSpeed := 100 // 笔/秒
	if len(os.Args) > 1 && os.Args[1] == "slow" {
		readerSpeed = 40
	}
	var r ring
	deadline := time.Now().Add(3 * time.Second)

	go func() { // 写者:每 10ms 记一笔(≈ 100Hz 的信号处理器)
		t := time.NewTicker(10 * time.Millisecond)
		defer t.Stop()
		for time.Now().Before(deadline) {
			<-t.C
			r.write(uint64(time.Now().UnixNano()))
		}
	}()
	go func() { // 读者:按 readerSpeed 抄账(≈ profileWriter,真身每 100ms 醒一次)
		t := time.NewTicker(time.Second / time.Duration(readerSpeed))
		defer t.Stop()
		for time.Now().Before(deadline) {
			<-t.C
			r.read()
		}
	}()

	time.Sleep(3*time.Second + 200*time.Millisecond)
	fmt.Printf("读者 %d 笔/秒(写者恒 100):写出=%d 抄走=%d 撕掉(丢)=%d\n",
		readerSpeed, r.wcnt.Load(), r.rcnt.Load(), r.overflow.Load())
}
bash 复制代码
go run ./ring        # 快读者:100 笔/秒,跟得上写者
go run ./ring slow   # 慢读者:40 笔/秒,跟不上

实测输出(go1.24.0 / Windows 11):

输出 复制代码
读者 100 笔/秒(写者恒 100):写出=300 抄走=299 撕掉(丢)=0
读者 40 笔/秒(写者恒 100):写出=184 抄走=120 撕掉(丢)=116

解读:

  • 快读者 :读者每秒抄 100 笔 ≥ 写者速度,账本永远只有一两笔存货,3 秒 300 笔只丢 0 笔(那 1 笔差值是起停相位,非溢出)。这就是正常运行时的 cpuprof:profileWriter 足够勤快,你从没听过 CPU 剖析丢样本。
  • 慢读者 :写者恒 100 笔/秒,读者只抄得动 40 笔/秒,缺口 60 笔/秒。前 ~1 秒靠 64 格本子硬扛(存量 64),本子写满后写者只能"读者抄一笔、我写一笔",实际写出降到读者的速度;3 秒共尝试 300 笔,成功 184、抄走 120、撕掉 116 (184+116=300,一笔不差)。撕掉的 116 笔,就是剖析报告里"lost samples"的诞生过程。
  • 注意整个程序没有一把锁:写者和读者从没互相等待过,靠"各写各的计数器"相安无事------这就是 B.4 标题的含金量。

B.5 四块地基拼回 cpuprof.go:总映射表

把四块地基按 03 篇的源码顺序拼回去。每一行都可以这样读:源码写法 ← 地基铁律。

cpuprof.go 位置 源码做了什么 踩的是哪块地基
§四 SetCPUProfileRate 装/拆定时器(POSIX)或启停 profileLoop(Windows) B.1:信号由内核定时触发
§五 add:106 一带 signalLock.CompareAndSwap 拿不到直接 return B.2:CAS-once,绝不自旋绝不睡
§五 add 全函数 零分配,只写预分配数组;//go:nowritebarrierrec B.1 铁律 3 + B.3:不能惊动写屏障/GC
§五 add:计数递增、写 tagPtr、写栈帧 定长记录写入环形缓冲 B.4:SPSC 无锁账本
§七 addExtra/addNonGo 溢出计数 → _LostExternalCode B.4:满了撕笔不等待
§八 readProfile signalLock 短暂置真,抄空缓冲,置 log=nil B.1+B.2:抄账员与记账员的唯一同步点
§九 sigprof(proc.go) 收 SIGPROF → 抓当前栈 → 调 add B.1:被拍肩的是谁,记的就是谁

一句话总结 :cpuprof.go 是把"信号处理器的三条铁律"翻译成数据结构设计的一篇范文------锁变成 CAS-once(B.2),堆变成预分配数组(B.3),队列变成 SPSC 环形账本(B.4),而这一切的起点是"内核每 10ms 拍一次肩"(B.1)。

相关推荐
panbook43 分钟前
从0到1实现一个分布式时序数据库(二):写入成功之后,如何用 WAL 找回数据
后端
Swot1 小时前
上传一张 4MB 图片,我先后撞上了三层请求体限制
后端
IT_陈寒1 小时前
Vite的HMR把我整不会了:一个生产环境的热更新坑
前端·人工智能·后端
OLRainM1 小时前
实习复盘:大模型网关渠道质量评分实战(二)—— 报错了到底算谁的锅?流式归因与 Token 补账踩过的 8 个真坑
后端
梦想恒远2 小时前
Spring Boot + LangChain4j 统计 token 成本:ChatModelListener 实测与单价口径
spring boot·后端
努力的小郑2 小时前
数据查到了,回答去哪儿了?一次百炼 + LangChain 排查实录
后端·langchain·agent
isfox3 小时前
Python 虚拟环境:别再把所有项目塞进一个"大仓库"了
后端
易师傅3 小时前
技术面试已死?AI 时代,真正拉开程序员差距的是什么?
前端·后端·程序员
苍何3 小时前
办公 Agent 权威评测榜单出炉了,豆包第一
后端