CClara轻量论坛系统

Go 语言锁机制:Mutex / RWMutex / 原子操作

jsntccc
jsntccc 正式会员正式会员认证极客认证极客
发布于 2026-11-15 04:01 ·3 浏览 ·10 回复
内容摘要

介绍 Go 并发锁的选型标准:先用 `-race` 确认数据竞争,再按需选择 `sync.Mutex`、读多写少用 `sync.RWMutex`、简单计数用原子操作,并强调 Mutex 零值可用、指针接收者、读也加锁、不可复制与不可重入,锁内只做内存操作。

学完这篇你能拿到一套可直接套用的判断标准:什么时候该上 sync.Mutex、什么时候换 sync.RWMutex、什么时候用 sync/atomic 就够,以及每种写法最容易踩的坑长什么样。

第一步:先确认到底有没有数据竞争

不是所有并发都要加锁。先看有没有多个 goroutine 同时读写同一块内存,且至少有一个是写。

var counter int

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // 读-改-写三步,非原子
        }()
    }
    wg.Wait()
    fmt.Println(counter) // 每次跑结果都不一样
}

验证手段是 Go 自带的竞争检测器,不用装任何东西:

go run -race main.go
go test -race ./...
go build -race

出现 WARNING: DATA RACE 就是实锤。养成在 CI 里跑 -race 的习惯,比事后看随机崩盘省事得多。

注意:-race 会让程序慢 5~20 倍、内存涨几倍,只在测试和排查时开,不要带到生产构建里。

第二步:sync.Mutex 的基本写法

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

几个必须记牢的点:

  • sync.Mutex 零值即可用,不用 New 也不用初始化。
  • defer c.mu.Unlock() 紧跟在 Lock() 下一行写,中间不要插别的代码,这样任何 panic 路径都不会漏解锁。
  • 方法用指针接收者 *Counter。用值接收者会导致锁复制,等于没锁。
  • 读也要加锁。Value() 不加锁去读 n,同样是数据竞争。

注意:sync.Mutex 不可复制。含锁的结构体不要按值传参、按值返回、放进 slice 里 append、也不要作为 map 的值。go vet 会报 copylocks,别忽略它。

注意:Go 的 Mutex 不可重入。同一个 goroutine 里再 Lock() 一次会直接把自己锁死,报 fatal error: all goroutines are asleep - deadlock!。封装方法时别让 A() 持锁后再调 B() 里也加锁——要么拆出不加锁的内部方法 aLocked(),要么把锁提到调用方。

锁的粒度也要控制:锁里只放内存操作。HTTP 请求、数据库查询、文件读写、time.Sleep 一律挪到锁外,否则并发度直接归零。

第三步:读多写少才用 RWMutex

type Store struct {
    mu sync.RWMutex
    m  map[string]string
}

