profbuf.go —— 单写单读无锁环形缓冲

0 阅读14分钟

目录

  • 一、它要解决什么问题
  • 二、文件头注释:协议总纲(12-86 行)
  • 三、类型与字段(87-126 行)
  • 四、计数运算三件套(128-149 行)
  • 五、溢出的存取协议(151-203 行)
  • 六、newProfBuf:2 的幂强制(205-233 行)
  • 七、canWriteRecord / canWriteTwoRecords(235-296 行)
  • 八、write:写路径全解(298-390 行)
  • 九、close 与 wakeupExtra(392-417 行)
  • 十、read:读路径全解(429-557 行)

一、它要解决什么问题

第 03 篇里,cpuprof.add 在信号处理器里被调用——那是全进程最恶劣的执行环境:没有 g、不能分配、不能拿普通锁、栈只有几 KB。而消费数据的是 runtime/pprof 里的普通用户 goroutine。两者之间需要一个满足全部约束的通道:

约束profBuf 的对策
写者可能在任何指令间打断任何人写路径只用原子 store/CAS,无锁
恰好 1 写者(信号串行)× 1 读者不需要多写者仲裁,索引协议足够
记录变长(栈 0~64 帧)长度字 + 定长头 + 变长栈的 framing
缓冲会满满则丢弃 + 溢出计数,事后合成"丢失记录"
无数据时读者要睡,来数据要被叫醒profReaderSleeping 标志位 + runtime note
环形缓冲回绕总读写次数 mod 2^n 与 mod len 对齐(长度取 2 的幂)

CPU profile 用它,runtime/pprof 的 profileWriter 是它的唯一读者(经 readProfile)。

IMG-4-roles.png 图 4-1 角色与环境约束:信号处理器是无 g 的写者,用户 goroutine 是读者,二者通过 data / tags 两个环共享数据。


二、文件头注释:协议总纲(12-86 行)

文件头 75 行注释是协议的"合同文本",分六段,逐段翻译:

① 角色定义(12-15 行):"profBuf 是剖析事件的无锁缓冲,并发安全限于一个读者 + 一个写者;写者可以是无 g 的信号处理器;读者假定是用户 g。"——超出这个并发度的使用是未定义行为。

② 记录格式(17-24 行):每条事件 = 定长头(hdrsize 字)+ uintptr 列表(栈)+ 恰好 1 个 unsafe.Pointer 标签。在 data 环里占 2+hdrsize+len(stk) 字,布局:

┌─────────┬──────────┬───────────────┬────────────────┐
│ 长度字   │ 时间戳    │ hdr(定长,1字) │ stk(变长 0..64) │   data 环
│ 2+h+n   │ uint64   │ 零填充        │ PC,PC,PC...    │
└─────────┴──────────┴───────────────┴────────────────┘
     ↕ 一一对应
┌────────────────────────┐
│ tag: *unsafe.Pointer    │                            tags 环
└────────────────────────┘

IMG-4-record.png 图 4-2 记录格式:data 环的每个槽 = 长度字 + 时间戳 + 定长头 + 变长栈;tags 环每记录一格,与 data 记录一一对应。

③ 读写索引的打包(26-32 行):"tags 与 data 两个环的有效读写偏移,打包存进 r、w 两个 64 位字的高 30 位和低 32 位。"有效偏移 = 历史总读/写次数(不取模)。定位下标 = 有效偏移 mod 环长。"为让 mod 2^length 的回绕与 mod 环长的回绕一致,环长必须是 2 的幂。"

63        34 33   32 31              0
┌──────────┬───────┬──────────────────┐
│ tagCount │ flags │    dataCount     │
└──────────┴───────┴──────────────────┘
   30 位      2 位        32 位

IMG-4-index.png

图 4-3 64 位打包索引:高 30 位 tagCount + 中间 2 位 flags + 低 32 位 dataCount,三段互不浪费。

④ 读者进度(34-39 行):追上写者时,read 可选阻塞;rNext 记录"下次 read 将返回的偏移",恒有 r ≤ rNext ≤ w,只被读者碰,无需原子。

