我给一个带 sync.Mutex 的 Go 结构体写了用值接收者的方法、又到处用值传递把它传来传去,自以为加了锁就线程安全了,结果高并发下那份本该被锁保护的数据还是被改乱了、计数器还是丢更新,排查很久才搞懂我每次值拷贝这个结构体时把里面的锁也一起复制成了另一把、副本锁的根本不是同一把锁的深度复盘
这次踩的坑特别隐蔽:代码里明明白白加了锁——mu.Lock()、defer mu.Unlock() 一个不少,逻辑看着无懈可击,可它就是锁不住。锁还在,只是锁的不是同一把了。
故障现场:加了锁,数据还是被改乱了
我写了个带计数功能的结构体,里面有个 sync.Mutex 来保护并发访问,自觉很标准:
type Counter struct {
mu sync.Mutex
count int
}
// 用了值接收者!(这就是祸根)
func (c Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
然后我在多个 goroutine 里并发调 Inc(),还经常把这个 Counter 当参数值传递给别的函数。上线后问题来了:
- 计数永远不对:开 1000 个 goroutine 各
Inc()一次,最后Value()读出来永远是 0 或者一个很小的数,根本不是 1000。 - 锁明明加了:我反复检查,
Lock/Unlock都在、配对也对,逻辑上无懈可击,可数据就是乱、就是丢更新,跟没加锁一个样。 - go vet 早就警告过:后来我跑
go vet,它直接报Inc passes lock by value: Counter contains sync.Mutex——它一直在喊,是我没当回事。 - 改成指针接收者就好了:我试着把接收者从
(c Counter)改成(c *Counter),其它什么都没动,计数立刻就准了。
"锁加了却锁不住、go vet 报 passes lock by value、改成指针就好"——这几条合起来,把矛头死死指向那个我从没多想的"值接收者 / 值传递":每当我用值的方式去传递或调用这个含锁的结构体,Go 都会拷贝一份它,而这一拷贝,把里面那把 Mutex 也一起复制成了一把新的、独立的锁。我得去搞清楚,锁被拷贝之后到底发生了什么。
第一件事:搞懂 Mutex 被拷贝后,副本锁的是另一把锁、根本互斥不了
带着"锁被复制了"这条线去翻 Go 的并发原语,我才算真正理解了一条本该牢记的铁律——sync.Mutex(以及任何包含它的结构体),绝对不能被拷贝;一旦拷贝,就出大问题。
原因要从 Mutex 的本质说起。sync.Mutex 本身就是一个带状态的值(内部有表示"是否已锁定、有没有等待者"的字段),互斥能成立,靠的是所有并发方都去操作同一个 Mutex 实例——A 锁上了,B 去锁同一个实例时发现已锁定,就会阻塞等待,这才形成互斥。可一旦你拷贝了这个 Mutex(或含它的结构体):
- 副本里的 Mutex 是一个全新的、独立的实例,它和原件的 Mutex毫无关联;
- 你对副本的锁
Lock(),锁住的是副本自己那把锁,丝毫不影响原件那把,也不影响其它副本; - 于是"大家排队抢同一把锁"的前提就崩了——每个副本各锁各的,等于没有任何互斥,大家可以同时进临界区改数据。
而在 Go 里,"拷贝"发生得悄无声息、无处不在:值接收者方法(func (c Counter))每次调用都把 c 拷贝一份;值传递把结构体当参数传时拷贝;把结构体存进 slice/map 再取出、range 遍历时也都是拷贝。我那个 Inc() 用的正是值接收者,所以每次调用 c.mu.Lock() 锁的都是这次调用专属的那份拷贝的锁,goroutine 之间锁的根本不是同一把,互斥完全失效;更要命的是,c.count++ 改的也是拷贝的 count,方法一返回拷贝就丢了,所以计数永远累加不上去(这也是为什么读出来是 0)。我把这个对照验证清楚:
// 值接收者: 每次调用 c 都是一份拷贝, 锁和 count 都是拷贝的
func (c Counter) IncBad() { c.mu.Lock(); defer c.mu.Unlock(); c.count++ }
// 调用方的原对象 count 永远不变, 锁也各锁各的 -> 既不互斥也不累加
// 指针接收者: 所有调用共享同一个 Counter, 锁和 count 都是同一份
func (c *Counter) IncGood() { c.mu.Lock(); defer c.mu.Unlock(); c.count++ }
// 大家抢的是同一把锁, 改的是同一个 count -> 互斥生效、计数正确
var wg sync.WaitGroup
c := &Counter{} // 用指针, 全程共享同一个实例
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); c.IncGood() }()
}
wg.Wait()
fmt.Println(c.Value()) // 1000, 正确(IncBad 则是 0)
真相大白:不是锁没加,而是我每次值拷贝这个含锁结构体时,把锁也复制成了一把独立的新锁,各个 goroutine 锁的是各自拷贝里的锁、根本不是同一把,互斥自然形同虚设;而值接收者还顺带让 count++ 改在了拷贝上、丢了更新。错的根源,是 Mutex 这种东西必须被共享、绝不能被拷贝,而我却用值语义到处复制它。
第二件事:正解——含锁结构体一律用指针,别拷贝,靠 go vet 兜底
根因是"拷贝了不该拷贝的锁",那正解的核心就一句话:任何含有 sync.Mutex(或 sync.RWMutex、sync.WaitGroup 等不可拷贝类型)的结构体,一律通过指针使用、传递,绝不值拷贝。具体几条:
type Counter struct {
mu sync.Mutex
count int
}
// 1) 方法用指针接收者(所有调用共享同一个实例和同一把锁)
func (c *Counter) Inc() { c.mu.Lock(); defer c.mu.Unlock(); c.count++ }
func (c *Counter) Value() int { c.mu.Lock(); defer c.mu.Unlock(); return c.count }
// 2) 创建和传递都用指针, 别用值
c := &Counter{} // 指针
doSomething(c) // 传指针, 不拷贝
func doSomething(c *Counter) { c.Inc() } // 参数也是指针
// 3) 别把含锁结构体直接放进 slice/map 的"值"里再取出(取出是拷贝)
// 存指针:
m := map[string]*Counter{}
m["a"] = &Counter{}
m["a"].Inc() // OK, 操作的是同一个
// 4) range 也会拷贝, 要改原件就用下标取地址或存指针
for i := range counters { counters[i].Inc() } // counters []*Counter 或用 &counters[i]
这里的关键是把"这个东西不能被拷贝"这件事贯穿到底:接收者用指针、参数用指针、放进容器存指针、range 用下标取地址——任何一处不小心用了值语义,锁就被复制了。还有个常见做法是把锁(或含锁的部分)放在一个本来就通过指针共享的对象上,从设计上就不会被值拷贝。
更重要的是用工具兜底:go vet 自带 copylocks 检查,能在编译期就揪出"passes lock by value"这类锁拷贝问题——把 go vet 接进 CI,这类坑根本到不了线上。核心就一条:含锁结构体是"不可拷贝"的,全程用指针共享同一个实例,并让 go vet 帮你守住。
第三件事:同一类"拷贝了一个本该被共享的、带状态/带身份的东西"的坑,我后来又撞见好几个
这次踩坑让我看清一个更普遍的模式:有些东西的意义,在于它是"那一个唯一的实例"——它携带着状态、或代表着某种身份/某个真实资源的句柄;对这种东西做"拷贝",得到的副本看起来一模一样,实际上却脱离了那个唯一实例,要么状态不再同步,要么句柄指向了错乱的东西。这种坑不止 Mutex:
- sync.WaitGroup / sync.Once 被拷贝:和 Mutex 一样,拷贝后副本的计数/标志和原件无关,Wait 不到、Once 又执行,go vet 同样会报。
- 含 sync.Mutex 的对象塞进 channel:channel 传值会拷贝,把含锁结构体发进 channel,收到的是拷贝、锁失效。
- 拷贝了文件句柄/连接的包装:拷贝一个持有 fd、socket 的结构体,两个副本指向同一个底层资源,各自 Close 会重复关闭或提前关闭。
- 原子变量 sync/atomic 值拷贝:atomic.Int64 等拷贝后两份各自原子、合起来不原子。
- "单例"被无意复制:本该全局唯一的对象被值拷贝出多份,各自维护状态,以为在操作同一个其实是多个。
它们的内核是同一个:这世上的东西大致分两种——一种是"值",它的意义完全由内容决定,拷贝一份和原件完全等价(如一个数字、一个不可变字符串);另一种是"有身份/有状态的实体",它的意义在于"它是那一个"——它承载着会变化的状态、或代表着某个唯一的资源,对这种东西,"拷贝"会制造出一个貌合神离的赝品:内容像,却脱离了那个唯一本体,状态不再同步、资源关联错乱。而像 Mutex 这种,它的全部作用就建立在"大家共享同一个"之上,拷贝直接让它的作用归零。所以,面对任何"带状态、代表资源、要求唯一"的东西,都要用共享(指针/引用)而非拷贝来传递它。我把这套判断画成了一张图(见后文)。
| 被拷贝的东西 | 拷贝后的恶果 | 正确做法 |
|---|---|---|
| sync.Mutex / 含它的结构体 | 各锁各的、互斥失效 | 全程用指针、go vet 兜底 |
| sync.WaitGroup / Once | 计数/标志不同步 | 用指针共享 |
| 文件/连接句柄包装 | 重复关闭、资源错乱 | 共享或明确所有权 |
| atomic 原子变量 | 各自原子、整体不原子 | 用指针访问同一个 |
| 本该唯一的单例 | 多份各管各、状态不一 | 共享同一实例 |
第四件事:值接收者 vs 指针接收者(对含锁结构体)——一张对照表
这次事故逼我把含锁结构体下值接收者和指针接收者的区别摆成一张表,以后写方法前先对照:
| 维度 | 值接收者 func (c Counter) | 指针接收者 func (c *Counter) |
|---|---|---|
| 每次调用 | 拷贝整个结构体(含锁) | 共享同一个实例 |
| 锁的是哪把 | 各调用拷贝里的锁、各锁各的 | 同一把锁、真正互斥 |
| 改 count | 改在拷贝上、丢更新 | 改在原件上、生效 |
| 互斥效果 | 形同虚设 | 正确 |
| go vet | 报 passes lock by value | 通过 |
| 含锁结构体该用 | 绝不 | 一律用这个 |
看清这张表,规矩就明确了:结构体里只要有 Mutex 之类不可拷贝的东西,方法一律用指针接收者、传参一律用指针;值接收者会把锁和状态一起拷走,既锁不住也存不下。对含锁结构体来说,值语义不是"更安全的副本",而是"切断了联系的赝品"。
第五件事:我曾经对锁和值拷贝想当然的几个误区
这场"加了锁却锁不住"的事故,把我对锁和值拷贝的一堆想当然照得清清楚楚:
| 我以为 | 实际上 |
|---|---|
| 加了 Lock/Unlock 就线程安全 | 得锁同一把锁;锁被拷贝就失效 |
| Mutex 拷贝一份还是同一把锁 | 副本是独立新锁、和原件无关 |
| 值接收者只是少了点性能 | 对含锁结构体是正确性灾难 |
| 值传递结构体更安全 | 含锁/含状态时反而把锁和状态拷断 |
| go vet 的警告无关紧要 | copylocks 正是在救你、必须当真 |
| 放 slice/map、range 不影响锁 | 取值/range 都是拷贝、同样坑 |
这些误区的根子是同一个:我默认了"拷贝一个东西,得到的副本和原件是等价的、可以互换的"——这个假设对"纯数据值"成立,可 Mutex 不是纯数据值,它是一个靠"大家共享同一个实例"才能工作的协调机制,它的价值恰恰在于"不可拷贝、必须共享"。正因为我把它当成了一个普通的、可以随便复制的字段,才会用值接收者、值传递到处拷它,亲手把它赖以生效的"共享同一个"的前提给破坏了。把一个"靠共享唯一实例才有意义"的东西,当成一个"可随意拷贝、副本等价"的普通值,是这类锁失效的共同根源。
第六件事:写含锁结构体、排查"加了锁却锁不住"时,我现在的自检习惯
现在每当我写含锁(或含状态)的结构体、或排查"加了锁还是有竞态",我都会先盯住"有没有发生拷贝"。先看清锁被拷贝为什么就失效:
然后用这张自检图决定含锁结构体怎么用:
配套地,我会用一个"禁止拷贝"的小技巧给关键结构体上把保险,让误拷贝在 vet 阶段就暴露:
// 用 noCopy 显式标记"此类型不可拷贝", go vet 会对任何值拷贝报错
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type Counter struct {
_ noCopy // 嵌入它, 值拷贝 Counter 时 go vet 立刻报警
mu sync.Mutex
count int
}
// 之后凡是值传 Counter / 值接收者, go vet 都会喊 "copies lock value"
// 配合: 一律 c := &Counter{} 用指针; CI 里 go vet ./... 必跑
这套习惯的精髓,是"结构体含 Mutex 等不可拷贝物就全程用指针、绝不值拷贝、go vet 兜底、必要时 noCopy 上保险"。它让我从"加了 Lock 就以为安全",变成了"先确认大家锁的是不是同一把锁、有没有被拷贝"——核心始终是:sync.Mutex 以及任何包含它(或 sync.RWMutex sync.WaitGroup sync.Once sync/atomic 等)的结构体绝对不能被值拷贝,因为 Mutex 本身是一个带状态的值(内部记录是否已锁定有无等待者)、互斥能成立完全依赖所有并发方去操作同一个 Mutex 实例(A 锁上后 B 锁同一个实例发现已锁定才会阻塞等待形成互斥),一旦把这个 Mutex 或含它的结构体拷贝一份,副本里的 Mutex 就是一个与原件毫无关联的全新独立实例、对副本 Lock 锁的是副本自己那把锁丝毫不影响原件和其它副本、于是大家排队抢同一把锁的前提崩塌每个副本各锁各的等于完全没有互斥;而在 Go 里拷贝悄无声息无处不在——值接收者方法每次调用都拷贝接收者、值传递参数会拷贝、把结构体存进 slice/map 再取出会拷贝、range 遍历也会拷贝,所以含锁结构体的方法必须用指针接收者 *T、创建和传递必须用指针、放进容器要存指针、range 要用下标取地址,任何一处用了值语义锁就被复制了互斥就失效(值接收者还会让对状态字段的修改改在拷贝上方法返回即丢失);防范上要把这个东西不可拷贝贯穿到底并用工具兜底——go vet 自带的 copylocks 检查能在编译期揪出 passes lock by value 这类锁拷贝问题、接入 CI 让它根本到不了线上,还可以嵌入一个 noCopy 类型显式标记不可拷贝让 vet 对任何误拷贝报错;更一般地,世上的东西大致分两种,一种是值它的意义完全由内容决定拷贝一份和原件完全等价(数字不可变字符串),另一种是有身份或有状态的实体它的意义在于它是那一个唯一实例——它承载着会变化的状态或代表着某个唯一的真实资源(锁、句柄、连接、单例),对这种东西拷贝会制造出一个内容相像却脱离了唯一本体的赝品使状态不再同步资源关联错乱,而像锁这种全部作用都建立在大家共享同一个之上的东西拷贝会直接让它的作用归零,所以面对任何带状态代表资源要求唯一的东西都必须用共享(指针/引用)而非拷贝来传递它。
我立下的几条规矩
这场"加了锁却锁不住"的事故,换来了我写 Go 并发代码时,刻进骨子里的几条铁律:
- 含 sync.Mutex 等的结构体绝对不能被值拷贝,一律用指针。
- 互斥靠大家锁同一把锁;锁被拷贝成两把就形同虚设。
- 含锁结构体的方法一律用指针接收者 *T,绝不用值接收者。
- 传参、放容器、range 都防拷贝:传指针、存指针、取地址。
- WaitGroup/Once/atomic 同理不可拷贝,都用指针共享。
- 把 go vet(copylocks)接进 CI,锁拷贝在编译期就拦下。
- 带状态/代表资源/要求唯一的东西,一律共享而非拷贝。
附:一段排查"加了锁却锁不住"的自测清单
最后留一段我自己排查锁拷贝问题、把含锁结构体改对时照着走的清单:
// === 1. 先让 go vet 替你扫一遍, 它直接报锁拷贝 ===
// go vet ./...
// 报 "passes lock by value: T contains sync.Mutex" 或 "copies lock value"
// -> 这就是实锤, 顺着它指的位置去改
// === 2. 检查含锁结构体的所有方法: 接收者都得是指针 ===
type Counter struct { mu sync.Mutex; count int }
func (c *Counter) Inc() { c.mu.Lock(); defer c.mu.Unlock(); c.count++ } // *Counter
func (c *Counter) Value() int { c.mu.Lock(); defer c.mu.Unlock(); return c.count }
// === 3. 检查所有传递点: 创建/传参/存容器/range 都不能值拷贝 ===
c := &Counter{} // 指针创建
go func() { c.Inc() }() // 闭包捕获指针, 共享同一个
m := map[string]*Counter{"a": {}} // map 存指针, 不存值
for i := range list { list[i].Inc() } // list []*Counter; 或 (&list[i]).Inc()
// === 4. 用 -race 跑测试, 验证竞态确实消失 ===
// go test -race ./...
// 改对后不再报 DATA RACE, 计数也对了
// === 5. 关键类型加 noCopy 上保险, 让以后的误拷贝在 vet 期就暴露 ===
// 见正文第六件事的 noCopy 写法
这段清单的核心就一句:go vet 揪锁拷贝、所有方法用指针接收者、所有传递点防值拷贝、-race 验证竞态消失、noCopy 上长期保险。把"加了锁就以为安全"换成"确认大家锁的是同一把、没被拷贝",那个"怎么加锁都是 0"的计数器,就再也不会出现了。
写在最后
回头看,这场由"拷贝了一把锁"引发的"加锁却锁不住"事故,真正教给我的,远不止"含锁结构体用指针"这一个技巧。它让我对"'拷贝一个东西'这件看似无害、天天在做的事,对不同性质的东西,意味着截然不同的后果;有些东西经得起拷贝,有些东西一拷贝就失去了灵魂",有了一次刻骨的体会。我栽跟头,是因为我把"拷贝"当成了一个永远安全、永远等价的操作——在我朴素的认知里,复制一份不就是多了个一模一样的东西嘛,副本和原件还能有什么区别?这个直觉对付数字、字符串这些纯粹的值时百试百灵,于是我把它无差别地套用到了一切东西上;可锁偏偏不是一个值,它是一个靠"所有人盯着同一个"才能运转的协调机制——它的意义不在它的内容里,而在"它是被大家共享的那唯一一个"这件事里;我一拷贝,内容是原样复制了,可那个赖以生效的"唯一、共享"却被我亲手斩断了,于是我手里多了一把长得一模一样、却谁也管不住的假锁。这让我领悟到一个关于"值与实体"的深刻认知:我们要时刻分清,手里的东西到底是一个"值"还是一个"实体"——"值"的全部意义都写在它的内容里,所以它可以被自由复制,每个副本都是一个完整、独立、等价的它;而"实体"的意义,除了内容,还系于它的"身份"——它是世界上独一无二的那一个,它承载着随时间变化的状态,或代表着某个真实存在的、唯一的资源;对"实体"做拷贝,是一种认知上的错配:你以为你复制了它,其实你只复制了它的"外壳/快照",却复制不了它的"身份和与本体的联系",于是副本成了一个脱离了本体、状态僵死或各行其是的空壳;所以,判断一个东西"能不能拷贝、该拷贝还是该共享",本质上是在判断"它的意义,是在它的内容里,还是在它的身份里"——内容里的,放心拷贝;身份里的,只能共享(传引用/指针),拷贝即背叛。这给了我一种面对"一切'要把某个东西传给别处'之事"时的审慎:每当我要把一个东西传递、存储、复制时,我都先问"它是个纯粹的值,还是个带状态、代表资源、要求唯一的实体?如果是后者,我现在是在共享它,还是在悄悄拷贝出一个会脱离本体的赝品"——值可拷贝、实体须共享,对带状态/带身份的东西一律传引用而非复制;"分清值与实体、实体只共享不拷贝",是写对 Go 并发、也是正确传递一切有状态资源的关键。认清锁不可拷贝、含锁结构体用指针、go vet 兜底——这,是我用一次"加了锁却锁不住、计数永远是 0"的事故,换来的、关于 Go、也关于如何分清一个东西该拷贝还是该共享的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给含锁结构体写方法、或把它传出去时,手指顿一下、补上那个 *,再顺手跑一遍 go vet,那我对着那个"怎么加锁都是 0"的计数器熬的那个通宵,就值了。
—— 别看了 · 2026