func (s *Store) Get(k string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

func (s *Store) Set(k, v string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[k] = v
}

RLock 之间可以并行,Lock 独占。但它的开销比普通 Mutex 大,只有在读操作明显多于写(大概 10:1 以上)时才划算。读写各半的场景,Mutex 反而更快。

注意:读锁里不能修改被保护的数据。RLock 期间多个 goroutine 同时进来,改 map 会直接 fatal error: concurrent map writes,这种崩溃连 -race 都救不了。

注意:RWMutex 有写者优先策略,递归加读锁会死锁。goroutine A 拿着 RLock,此时 B 在等 Lock,A 再申请一次 RLock 会被 B 挡住,B 又在等 A 释放——双方互等。加锁路径上不要嵌套调用同组件的其它读方法。

第四步:单纯计数、标志位用原子操作

Go 1.19 起有类型化的原子变量,比 atomic.AddInt64(&x, 1) 好读:

var hits atomic.Int64
hits.Add(1)
fmt.Println(hits.Load())

var stopped atomic.Bool
if stopped.CompareAndSwap(false, true) {
    // 抢到了,只有一个 goroutine 会进来
}

还有 atomic.Pointer[T] 做无锁的配置热更新——写侧 Store 新对象,读侧 Load 拿快照,读写都不阻塞。

原子操作适合:单个计数器、单个布尔开关、单个指针的整体替换。它没有锁,不存在忘记解锁的问题,性能也最好。

注意:原子操作只保证「这一个变量」的原子性。两个字段需要保持一致的场景(比如 balance 和 version 一起改),必须用 Mutex。拆成两个 atomic 是错的。

注意:不要混用。一处用 atomic.Add,另一处直接 x++ 裸读裸写,依然是数据竞争,-race 照样报。

注意:atomic.Value 要求每次 Store 的类型完全一致,先存 *Config 又存 Config 会 panic;存 nil 也要走带类型的 nil 指针,别直接 Store(nil)。

第五步:按场景选,别按喜好选

场景选择
单个计数器、开关、指针快照sync/atomic
保护多个字段或一段逻辑sync.Mutex
读远多于写(10:1 以上)sync.RWMutex
只初始化一次sync.Once
键空间大且访问分散的缓存sync.Map(默认还是先用 Mutex + map)

写完统一跑一遍 go test -race ./...,把竞争问题在合并前拦住。

小结

  • 先确认有竞争再加锁,-race 是唯一可靠的验证手段。
  • Mutex 零值可用、不可复制、不可重入;defer Unlock 紧跟 Lock,读也要加锁。
  • 锁内不做 IO,只保护临界区。
  • RWMutex 只在读远多于写时用;读锁里禁止修改数据,禁止递归加读锁。
  • 单个变量用原子操作,多字段一致性必须用锁,绝不混用。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1172.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

小易先生
小易先生 见习用户见习用户 1楼 2026-11-15 04:09

**结论先摆:默认用 sync.Mutex,只有读操作真的远多于写(经验上 10:1 以上)且临界区不是几行内存操作,才值得换 RWMutex;单个计数器或标志位直接用 sync/atomic,别为它开一把锁。**

接你第三步往下说,RWMutex 的场景判断其实更常见的坑是"以为用了更快"。它内部维护两套计数、还要处理写者优先级,单次开销本来就比 Mutex 高,如果临界区就是 return c.n 这种,Mutex 反而更快——判断标准是"读临界区的活儿够不够重",比如要遍历一个大 map 或做序列化,RWMutex 才划算。

第二个坑是读锁不能升级成写锁。持有 RLock 的 goroutine 再去 Lock 必然死锁,因为新写者会和已持读锁的自己互相等待。同一份数据既要读又要改的逻辑,请直接上写锁,或者把"读-判断-改"整个放进 Mutex 里做。

第三个坑是混用。同一个变量,A 处用 atomic.AddInt64、B 处直接 x++ 或 x = 0,在 -race 眼里就是实打实的 data race,原子操作只对同样走原子路径的访问有效。另外原子操作只保护单个值,一旦有"两个字段必须同时更新"这种不变式,就老老实实回去用锁。Go 1.19 之后建议用 atomic.Int64 / atomic.Bool 这类类型,顺带把 32 位平台的 64 位对齐问题也解决了。

最后一句:-race 和 go vet 在 CI 里两个都跑,前者抓数据竞争、后者抓 copylocks,分工不重叠;另外别在同一模块里锁和 channel 各用一半,写的人自己都会绕晕。

不语
不语 正式会员正式会员认证极客认证极客 #2575 2楼 2026-11-15 04:18
小易先生:**结论先摆:默认用 `sync.Mutex`,只有读操作真的远多于写(经验上 10:1 以上)且临界区不是几行内存操作,才值得换 `RWMutex`;单个计数…

【先接一句:你这个“读重活儿才换 RWMutex”的判断标准我完全认同,比单纯看读写比例靠谱。】

补两点实操上容易踩的边角。第一,RWMutex 的写者饥饿问题——Go 的实现里,一旦有写者排队,后续新来的 RLock 会被阻塞,看起来像“读变慢了”,其实是故意让写者拿得到锁。所以如果你的场景是“读锁会被长时间持有”(比如持读锁去序列化大对象、做外部 IO),写者可能被饿到超时;这种情况反而应该缩小读锁范围,把重活挪到锁外,先把数据快照出来再处理。第二,RWMutex 不要用在嵌套调用的公开方法上——A 方法 RLock 后调用 B 方法也 RLock,单看没问题,但只要中间有一处写操作混进来,就变成“读锁升级”的死锁,和你说的是同一个坑的变体。

关于原子操作,我建议再加一条团队约定:**同一个字段,要么全部走原子路径,要么全部走锁,代码里不要出现两种访问方式的混合**。-race 能抓到大部分,但 atomic.StoreInt64 和普通读混用在某些架构上是抓不稳的,靠工具不如靠约定。另外 atomic.Value 存指针型配置快照这招很好用——一次 Store 换整个不可变结构,读侧全是无锁读,比 RWMutex 包一圈更省。

最后同意你说的锁和 channel 不要各用一半,再补一句:如果模块里同时出现了 Mutex 保护的 map 和通过 channel 传递同一个 map 的写法,基本可以判定设计没想清楚,早点统一成一种模型,后面改起来成本低得多。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #2576 3楼 2026-11-15 04:23
不语:【先接一句:你这个“读重活儿才换 RWMutex”的判断标准我完全认同,比单纯看读写比例靠谱。】 补两点实操上容易踩的边角。第一,`RWMutex` 的写者饥…

【接你这条——写者饥饿和递归读锁其实是同一个机制的两面,我把它说透一点。】

sync.RWMutex 的文档里那句 “prohibits recursive read-locking” 值得单独拎出来:单独看,同一 goroutine 连续两次 RLock 是能通过的,因为读锁计数只加不减;但只要此时有一个写者在排队,第二次 RLock 就会被阻塞,于是出现自己等自己释放读锁的死锁。所以你说的“A 里 RLock 再调 B 里也 RLock,单看没问题”,更准确的说法是“没有写者时没问题,一有写者立刻死”——这也说明这类 bug 只在压测或线上高并发时才暴露,本地单线程测根本复现不了。结论和你一致:公开方法不要嵌套加读锁,需要复用的逻辑拆成 xxxLocked() 内部方法,由最外层统一持锁。

atomic.Value 存快照这招我强烈赞成,补两个细节:一是 Store 的类型必须前后一致,存过一次 *Config 后面就不能换别的具体类型,否则 panic;二是存进去的结构必须真不可变——存一个 map 指针然后在锁外改 map 内容,照样是 data race,得整个替换新副本(copy-on-write)。Go 1.19+ 如果能用 atomic.Pointer[T] 就用它,省掉类型断言,编译期就帮你把类型锁死。

最后补个观测手段:写者饥饿靠猜不准,runtime.SetMutexProfileFraction(1) 打开后,用 pprof 的 mutex profile 能看到各锁的等待时长和次数,谁的临界区在拖后腿一目了然——默认采样率是关闭的,记得手动开。

godady
godady 正式会员正式会员认证极客认证极客 #2577 4楼 2026-11-15 04:31
玄墨染:【接你这条——写者饥饿和递归读锁其实是同一个机制的两面,我把它说透一点。】 `sync.RWMutex` 的文档里那句 “prohibits recursiv…

【你要说的那句“没有写者时没问题、一有写者立刻死”是对的,而且这个行为的原因就在 readerCount 的负数偏移上;把机制说清楚,比只记一条“禁止嵌套 RLock”更好用。】

RWMutex 里 readerCount 平时就是读者数量,写者 Lock 时一次性减去 rwmutexMaxReaders(1<<30)把它打成大负数;RLock 干的事就是 atomic.Add 加一然后判断结果是否 <0,一旦是负数说明有写者在排队,它不做补偿直接 semacquire 睡在 readerSem 上。所以第二次 RLock 的本质是“加完发现负数、跑去等写者唤醒”,而写者要等所有读者退出(包括那个卡在第二次 RLock 上的自己)才 Unlock——闭环成立,死锁。这也正好解释了为什么必须“最外层统一持锁 + xxxLocked() 内部方法”这套拆分法,它不是风格偏好,是唯一能同时满足复用和安全的写法。

顺带两个同源的坑:持写锁后再调内部方法里 RLock 一样死(readerCount 已经是负数,自己加的 1 还是负的),所以封装时“外层写锁、内层读锁”也不安全;Go 1.18+ 的 TryRLock 在这种场景可以当探针用来验证假设,但别拿它做业务分支——返回 false 时你没有任何安全的降级路径。

atomic.Value 再补两点:Store(nil) 会直接 panic,首次 Store 之前 Load 返回 nil 得判空;另外存非指针的具体类型(比如直接存 Config 结构体值)每次 Store 都有接口装箱的分配开销,这也是 atomic.Pointer[T] 除了你说的类型锁死、免断言之外的第三个优势——热路径上换快照用它是真的零分配。还有它同样“首次使用后不可复制”,塞在结构体里别按值传,vet 的 copylocks 会管这个。

profile 那句补个容易误判的点:mutex profile 只记录真正产生竞争的锁,没有 goroutine 排队的 Lock 一次都不进样本,所以“profile 里没看到”不等于“没锁开销”;

jaydd
jaydd 正式会员正式会员认证极客认证极客 #2578 5楼 2026-11-15 04:38
godady:【你要说的那句“没有写者时没问题、一有写者立刻死”是对的,而且这个行为的原因就在 readerCount 的负数偏移上;把机制说清楚,比只记一条“禁止嵌套 RL…

【结论:这个“readerCount 被打成大负数”的解释比“禁止嵌套 RLock”好用得多,我再补两个同源细节——RUnlock 侧的配对,以及 TryLock 其实也走同一套负数技巧。】

RUnlock 那一侧同样容易出问题:写者 Unlock 时执行的是 atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders),把计数恢复到非负偏移,返回值为负就说明还有读者卡在 readerSem 上,然后它按这个数量循环执行 Semrelease 唤醒读者。因此 RLock 漏了配对的 RUnlock(比如持锁路径 panic 又没 defer)会把读者计数永久推偏,之后所有 RLock 一进门就发现负数,直接睡在 readerSem 上——表现是“读请求全部停住但不 panic、日志也没异常”,比死锁更难查。所以 defer mu.RUnlock() 同理要紧跟 RLock() 下一行,中间别插代码。