⑤ 溢出记录(41-57 行):写者超前导致环满 → 后续写入被丢弃并聚合成一条溢出记录:大小 2+hdrsize+1、时间为首个被丢事件的时间、头全零、"栈"是 1 个字 = 丢弃条数。溢出记录先"挂起"(pending)在 overflow/overflowTime 字段里,写者或读者谁先腾出地方谁负责把它落成真实记录。

⑥ ABA 防护(59-86 行):overflow 的低 32 位是被丢条数,高 32 位是代数(generation):每次低 32 位从 0 变非 0(或反向)代数 +1。读者用"CAS 清零成功 = 我抢到了这条溢出"来裁决,代数保证 CAS 比较的 old 不会"恰好又变回原值"(经典 ABA)。注释 72-86 行给出了读者取走溢出的参考循环——就是后面 takeOverflow 的原文。


三、类型与字段(87-126 行)

type profBuf struct {
	// accessed atomically
	r, w         profAtomic  // ← 打包索引:读者已提交位 / 写者已发布位
	overflow     atomic.Uint64  // ← 低32:待上报丢弃数;高32:代数
	overflowTime atomic.Uint64  // ← 首个被丢事件的时间(仅低32为0时可写)
	eof          atomic.Uint32  // ← close() 置 1

	// immutable (excluding slice content)
	hdrsize uintptr
	data    []uint64          // ← 主环:头+时间+栈
	tags    []unsafe.Pointer  // ← 标签环:每记录 1 格,与 data 记录一一对应

	// owned by reader
	rNext       profIndex   // ← 读者的私有进度(见协议④)
	overflowBuf []uint64    // ← 读者合成溢出记录用的工作区
	wait        note        // ← runtime note:读者睡觉/被唤醒的原语
}

type profAtomic uint64   // ← 对原子访问的 profIndex 的包装
type profIndex uint64    // ← 打包索引本身(值类型)

const (
	profReaderSleeping profIndex = 1 << 32 // ← 读者已睡,写者发布后要唤醒
	profWriteExtra     profIndex = 1 << 33 // ← 有"额外信息"(溢出/EOF)待读者注意
)

func (x *profAtomic) load() profIndex { return profIndex(atomic.Load64(...)) }
func (x *profAtomic) store(new profIndex) { atomic.Store64(...) }
func (x *profAtomic) cas(old, new profIndex) bool { return atomic.Cas64(...) }
//  ↑ 三个瘦封装:语义化 + 防止误用普通读写。

注意 flags 的位置:1<<32、1<<33 恰好挤在 dataCount(低 32 位)与 tagCount(34 位起)之间的"夹缝"里——64 位被切成了 32+2+30 三段,一格不浪费。


四、计数运算三件套(128-149 行)

func (x profIndex) dataCount() uint32 { return uint32(x) }         // 取低 32 位
func (x profIndex) tagCount() uint32  { return uint32(x >> 34) }    // 取 34 位起

// countSub:x-y,要求差值 < 2^29。
// tagCount 在 2^30 处回绕、dataCount 在 2^32 处回绕,
// "(x-y) 的低 30 位符号扩展"对两种回绕同时成立:
func countSub(x, y uint32) int {
	return int(int32(x-y) << 2 >> 2)
//       └─ 低30位有符号数:×4 再算术右移 2 = 保留低 30 位的符号
}

为什么要"差值 < 2^29"这个前提?因为环长被限制在 2^28 以内(newProfBuf 的 throw),r 与 w 的距离不可能超过环长,所以差值天然有界,30 位够放。

// addCountsAndClearFlags:x + (data,tag) 个事件,同时抹掉两个 flag 位。
// 写者提交新记录时用它原子地"推进 w 并清 flags":
func (x profIndex) addCountsAndClearFlags(data, tag int) profIndex {
	return profIndex((uint64(x)>>34+uint64(uint32(tag)<<2>>2))<<34 | uint64(uint32(x)+uint32(data)))
}

