症状
工具链会在生产之前告诉你:go vet 报出 assignment copies lock value to c2: counter.Counter contains sync.Mutex。运行期症状比警告更诡异:互斥静默失效(两个 goroutine 同时进入“受保护”段),或 Unlock 在一个看似无人持有的锁上 panic。两者都源于同一事实——锁的状态被复制了:c2 := c1 把使用中的 mutex 复制进新值,随后 c2.mu.Lock() 锁的是副本,c1.mu.Lock() 也照样成功,因为它们是两把不同的锁。经典陷阱是给一个早已按值传递的结构体新加 mutex 字段:编译照旧通过,vet 才是会开火的卫兵。
诊断思路
先跑 go vet 读出复制点(赋值、函数实参或值接收者),那就是修复位置;再全局排查值接收者——含锁或将来可能含锁的结构体上的值接收者都是潜伏违规,指针接收者永不复制;最后用 go test -race -count=20 重复跑,锁副本提供的互斥为零,竞态检测器常能直接演示复制引入的数据竞态。range 循环变量对锁结构体做值拷贝、再对其调用方法,是最隐蔽的复制形态。
根因
sync.Mutex 在首次使用后不可复制——其内部状态编码了是否加锁与持有者。复制包含它的结构体就把锁状态复制进一个新锁,两个 goroutine 各锁各的副本同时进入临界区,Unlock 也可能解锁一把由另一个副本持有的锁。
修复与验证
含锁结构体一律指针接收者;传参与存储都用指针([]*Counter 而不是 []Counter);range 循环按下标操作元素本身(regs[i].Inc())而不是迭代副本;CI 里把 go vet ./… 设为合并门禁,因为这个类别会在某人给值传递结构体加锁字段的那一刻复活。改指针接收者会牵动接口满足与 nil 语义,需要顺带审查调用方的 nil 接收者用法。验证时 vet 清零、竞态套件重复运行零报告、并发压力测试断言互斥真实成立——三关全过才算修复。
代码示例
$ go vet ./...
./src/counter.go:31:9: assignment copies lock value
to c2: counter.Counter contains sync.Mutex
c2 := c1 // copies the mutex mid-use: two locks, zero exclusion
c2.mu.Lock() // locks the COPY, not the original
c1.mu.Lock() // also succeeds: different mutex entirely
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() { // pointer receiver: no copy, real exclusion
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func aggregate(counters []*Counter) int // not []Counter
for _, c := range regs { // c is a COPY if regs is []Counter
c.Inc() // locks the copy
}
for i := range regs { // fix: operate on the element itself
regs[i].Inc()
}