Go 如何通过 sync.noCopy 检测结构体复制
摘要
本文解释了 Go 如何使用 `sync.noCopy` 标记和 `go vet` 工具来检测并警告可能导致并发问题的结构体复制。
暂无内容
查看缓存全文
缓存时间: 2026/08/17 12:50
# Go 如何通过 sync.noCopy 检测结构体复制
来源:https://func25.dev/posts/go-sync-nocopy/
如果你阅读过 `sync` 包的源代码,你可能已经注意到几个结构体包含了一个非同寻常的 `noCopy` 类型字段,例如 `sync.Mutex`、`sync.Once` 和 `sync.Map`:
go
```go
type Mutex struct {
_ noCopy
...
}
type Once struct {
_ noCopy
...
}
type Map struct {
_ noCopy
...
}
```
`noCopy` 是一个特殊的标记,用于指示那些在首次使用后不应被复制的类型。但这个标记本身只是一个空结构体,带有两个空方法:
go
```go
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
```
尽管方法名称如此,这里并没有锁,也不会有任何解锁操作。但此处没有任何机制能阻止我们复制这个值。本文将解释复制后可能破坏什么、为什么 `noCopy` 需要这两个方法,以及如何在你自己的类型中添加相同的标记。
## 1. noCopy 的作用与局限
`noCopy` 标记并没有向 Go 编译器添加任何特殊规则。你仍然可以在使用后复制一个 `sync.Map`:
go
```go
var a sync.Map
a.Store("k", 1)
b := a // 复制一个 sync.Map
```
赋值操作会将 `a` 的所有字段复制到 `b` 中,就像复制任何其他结构体值一样。这段代码仍然能通过 `go build`,因为编译器并不赋予 `noCopy` 这个名字或其 `Lock` 和 `Unlock` 方法任何特殊含义。
原来,警告来自于一个名为 `go vet` 的独立工具。这个静态分析命令是 Go 自带的,用于报告编译器仍然接受但可疑的代码。当 `go vet` 检查相同的赋值操作时,它会报告:
```
assignment copies lock value to b: sync.Map contains sync.noCopy
```
短语 “copies lock value” 源于 `go vet` 中 `copylocks` 检查器的用途。它被创建用来报告包含锁的值的复制,例如 `sync.Mutex`,因为复制一个已使用的锁也会复制其内部状态。
这个行为很容易用一个包含 `sync.Mutex` 的普通结构体来复现:
go
```go
type Counter struct {
mu sync.Mutex
value int
}
func main() {
var a Counter
b := a
_ = b.value
}
```
```
$ go vet ./...
main.go:12:7: assignment copies lock value to b: example.com/nocopy-repro.Counter contains sync.Mutex
```
`Counter` 包含一个真正的互斥锁,因此复制外层结构体也会复制互斥锁的状态。
## 2. go vet 和 noCopy 的工作原理
因此,`go vet` 通过其 `copylocks` 检查器产生此警告,而这个检查器并不搜索名为 `noCopy` 的字段。它在检查被复制的类型及其字段时使用以下规则:
go
```go
if types.Implements(types.NewPointer(typ), lockerType) &&
!types.Implements(typ, lockerType) {
return []string{typ.String()}
}
```
检查器从被复制的类型(例如 `Counter`)开始,询问其指针类型是否实现了 `sync.Locker` 而其值类型没有。如果该类型是一个结构体且答案是否,则检查器会对其每个字段的类型应用相同的规则,包括嵌套结构体内部的字段。对于 `Counter`,这个搜索会在 `mu` 字段中找到 `sync.Mutex`。
COPYLOCKS 规则
*T 实现了 sync.Locker,T 没有实现 sync.Locker*
*Counter → sync.Once:未匹配 → 检查字段 mu → 未匹配 → 检查字段 _ noCopy → sync.Mutex → 规则匹配*
*noCopy → 规则匹配*
检查器对外层类型及其字段类型应用相同的规则。
对于 `sync.Once`,它在 `_ noCopy` 字段中找到了 `noCopy`。这种递归搜索是警告能够报告外层结构体包含锁或 `noCopy` 的原因。
这就是为什么 `noCopy` 标记的方法被命名为 `Lock` 和 `Unlock`。如前所述,上述规则检查一个类型是否实现了 `sync.Locker`。这个接口恰好包含两个方法:
go
```go
type Locker interface {
Lock()
Unlock()
}
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
```
这些名字使得指针类型 `*noCopy` 实现了 `sync.Locker`,而值类型 `noCopy` 则没有。
`noCopy` 是由 VictoriaMetrics (https://github.com/VictoriaMetrics) 的 CTO Aliaksandr Valialkin (https://github.com/golang/go/commit/c81a3532fea42df33dea54497dfaa96873c2d976) 于 2016 年添加到标准库中的,基于 Russ Cox (https://github.com/golang/go/issues/8005#issuecomment-190753527) 提出的模式。这些类型在当时已经不能安全复制了。这个改动让 `go vet` 有办法报告以前遗漏的复制。2016 年的实现只有 `Lock` 方法。2018 年 (https://github.com/golang/go/commit/c2eba53e7f80df21d51285879d51ab81bcfbf6bc),当 `copylocks` 切换到检查 `sync.Locker` 时,`noCopy` 增加了 `Unlock` 方法。
但你可能会注意到 `sync` 包中的一些结构体已经包含了 `sync.Mutex`,例如 `sync.Map`:
go
```go
type Map struct {
mu Mutex
...
}
```
如果 `go vet` 已经能够找到互斥锁,为什么 `sync.Map` 还需要 `_ noCopy`?
`sync.Map` 并非仅仅为了触发警告才需要 `_ noCopy`。`copylocks` 已经可以到达其 `mu Mutex` 字段。`sync.Map`、`sync.Once` 和 `sync.Mutex` 中的显式标记有三个目的。
首先,`_ noCopy` 为每个结构体提供了一个显式标记,这样 `copylocks` 就不依赖于该结构体的具体实现方式。检查器可以直接找到 `_ noCopy`,而不是找到内部的互斥锁或依赖外层类型的 `Lock` 和 `Unlock` 方法。
其次,警告更清晰地描述了复制限制。`sync.Map` 以前的警告命名了内部发现的互斥锁:
```
assignment copies lock value to b: sync.Map contains sync.Mutex
```
当前的警告改为命名 `noCopy`。这个名字直接表明外层类型不应被复制:
```
assignment copies lock value to b: sync.Map contains sync.noCopy
```
第三,该标记修复了一个涉及 `sync.Mutex` 的假阴性问题。假阴性意味着检查器应该报告一个复制却没有报告。检查器通过 `Lock` 和 `Unlock` 方法识别 `sync.Mutex`,但一个新的命名类型不会继承这些方法:
go
```go
type LocalMutex sync.Mutex
```
在显式标记添加到 `sync.Mutex` 之前,`copylocks` 可能会漏掉被复制的 `LocalMutex`。其底层结构体现在包含了 `_ noCopy` 字段,因此即使 `LocalMutex` 没有 `sync.Mutex` 的方法,递归搜索也能找到该标记。
注意
有趣的是,`sync.RWMutex` 是 `sync` 包中唯一一个在首次使用后不应复制但不包含 `noCopy` 字段的导出结构体。但 `go vet` 仍然会捕获直接复制,因为 `*RWMutex` 实现了 `sync.Locker` 而 `RWMutex` 没有。
对于一个丢失了这些方法的新的已定义类型,就像 `LocalMutex` 一样,情况如何?
go
```go
type LocalRWMutex sync.RWMutex
```
它的源代码为检查器提供了另一条路径:
go
```go
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount atomic.Int32
readerWait atomic.Int32
}
```
递归搜索会到达突出显示的 `w` 字段,因此 `copylocks` 仍然会报告 `LocalRWMutex contains sync.Mutex`。旧的 `LocalMutex` 在丢失方法后只有整数字段,这就是为什么该类型需要显式标记来修复其假阴性问题。
但从一致性角度看,这是一个例外。
既然我们理解了标记的工作原理,你的包就可以定义自己的版本。`sync.noCopy` 未导出,因此 `sync` 包外的代码不能直接使用它:
go
```go
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
type Session struct {
_ noCopy
id string
closed bool
}
```
该标记仍然无法阻止编译器复制一个值。`go test` 默认运行多个 vet 检查,但 `copylocks` 不在其中。因此,默认的 `go test ./...` 运行不会提及此警告:
sh
```sh
$ go test ./...
ok example 0.4s
$ go vet ./...
lib.go:7:7: assignment copies lock value to b: example.Session contains example.noCopy
```
许多项目会直接运行 `go vet`,无论是作为开发人员命令还是在 CI 中,这样 `copylocks` 就能报告复制的值。如果你更喜欢通过 `go test` 运行检查,可以显式启用 `copylocks` 检查器,可以单独运行,也可以作为所有 vet 检查的一部分:
sh
```sh
go test -vet=copylocks ./...
# 或者运行所有 vet 检查。
go test -vet=all ./...
```
这两个命令都会在运行测试之前对包的源代码及其测试源文件运行 `vet`。
## 3. 忽略警告会怎样
这个警告背后没有单一的故障模式。不同的类型在复制后可能表现不同。
### 永远无法完成的 WaitGroup
go
```go
func finish(w sync.WaitGroup) { // w 是一个副本
w.Done()
}
var wg sync.WaitGroup
wg.Add(1)
finish(wg)
wg.Wait() // 永远不会返回
```
对 `finish(wg)` 的调用将 `wg` 的字段复制到参数 `w` 中,包括计数值 1。`w.Done()` 只将复制的计数器从 1 变为 0。
原始 wg:计数器1,等待块
调用内的副本:计数器0,Done 修改的是副本
Done 修改的是副本,而原始值仍在等待。
原始计数器保持为 1,因此 `wg.Wait()` 没有理由返回。这是经典案例,也是许多开发者首次遇到该警告的原因。
go
```go
var a sync.Map
a.Store("x", 1)
b := a
b.Store("fromB", 1)
_, seen := a.Load("fromB") // true
```
第一次 `Store` 初始化了 `a` 并给它一个根指针。赋值给 `b` 复制了这个指针,因此 `a` 和 `b` 访问的是同一个 trie(字典树)。如果我们后来在 `b` 上调用 `Store`,新的条目也可能通过 `a` 可见,即使 `a` 和 `b` 是不同的变量。
> 此示例中 `a` 和 `b` 的共享存储源于当前 `sync.Map` 实现中的根指针。由于该复制发生在 map 的首次使用之后,其行为不受保证,未来的实现可能会以不同方式失败。我们将在专门的 `sync.Map` 文章中讨论当前实现。
### 两个复制的变量初始化独立的 trie
当我们在首次 `Store` 之前进行复制时,行为会发生变化:
go
```go
var c sync.Map
d := c // 在使用之前复制
c.Store("fromC", 1)
d.Store("fromD", 1)
_, cSeesD := c.Load("fromD") // false
_, dSeesC := d.Load("fromC") // false
```
因为复制发生在首次使用之前,`c` 没有根指针可以复制到 `d` 中,这是当前 `sync.Map` 实现的一个细节。两个变量都从空开始,当调用 `Store` 时,每个变量都初始化独立的 map 存储。这就是为什么没有变量能看到通过另一个添加的条目。
首次使用后复制:a,b 共享一个存储,"fromB" 两者都写入此处
首次使用前复制:c,d 各自独立存储,"fromC" 和 "fromD" 互不可见
相同的复制根据执行时机的不同,会产生共享或分离的效果。
Go 官方文档中 `sync.Map` 的契约 (https://pkg.go.dev/sync#Map) 允许这种复制:
```
"A Map must not be copied after first use."
```
但 `copylocks` 仍然会报告它,因为检查器不会追踪 map 是否已经被使用过。即便如此,复制 `sync.Map` 通常是个坏主意,因为代码会依赖于顺序相关的行为。
## 4. 字段位置控制结构体大小
`noCopy` 的大小为零,但一个尾部的 `noCopy` 字段仍可能增加其包含结构体的总大小,因为 Go 可能会在它后面添加填充。将字段放在第一位可以避免额外的填充:
go
```go
type plain struct {
n int64
}
type first struct {
_ noCopy
n int64
}
type last struct {
n int64
_ noCopy
}
```
在像 amd64 或 arm64 这样的 64 位平台上使用 `unsafe.Sizeof` 得到:
```
plain 8 bytes
noCopy first 8 bytes
noCopy last 16 bytes
```
在 `first` 中,`noCopy` 大小为零,从偏移量 0 开始。`int64` 也可以从偏移量 0 开始,跨越字节 0 到 7,因此整个结构体使用 8 字节。
noCopy first:noCopy + n(偏移0),n(int64)字节0到7,总大小8字节
零大小字段和 int64 都从偏移量 0 开始。
在 `last` 中,`int64` 跨越字节 0 到 7,将 `noCopy` 放在偏移量 8。一个大小为 8 字节的结构体在相同的偏移量结束,因此 `noCopy` 的地址将位于结构体分配的内存之外。
Go 在该字段后添加 1 字节,然后将总大小向上舍入到结构体的对齐要求。在 amd64 和 arm64 上,对齐要求为 8 字节,因此总大小从 8 增加到 16 字节。在 386 和 arm 上,对齐要求为 4 字节,因此相同结构体的总大小为 12 字节。
noCopy last:
无填充时:n(int64)字节0到7,noCopy(偏移8)在已分配字节之外
有填充时:n(int64)字节0到7,填充字节8到15,noCopy(偏移8),总大小16字节
在 64 位平台上,一个尾部的零大小字段会导致偏移量 8 之后出现填充。
标准库通过将 `noCopy` 放在具有非零大小的字段之前来避免这种大小增加。在 `sync.Map`、`sync.Once`、`sync.Pool` 和 `sync.WaitGroup` 中,它都是第一个字段。
> `atomic.Pointer[T]` 在 `noCopy` 之前还有另一个零大小字段:
go
```go
type Pointer[T any] struct {
_ [0]*T
_ noCopy
v unsafe.Pointer
}
```
`v` 上方的两个字段都具有零大小。存储状态的指针仍然在 `noCopy` 之后。
## 参考资料
- `src/sync/cond.go` (https://go.dev/src/sync/cond.go)
- `src/sync/map.go` (https://go.dev/src/sync/map.go)
- `src/sync/rwmutex.go` (https://go.dev/src/sync/rwmutex.go)
- `src/internal/sync/hashtriemap.go` (https://go.dev/src/internal/sync/hashtriemap.go)
- 引入 `noCopy` 的 2016 年变更 (https://github.com/golang/go/commit/c81a3532fea42df33dea54497dfaa96873c2d976)
- 添加 `Unlock` 的 2018 年变更 (https://github.com/golang/go/commit/c2eba53e7f80df21d51285879d51ab81bcfbf6bc)
- 编译器结构体大小计算 (https://go.dev/src/cmd/compile/internal/types/size.go)
- `copylocks` 分析器 (https://cs.opensource.google/go/x/tools/+/master:go/analysis/passes/copylock/copylock.go)
- `go vet` 文档 (https://pkg.go.dev/cmd/vet)
相似文章
我的极简、内存安全的Go rsync如何规避漏洞
深入探讨极简、内存安全的Go实现的rsync如何避免原始C版本中存在的十几个漏洞,并与OpenBSD的openrsync及纵深防御技术进行对比。
Go中的零拷贝: sendfile、splice 以及 io.Copy 的成本
本文解释了Go的io.Copy如何自动使用sendfile和splice进行TCP上的零拷贝文件传输,并展示了将文件包装成普通的io.Reader(例如用于日志记录)会悄然禁用此优化,导致性能显著下降。基准测试和strace输出说明了这种影响。
Go 中的数据竞争与内存模型
本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。
Go 中过度的空指针检查
一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。
使用unsafe消除Go中的边界检查
本文解释了如何在Go中使用unsafe指针算术来消除编译器无法移除的边界检查,从而提升热点路径的性能。它讨论了边界检查的开销和传统的BCE方法,然后介绍了unsafe方法。