读法:右半 uint32(x)+uint32(data) —— dataCount 原地加(dataCount 本来就在低 32 位,flag 位被加法"顺带"清掉了吗?不——flag 在 32、33 位,加 data 只动低 32 位,flag 仍在!)。再看左半:tagCount 部分 >>34 取出后加 tag 再放回 34 位以上。flag 位既没被右半清(加法不进位到 32 位,因为 dataCount 距回绕极远)也没被左半动(>>34 丢掉了 32、33 位,重打包时它们天然变 0)——"ClearFlags"就是这么免费实现的。


五、溢出的存取协议(151-203 行)

func (b *profBuf) hasOverflow() bool {
	return uint32(b.overflow.Load()) > 0     // 低 32 位非 0 = 有挂起的溢出
}

// takeOverflow:读者侧取走挂起的溢出(写者也可能来抢着落盘)
func (b *profBuf) takeOverflow() (count uint32, time uint64) {
	overflow := b.overflow.Load()
	time = b.overflowTime.Load()      // ← 先取时间(可能读到旧值,见下)
	for {
		count = uint32(overflow)
		if count == 0 { time = 0; break }
		// CAS:清空低 32 位计数、代数 +1 —— 谁 CAS 成功谁取走
		if b.overflow.CompareAndSwap(overflow, ((overflow>>32)+1)<<32) {
			break
		}
		overflow = b.overflow.Load()  // ← CAS 失败 = 有并发,重读再来
		time = b.overflowTime.Load()
	}
	return uint32(overflow), time
}

// incrementOverflow:写者侧记一笔丢弃(与读者竞争)
func (b *profBuf) incrementOverflow(now int64) {
	for {
		overflow := b.overflow.Load()
		if uint32(overflow) == 0 {
			// 当前无挂起溢出 → 我是"从 0 变非 0"的那个:
			b.overflowTime.Store(uint64(now))  // ① 先写时间
			b.overflow.Store((((overflow>>32)+1)<<32) + 1) // ② 再立起新代+计数1
			// 顺序保证:计数非 0 时时间一定已就位(incrementOverflow
			// 单写者,store 顺序即可见顺序)
			break
		}
		if int32(overflow) == -1 { break }
		// ↑ 低32位全是1:粘性饱和值,防极端情况下回绕(字面意思的 paranoid)
		if b.overflow.CompareAndSwap(overflow, overflow+1) { break }
		// ↑ 与读者的"取走"(清零+换代)竞争,输了重试
	}
}

代数(generation)在这里的价值:读者 CAS 的比较值包含高 32 位。假设没有代数,读者看到 count=5 → 被写者改成 6 → 又变回 5,读者的 CAS 会错误成功(ABA),导致同一批丢弃被上报两次。有了代数,"5 → 0 → 5"实际是 (gen,5) → (gen+1,0) → (gen+2,5),比较永不通过。

IMG-4-aba.png

图 4-4 溢出计数的 ABA 防护:低 32 位计数的每一次"0↔非0"翻转都让高 32 位代数 +1,使读者的 CAS 比较值永不"恰好变回原值"。


六、newProfBuf:2 的幂强制(205-233 行)

func newProfBuf(hdrsize, bufwords, tags int) *profBuf {
	if min := 2 + hdrsize + 1; bufwords < min {
		bufwords = min   // ← 至少放得下一条最小记录(长度+时间+头+1字栈)
	}
	if bufwords >= 1<<28 || tags >= 1<<28 {
		throw("newProfBuf: buffer too large")
		// ↑ 2^28 上限:给 countSub 的 2^29 差值假设留裕量
	}
	var i int
	for i = 1; i < bufwords; i <<= 1 { }
	bufwords = i          // ← 向上取 2 的幂(协议③的要求)
	for i = 1; i < tags; i <<= 1 { }
	tags = i

	b := new(profBuf)
	b.hdrsize = uintptr(hdrsize)
	b.data = make([]uint64, bufwords)
	b.tags = make([]unsafe.Pointer, tags)
	b.overflowBuf = make([]uint64, 2+b.hdrsize+1) // ← 恰好一条溢出记录的容量
	return b
}

CPU 剖析传入 (1, 1<<17, 1<<14) ——已经都是 2 的幂,循环是空转,但 API 层面不假设调用者守规矩。


