Go 如何通过 sync.noCopy 检测结构体复制

Hacker News Top 工具

摘要

本文解释了 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中的零拷贝: sendfile、splice 以及 io.Copy 的成本

Hacker News Top

本文解释了Go的io.Copy如何自动使用sendfile和splice进行TCP上的零拷贝文件传输,并展示了将文件包装成普通的io.Reader(例如用于日志记录)会悄然禁用此优化,导致性能显著下降。基准测试和strace输出说明了这种影响。

Go 中的数据竞争与内存模型

Lobsters Hottest

本文解释了数据竞争和 Go 内存模型,说明了在 goroutine 中对共享变量的非同步访问可能导致的问题,并讨论了正确的同步方法。

Go 中过度的空指针检查

Lobsters Hottest

一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。

使用unsafe消除Go中的边界检查

Lobsters Hottest

本文解释了如何在Go中使用unsafe指针算术来消除编译器无法移除的边界检查,从而提升热点路径的性能。它讨论了边界检查的开销和传统的BCE方法,然后介绍了unsafe方法。