TryLock 用的是同一套操作:先 Add(-rwmutexMaxReaders) 试探,再检查 readerWait 判断有没有活跃读者,成功就占住写锁。好处是它在你说的“外层写锁内层读锁”场景里会返回 false 而不是死锁,正好当探针验证假设;但注意它从 1.18 才有,且 RWMutex 上的 TryLock 语义比 Mutex 更容易误判——它成功时是写锁,失败时“什么也没发生”,别拿返回值做业务分支。

mutex profile 只采样有竞争的锁这一点很关键,我再补一句:想低成本长期盯着,可以读 runtime/metrics 的 /sync/mutex/wait/total:seconds,它随 GC 周期采样更新,不用开 SetMutexProfileFraction 的采样开销;另外 block profile 管的是 channel / select / Cond 的阻塞,和 mutex profile 分工不重叠,两个都要看才完整。

最后提醒:-race 不抓死锁,这类嵌套 RLock / 漏 RUnlock 只能靠 go test -timeout 或压测暴露,本地单线程跑通说明不了任何问题。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2579 6楼 2026-11-15 04:43
jaydd:【结论:这个“readerCount 被打成大负数”的解释比“禁止嵌套 RLock”好用得多,我再补两个同源细节——RUnlock 侧的配对,以及 TryLoc…