七、canWriteRecord / canWriteTwoRecords(235-296 行)

// 环里还放不放得下一条"栈长 nstk"的连续记录?
func (b *profBuf) canWriteRecord(nstk int) bool {
	br := b.r.load()
	bw := b.w.load()

	if countSub(br.tagCount(), bw.tagCount())+len(b.tags) < 1 {
		return false       // ← 标签环满(tags 是"已写-已读"的差)
	}

	nd := countSub(br.dataCount(), bw.dataCount()) + len(b.data)
	                     // ↑ data 环的剩余字数
	want := 2 + int(b.hdrsize) + nstk
	i := int(bw.dataCount() % uint32(len(b.data)))
	if i+want > len(b.data) {
		// 记录必须连续。尾部放不下 → 只能跳到环头,尾部作废:
		nd -= len(b.data) - i
	}
	return nd >= want
}

canWriteTwoRecords(258-296 行)是它的双记录版:先按 nstk1 走一遍(放不下尾部则归零重来),再按 nstk2 走一遍,两条各自连续但不必彼此连续。它只服务一个调用点:write 里"溢出记录 + 新记录"能否一起写。


八、write:写路径全解(298-390 行)

IMG-4-write.png

图 4-5 write 三分支全景:① 有挂起溢出且放得下两条→替读者落盘;② 环满或仍有溢出→本条丢弃并计数;③ 正常写入→先写数据、最后一次 CAS 原子提交。

func (b *profBuf) write(tagPtr *unsafe.Pointer, now int64, hdr []uint64, stk []uintptr) {
	if b == nil { return }                 // ← nil 安全:关闭后 add 仍可能被叫
	if len(hdr) > int(b.hdrsize) {
		throw("misuse of profBuf.write")   // ← 头超长是编程错误,直接崩
	}

	if hasOverflow := b.hasOverflow(); hasOverflow && b.canWriteTwoRecords(1, len(stk)) {
		// 分支一:有挂起溢出,且放得下"溢出+新记录"两条 → 顺手替读者落盘
		count, time := b.takeOverflow()
		if count > 0 {
			var stk [1]uintptr
			stk[0] = uintptr(count)
			b.write(nil, int64(time), nil, stk[:])
			// ↑ 递归写溢出记录:头传 nil(零填充)、栈 = [丢弃数]。
			//   递归深度恒为 1:这条记录自身不会再触发溢出路径
			//   (hasOverflow 已被 takeOverflow 清掉)。
		}
	} else if hasOverflow || !b.canWriteRecord(len(stk)) {
		// 分支二:有溢出但腾不出地方,或环满 → 本条丢弃,计数 +1
		b.incrementOverflow(now)
		b.wakeupExtra()          // ← 唤醒读者("有 extra 信息了")
		return
	}

	// ===== 分支三:正常写入 =====
	br := b.r.load()
	bw := b.w.load()

	// -- 标签:339-348 行的注释是写屏障安全性的论证,要点:
	//   信号处理器"代替"被中断的 g 执行,gp.labels 在此期间无人能改;
	//   未来任何 GC 扫描 b.tags(堆对象)都能看到这个指针;
	//   且写者保证只在 nil 槽上写 → 无需删除屏障。
	wt := int(bw.tagCount() % uint32(len(b.tags)))
	if tagPtr != nil {
		*(*uintptr)(unsafe.Pointer(&b.tags[wt])) = uintptr(*tagPtr)
		// ↑ 裸指针直写绕过写屏障(信号环境禁屏障);
		//   读回时 readProfile 侧再 unsafe.Pointer 还原
	}

	// -- 主记录:
	wd := int(bw.dataCount() % uint32(len(b.data)))  // 写下标
	nd := countSub(br.dataCount(), bw.dataCount()) + len(b.data)
	skip := 0
	if wd+2+int(b.hdrsize)+len(stk) > len(b.data) {
		b.data[wd] = 0            // ← rewind 标记:0 永不可能是合法长度字
		skip = len(b.data) - wd   // ← 尾部作废的字数
		nd -= skip
		wd = 0                    // ← 跳回环头
	}
	data := b.data[wd:]
	data[0] = uint64(2 + b.hdrsize + uintptr(len(stk)))  // 长度字
	data[1] = uint64(now)                                // 时间戳
	i := copy(data[2:2+b.hdrsize], hdr)                  // 头(可能不足)
	clear(data[2+i : 2+b.hdrsize])                       // 头零填充
	for i, pc := range stk {
		data[2+b.hdrsize+uintptr(i)] = uint64(pc)        // 栈逐字写入
	}

	for {
		// -- 原子提交:推进 w(data += skip+2+h+n,tag += 1),同时清 flags
		old := b.w.load()
		new := old.addCountsAndClearFlags(skip+2+len(stk)+int(b.hdrsize), 1)
		if !b.w.cas(old, new) { continue }
		// ↑ 与读者改 flag 位竞争:读者可能置 profReaderSleeping。
		//   CAS 失败重试,保证"读者刚标了睡觉"不会被这次发布漏掉唤醒
		if old&profReaderSleeping != 0 {
			notewakeup(&b.wait)   // ← 只有读者在睡才唤醒(否则 note 白敲)
		}
		break
	}
}

IMG-4-rewind.png

图 4-6 rewind 回绕标记:当一条记录尾部放不下时,在 data[wd] 写 0(0 不可能是合法长度字),尾部作废并跳回环头。

先写数据、后原子提交是整个结构的灵魂:数据落盘阶段读者根本看不见(它只认 w 的计数),提交一字 CAS 后整条记录瞬间"存在"。这给了我们一个重要推论:read 拿到的任意区间必然由若干条完整记录组成,绝无半条——不需要任何额外的"部分写"处理。


九、close 与 wakeupExtra(392-417 行)

func (b *profBuf) close() {
	if b.eof.Load() > 0 { throw("runtime: profBuf already closed") }
	b.eof.Store(1)
	b.wakeupExtra()      // ← EOF 是一种"extra 信息",同样要唤醒读者
}

// wakeupExtra:置好 overflow/eof 后必调。
// 用 CAS 把 profWriteExtra 标志打进 w:
//  · 不直接 notewakeup —— 因为标志和唤醒必须原子成对,
//    否则"置标志后、唤醒前"读者恰好检查过 w 就会睡死(丢唤醒)。
func (b *profBuf) wakeupExtra() {
	for {
		old := b.w.load()
		new := old | profWriteExtra
		if !b.w.cas(old, new) { continue }
		if old&profReaderSleeping != 0 { notewakeup(&b.wait) }
		break
	}
}

十、read:读路径全解(429-557 行)

type profBufReadMode int
const (
	profBufBlocking profBufReadMode = iota  // 无数据就睡
	profBufNonBlocking                       // 无数据就返回(darwin 专用)
)
var overflowTag [1]unsafe.Pointer // 溢出记录的标签:恒 nil