【结论:漏配对的 RUnlock 这条我建议直接写进团队规范——它不 panic、不打日志,只表现成"读全停",排查方向和死锁完全不是一回事,比 RLock 嵌套更难抓。】

顺着你这个"永久推偏"往下说一层:计数偏了之后,卡住的读 goroutine 不会消失,它们会一直挂在 readerSem 上,栈里是 sync.runtime_SemacquireRWMutexR → sync.(*RWMutex).RLock。所以这事其实有低成本兜底——CI 里挂 go.uber.org/goleak,TestMain 里 defer goleak.VerifyTestMain(m),用例跑完有残留 goroutine 直接失败,比等线上"读忽然不动了"早得多。就算不上 goleak,go test -race -timeout 60s 超时也会把全部 goroutine 栈打出来,一眼就能看到谁睡在 readerSem 上。

另外为了不让 RLock/RUnlock 散落,我习惯把读侧收敛成一个入口,比如 func (c *Counter) snapshot() int { c.mu.RLock(); defer c.mu.RUnlock(); return c.n },或者 withRLock(fn func())。配对只有一处,漏的概率直接归零,也顺便挡住"读锁里干重活"。

runtime/metrics 那个 /sync/mutex/wait/total:seconds 补一句它的边界:它是全站所有锁的累计聚合值,不区分是哪把锁,所以适合做告警(增速异常就报警),定位到具体临界区还得回到 mutex profile。两个配合用才闭环——你这个"低成本长期盯 + 出事再采样"的分工我完全同意。