func (b *profBuf) read(mode profBufReadMode) (data []uint64, tags []unsafe.Pointer, eof bool) {
	if b == nil { return nil, nil, true }

	br := b.rNext   // ← 读者私有进度:上次答应给、还没提交的起点

	// ===== 第 1 步:提交上一轮的读(441-452 行)=====
	// 把上一轮返回的数据区间还给写者:
	rPrev := b.r.load()
	if rPrev != br {
		ntag := countSub(br.tagCount(), rPrev.tagCount())
		ti := int(rPrev.tagCount() % uint32(len(b.tags)))
		for i := 0; i < ntag; i++ {
			b.tags[ti] = nil      // ← 先清标签:
			if ti++; ti == len(b.tags) { ti = 0 }
			                     //   1) 别替 GC 钉住 label 指针
			                     //   2) 成全 write 的"只在 nil 上写"假设
		}
		b.r.store(br)            // ← 一次原子 store 交还全部区间
	}

"两次调用"协议是理解 read 的钥匙:read 返回的切片指向环内部,在下次 read 前有效。读者用完再调 read,才把空间还回去。这也解释了 runtime_pprof_readProfile 的注释"调用者必须先保存 data 和 tags 再调下一次"(第 03 篇第八节)。

	// ===== 第 2 步:尽力取数(454-500 行)=====
Read:
	bw := b.w.load()
	numData := countSub(bw.dataCount(), br.dataCount())
	if numData == 0 {
		if b.hasOverflow() {
			// 环空但有挂起溢出 → 读者自己合成溢出记录返回
			count, time := b.takeOverflow()
			if count == 0 { goto Read }      // 与写者抢输了,重试
			dst := b.overflowBuf
			dst[0] = uint64(2 + b.hdrsize + 1)  // 长度字
			dst[1] = time                        // 首丢时间
			clear(dst[2 : 2+b.hdrsize])          // 头全零
			dst[2+b.hdrsize] = uint64(count)     // "栈" = 丢弃数
			return dst[:2+b.hdrsize+1], overflowTag[:1], false
		}
		if b.eof.Load() > 0 { return nil, nil, true }   // 真正的 EOF
		if bw&profWriteExtra != 0 {
			b.w.cas(bw, bw&^profWriteExtra)  // 写者说有 extra:清标志重查
			goto Read
		}
		if mode == profBufNonBlocking { return nil, nil, false }
			// ↑ darwin 分支:notetsleepg 在信号处理器不安全(#61768)
		if !b.w.cas(bw, bw|profReaderSleeping) { goto Read }
			// ↑ 挂"我要睡"牌子;失败说明 w 又变了,重看
		notetsleepg(&b.wait, -1)   // ← 无限期睡;被 notewakeup 敲醒
		noteclear(&b.wait)
		goto Read                  // 醒来重查
	}

	// ===== 第 3 步:切出可返回的区间(501-524 行)=====
	data = b.data[br.dataCount()%uint32(len(b.data)):]
	if len(data) > numData {
		data = data[:numData]       // 限制在已提交范围内
	} else {
		numData -= len(data)        // 环尾截断 → 剩余量记下(回绕用)
	}
	skip := 0
	if data[0] == 0 {
		// 撞到 write 留下的 rewind 标记:整段尾部作废,跳回环头
		skip = len(data)
		data = b.data
		if len(data) > numData { data = data[:numData] }
	}

	ntag := countSub(bw.tagCount(), br.tagCount())
	if ntag == 0 {
		throw("runtime: malformed profBuf buffer - tag and data out of sync")
	}
	tags = b.tags[br.tagCount()%uint32(len(b.tags)):]
	if len(tags) > ntag { tags = tags[:ntag] }

	// ===== 第 4 步:按记录框架对齐(526-541 行)=====
	di := 0; ti := 0
	for di < len(data) && data[di] != 0 && ti < len(tags) {
		if uintptr(di)+uintptr(data[di]) > uintptr(len(data)) {
			throw("runtime: malformed profBuf buffer - invalid size")
		}
		di += int(data[di])   // ← 跳过一整条记录
		ti++                  // ← 对应消费一个标签
	}
	// 循环条件三选一停下:到尾 / 到 rewind / 标签先用完
	// (标签先用完 = 环尾装不下整条记录被截断的情况,余量下次返回)

	b.rNext = br.addCountsAndClearFlags(skip+di, ti)
	// ← 记住"已切出但未提交"的进度,等下一次 read 的第 1 步提交

	if raceenabled { raceacquire(unsafe.Pointer(&labelSync)) }
	// ↑ 仅竞态检测器:与 setProfLabel 的 releasemerge 配对,
	//   给 race detector 一个"名义同步点"(真实同步全靠原子索引)

	return data[:di], tags[:ti], false
}

一张图总结 read 的四种结局:

环状态read 的行为
有整条记录切出 1..n 条返回(第 3、4 步)
空但有挂起溢出合成溢出记录返回(overflowBuf)
空、无溢出、EOFeof=true(读干,允许下一轮剖析)
空、无溢出、无 EOF挂 sleeping 睡觉,等 notewakeup

IMG-4-read.png

图 4-7 read 的四种结局:有整条记录则切出返回、空但有挂起溢出则合成溢出记录、空且 EOF 则结束、空且无溢出则挂 sleeping 睡觉。