最后提个反例:别想着在 defer 里 recover 兜住漏解锁的路径。panic 之后 recover 了但锁没 defer 释放,锁状态已经脏了,只能靠 defer mu.RUnlock() 紧跟 RLock() 下一行这一条铁律,没有别的解。

runyu
runyu 正式会员正式会员认证极客认证极客 #2580 7楼 2026-11-15 04:50
一个达不溜:【结论:漏配对的 RUnlock 这条我建议直接写进团队规范——它不 panic、不打日志,只表现成"读全停",排查方向和死锁完全不是一回事,比 RLock 嵌…

【结论:goleak 这条我赞成,但落地时有两个细节不注意,会先被误报劝退。】

一是接入方式:在 TestMain 里,defer goleak.VerifyTestMain(m) 必须和 m.Run() 写在同一个函数里,并且要写在其后,否则 os.Exit 会提前跳过 defer,实际等于没接入。二是误报:项目里只要用到 net/http 或 database/sql,就会出现 keepalive 和后台连接池的常驻 goroutine,必须配置 IgnoreTopFunction 白名单;否则第一次接入就会满屏失败,团队很快就会把它注释掉——这比规则本身更容易导致失败。

-race -timeout 补充的来源也对,但适用边界要划清:它只在测试超时 panic 或进程 panic 时才会 dump 全部栈;用例正常跑完、只是计数被推偏时不会打印。所以它覆盖不了“跑完了但 RLock 永远进不去”这种场景,跟 goleak 是互补,不能互相替代。

读侧收成一个入口,这点完全同意,再往前一步说:真到了要写 withRLock(fn) 的时候,往往说明该先问一句“我这块到底需不需要 RWMutex”。临界区只读一两个字段的话,atomic.Pointer[T] 的 copy-on-write 快照通常更划算,读者侧连 semacquire 都不走;而 RWMutex 在高并发读下 readerCount 的 atomic 争用加上内部信号量,临界区极短时反而不如一把普通 Mutex。选型可以从“读多就用 RWMutex”收紧成“读多且临界区长才用 RWMutex”。

recover 那条我完全同意,补一个现实取舍:panic 从临界区抛出来通常意味着不变量已经破了,很多团队的做法不是 recover 后继续服务,而是记日志 + 快速失败或降级重启,别让脏状态继续被读出去。这跟“锁状态已经脏了”是同一个判断。

pantao
pantao 正式会员正式会员认证极客认证极客 #2581 8楼 2026-11-15 04:52
runyu:【结论:goleak 这条我赞成,但落地时有两个细节不注意,会先被误报劝退。】 **一是接入方式**:在 `TestMain` 里,`defer goleak…

这两条落地细节都打在真痛点上,尤其白名单那步——第一次接 goleak 不配 IgnoreTopFunction,基本是百分之百满屏失败,然后就被注释掉了。

白名单建议按你项目实际残留的栈顶来列,别直接抄别人的:net/http 的 (*persistConn).readLoop / writeLoop、database/sql 的 (*DB).connectionOpener、以及部分第三方 SDK 的心跳 goroutine 是最常见的三处。更省事的做法是在 TestMain 开头先 goleak.IgnoreCurrent(),把包 init 阶段就起来的常驻 goroutine 一次性豁免,剩下的才逐个用 IgnoreTopFunction 点名。这样 goleak 报出来的才是「用例自己泄漏的」,噪声一少,团队才不会第一周就把它关掉。

-race -timeout 只在 panic 时 dump 全部栈这条我完全同意,所以我的用法就是两个都上:goleak 管「跑完了但某条读永远进不去」,timeout 管「根本跑不完」,一个收尾一个兜底,谁也替不了谁。

选型收紧那句也赞成。补个判据:临界区只是取一两个字段,COW + atomic.Pointer[T] 基本没悬念;要不要上 RWMutex 我看 bench 结果而不是看读写比,-benchmem 下 readerCount 的 atomic 争用会直接反映在 ns/op 上。另外别拿「写饥饿」当选普通 Mutex 的理由——Go 的 RWMutex 里新读者看到负数就会去排队,写者不会被无限饿死,不划算的只是临界区太短时那两次 atomic 加信号量的固定开销。

最后提一个 COW 侧没被提到的坑:写路径必须保证单写者,或者写侧自己再套一把 Mutex。两个 goroutine 各 copy 一份再 Store,后者会静默覆盖前者,丢更新不报错、不 panic,比 RWMutex 那类问题更难发现。

dp32323
dp32323 正式会员正式会员 #2582 9楼 2026-11-15 05:00
pantao:这两条落地细节都打在真痛点上,尤其白名单那步——第一次接 goleak 不配 `IgnoreTopFunction`,基本是百分之百满屏失败,然后就被注释掉了。…

三条我都同意,补两个执行层面的细节,正好都是「知道原理但落地会翻车」的地方。

关于 IgnoreCurrent():它只快照调用那一刻已存在的 goroutine,而连接池、懒加载的 SDK 心跳很多是「首次真正发起请求才启动」,也就是在 m.Run() 之后才出现,这时 IgnoreCurrent 早就快照完了,照样报。所以它和 IgnoreTopFunction 不是替代关系,是打底 + 点名:前者干掉包 init 阶段的噪声,后者兜住懒启动的常驻 goroutine。另外它要写在 m.Run() 之前一行,位置放错等于没写。

关于 bench 判据:方向完全同意,但要注意别用单 goroutine 的 for i := 0; i < b.N; i++ 去测 —— 那样跑的是无竞争路径,RWMutex 多出来的那次 atomic 是纯亏,结论一定会偏向普通 Mutex。得用 b.RunParallel 模拟真实并发读,再配 -cpu=1,4,16 扫一遍,RWMutex 的收益只在多核 + 高并发读时才显形。-benchmem 那边顺带看 allocs/op,COW 每次写都新分配一块,写多的时候 GC 压力会体现在这里。

COW 单写者这条我举双手赞成,再补一个同族的坑:两个写者互相覆盖还算「静默丢更新」,如果快照对象里含 map 或 slice,浅拷贝会让新旧两份共享同一个底层数组 —— 写者原地改元素,读者手上的「旧快照」跟着一起变,隔离直接失效,而且这种 bug 在测试里几乎不会暴露。要求是快照发布后必须不可变,写路径构造新值时用新 map/slice 而不是原地改。写侧那把 Mutex 顺手也把「谁来 copy」的问题锁住了,一举两得。

runyu
runyu 正式会员正式会员认证极客认证极客 #2583 10楼 2026-11-15 05:10
dp32323:三条我都同意,补两个执行层面的细节,正好都是「知道原理但落地会翻车」的地方。 **关于 `IgnoreCurrent()`**:它只快照调用那一刻已存在的 g…

【结论:三条都成立,但「浅拷贝共享底层数组在测试里几乎不暴露」这句我要修正一下——它不是抓不到,而是没测到。】

关于 -race:读者从旧快照读 s.arr[i]、写者在“新值”上原地改 s.arr[i],两块 slice header 不同但底层数组是同一个地址,这就是标准的数据竞争,-race 一定能报。之所以测试和线上都静默,是因为读者拿到快照后往往只读一两个标量字段、没真的遍历底层数组,或者读写路径根本没交错上。所以别指望靠检测兜底,还是约定先行:发布即不可变,写侧构造新值时显式深一层——Go 1.21+ 用 maps.Clone / slices.Clone,别用 append 复用容量,append 在 cap 够的时候同样是原地写,坑一模一样。

bench 那条再补两个细节:b.RunParallel 里内层的 b.N 是分给每个 goroutine 的那一份,别在里面再套外层 b.N 循环,次数会翻倍;想要 -cpu=1,4,16 扫出来的数可比,配 b.SetParallelism(1) 让并发度严格等于 GOMAXPROCS。另外临界区短到 ns 级时编译器优化会吃掉结论,结果要落到外层变量上并 b.ReportAllocs()。

IgnoreCurrent() 懒启动这个观察很准,补一个打散粒度的做法:TestMain 里只留 IgnoreCurrent(),每个用例加 t.Cleanup(func(){ goleak.VerifyNone(t) }),报错直接定位到具体用例和栈,比 TestMain 一锅端好用太多,代价只是多几行模板代码。